flaglint 0.5.4 → 0.7.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,88 @@ 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.7.0] - 2026-06-07
9
+
10
+ ### Added
11
+
12
+ - `flaglint audit --cost-estimate` — adds a directional migration-effort estimate to
13
+ audit output. Computes low/high hour ranges from automatable and manual-review call
14
+ counts using configurable planning heuristics.
15
+ - `flaglint audit --hourly-rate <rate>` — adds engineering cost projection
16
+ (`costLow`/`costHigh`) to the estimate. Requires `--cost-estimate`.
17
+ - Migration readiness score displayed in `flaglint audit` terminal output: 0–100 ratio
18
+ of safely automatable calls to total detected LaunchDarkly calls, with grade
19
+ (`ready` | `moderate` | `complex` | `not-applicable`) and progress bar.
20
+ - New docs pages: Migration Readiness concept page and Cost Estimation CLI reference.
21
+
22
+ ---
23
+
24
+ ## [0.6.0] - 2026-06-02
25
+
26
+ ### Added
27
+
28
+ - add flaglint audit command with risk-scored flag debt report
29
+ - migrate homepage into Astro, unify site into single build
30
+ - add dark/light/auto theme toggle + search to homepage
31
+ - add Mermaid diagrams to Safety Model and How FlagLint Works
32
+ - add Mermaid diagrams to Safety Model and How FlagLint Works
33
+ - complete favicon stack for Google Search and mobile
34
+ - replace plain-text architecture diagram with styled HTML flow
35
+ - add JSON-LD SoftwareApplication structured data to homepage
36
+ - align trust and privacy page nav with homepage nav
37
+ - add Further Reading section to quickstart linking to blog posts
38
+ - add cross-links between blog posts
39
+ - rename blog to FlagLint Blog and set focused description
40
+ - add Docs and Blog links to homepage nav and footer
41
+ - add Blog link to marketing homepage nav
42
+ - add blog to flaglint.dev using starlight-blog
43
+ - add terminal demo GIF (scan, migrate --dry-run, validate CI gate)
44
+
45
+ ### Fixed
46
+
47
+ - docs UX series P1-P9 (GIF, TOC, title, sidebar, cards, blog frames)
48
+ - UX and codebase series (A1–A5, B1–B5)
49
+ - sync package-lock.json after rebase (add png-to-ico)
50
+ - UX and codebase series (A1–A5, B1–B5)
51
+ - align dynamic key label with scan reporter terminology
52
+ - restore branded README header and correct docs links
53
+
54
+ ### Changed
55
+
56
+ - trigger cloudflare rebuild
57
+ - untrack www/ build output + fix GIF paths + add Why FlagLint CTA
58
+ - add excerpt markers to both blog posts for blog index truncation
59
+ - rebuild after rebase onto main (PR #83 docs UX changes)
60
+ - rebuild docs after rebase onto main (A1-A5/B1-B5 + favicon state)
61
+ - rebuild docs after rebase onto main (favicon + blog state)
62
+ - rebuild docs site with blog and demo GIF
63
+ - add LinkedIn URL to blog post authors
64
+ - remove debug artifacts from demo fixture folder
65
+ - update version references to 0.5.4
66
+
67
+ ### Other
68
+
69
+ - Merge feat/audit-command: add flaglint audit command
70
+ - Merge pull request #86 from flaglint/feat/docs-ux-fixes
71
+ - Merge pull request #85 from flaglint/fix/blog-excerpt-markers
72
+ - Merge pull request #84 from flaglint/feat/homepage-theme-search
73
+ - Merge pull request #83 from flaglint/fix/complete-series-p1-p12
74
+ - Merge pull request #82 from flaglint/feat/favicon-stack
75
+ - Merge pull request #80 from flaglint/fix/validate-dynamic-key-label
76
+ - Merge pull request #78 from flaglint/feat/architecture-diagram
77
+ - Merge pull request #77 from flaglint/feat/structured-data-jsonld
78
+ - Merge pull request #76 from flaglint/feat/consistent-nav
79
+ - Merge pull request #75 from flaglint/feat/quickstart-further-reading
80
+ - Merge branch 'main' into feat/quickstart-further-reading
81
+ - Merge pull request #74 from flaglint/feat/blog-cross-links
82
+ - Merge pull request #73 from flaglint/feat/blog-title-description
83
+ - Merge pull request #72 from flaglint/feat/nav-footer-blog-docs
84
+ - Merge pull request #71 from flaglint/chore/bump-version-0.5.4
85
+ - Update README.md
86
+ - Merge pull request #70 from flaglint/chore/bump-version-0.5.4
87
+ - Merge pull request #69 from flaglint/chore/bump-version-0.5.4
88
+ - chore/bump-version-0.5.4
89
+
8
90
  ## Unreleased
9
91
 
10
92
  ### Fixed
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  </p>
4
4
 
5
5
  <p align="center">
6
- <strong>Standardize LaunchDarkly usage on OpenFeature.</strong>
6
+ <strong>Find every direct LaunchDarkly SDK call. Migrate safely. Enforce the boundary.</strong>
7
7
  </p>
8
8
 
9
9
  <p align="center">
@@ -21,82 +21,103 @@
21
21
  </a>
22
22
  </p>
23
23
 
24
- # FlagLint
24
+ ---
25
25
 
26
- **Standardize LaunchDarkly usage on OpenFeature.**
26
+ Most teams do not know how many direct LaunchDarkly SDK calls are in their codebase, which ones are safe to migrate, or which ones will silently break if migrated naively. FlagLint answers all three questions before you touch a line of code.
27
27
 
28
- FlagLint inventories direct LaunchDarkly Node.js SDK calls, generates reviewable migration plans,
29
- and prevents new vendor-coupled flag access from entering your codebase.
30
- LaunchDarkly remains your provider. OpenFeature becomes the evaluation API your application code calls.
28
+ ```bash
29
+ npx flaglint audit ./src
30
+ ```
31
31
 
32
- **[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)**
32
+ ```
33
+ ✓ Audit complete: 13 flags — 3 high risk, 10 medium risk
33
34
 
34
- ---
35
+ Migration readiness: 50/100 · moderate
36
+ [█████████████░░░░░░░░░░░░] 50%
37
+ 10 safely automatable · 10 require manual review
38
+ ```
35
39
 
36
- ## Quick start
40
+ Add `--cost-estimate` to include a directional planning estimate:
37
41
 
38
42
  ```bash
39
- npx flaglint scan ./src
43
+ npx flaglint audit ./src --cost-estimate
40
44
  ```
41
45
 
42
46
  ```
43
- Scanning 5 files...
44
- ✔ Found 20 direct LaunchDarkly Node SDK calls across 11 unique flags
45
- ⚡ 6 dynamic flag keys — manual review required
46
- ↳ 2 detail method calls — manual review required
47
+ Audit complete: 13 flags — 3 high risk, 10 medium risk
47
48
 
48
- Run flaglint migrate --dry-run for a reviewable migration diff
49
+ Migration readiness: 50/100 · moderate
50
+ [█████████████░░░░░░░░░░░░] 50%
51
+ 10 safely automatable · 10 require manual review
52
+
53
+ Estimated migration effort: 22.8h – 43.9h
54
+ Estimates are directional. See the report for assumptions.
49
55
  ```
50
56
 
51
- Preview the migration before changing anything:
57
+ For the full report see `--format html`. [Documentation](https://flaglint.dev/docs) · [Quickstart](https://flaglint.dev/docs/quickstart) · [npm](https://npmjs.com/package/flaglint)
52
58
 
53
- ```bash
54
- npx flaglint migrate ./src --dry-run
55
- ```
59
+ No API key. No source upload. LaunchDarkly stays your provider — OpenFeature becomes the evaluation API your application calls.
60
+
61
+ ---
62
+
63
+ ## The problem FlagLint solves
64
+
65
+ The OpenFeature `getBooleanValue(key, defaultValue, context)` API takes arguments in a different order from LaunchDarkly's `boolVariation(key, context, defaultValue)`. A naive find-and-replace silently swaps your fallback and context, producing valid-looking code that evaluates flags incorrectly in production.
66
+
67
+ FlagLint's static analysis proves — before rewriting anything — that the flag key is static, the fallback value and type are known, and a verified OpenFeature client binding is present. If any condition cannot be proven, the call is reported for manual review and left untouched.
56
68
 
57
69
  ---
58
70
 
59
71
  ## Workflow
60
72
 
61
- | Step | Command | What happens |
62
- |------|---------|-------------|
63
- | 1 | `flaglint scan ./src` | AST inventory of every direct LD Node server SDK call |
64
- | 2 | `flaglint migrate --dry-run` | Reviewable before/after diffs; inline provider setup guidance |
65
- | 3 | `flaglint migrate --apply` | Rewrites only guarded, provably automatable call-sites |
66
- | 4 | `flaglint validate --no-direct-launchdarkly` | CI gate: exit 1 if direct LD evaluation calls remain |
73
+ | Step | Command | What it does |
74
+ |------|---------|--------------|
75
+ | 1 | `flaglint audit ./src` | Risk-ranked overview. High / medium / low per flag. No API key needed. |
76
+ | 2 | `flaglint scan ./src` | Detailed file-level structured inventory for automation or review. |
77
+ | 3 | `flaglint migrate ./src --dry-run` | Reviewable before/after diffs. Shows exactly what will change. |
78
+ | 4 | `flaglint migrate ./src --apply` | Rewrites only calls with proven static inputs and a verified OpenFeature binding. |
79
+ | 5 | `flaglint validate ./src --no-direct-launchdarkly` | CI gate — exits 1 if any direct LD evaluation call remains. |
67
80
 
68
81
  ---
69
82
 
70
- ## Before After (real output from enterprise demo)
83
+ ## Before and after
71
84
 
72
85
  ```diff
73
- --- a/checkout.ts
74
- +++ b/checkout.ts
86
+ --- a/src/routes/checkout.ts
87
+ +++ b/src/routes/checkout.ts
75
88
  - return ldClient.boolVariation("checkout-v2", ctx, false);
76
89
  + return openFeatureClient.getBooleanValue("checkout-v2", false, ctx);
77
90
 
78
91
  - return ldClient.stringVariation("payment-provider", ctx, "stripe");
79
92
  + return openFeatureClient.getStringValue("payment-provider", "stripe", ctx);
80
93
 
81
- --- a/pricing.ts
82
- +++ b/pricing.ts
94
+ --- a/src/services/pricing.ts
95
+ +++ b/src/services/pricing.ts
83
96
  - return ldClient.numberVariation("discount-percentage", ctx, 0);
84
97
  + return openFeatureClient.getNumberValue("discount-percentage", 0, ctx);
85
98
  ```
86
99
 
87
- Flag key, fallback value, `await`, and evaluation context are preserved exactly.
100
+ Flag key, fallback value, evaluation context, and `await` are preserved exactly. The LaunchDarkly packages stay — the OpenFeature provider depends on them at runtime.
88
101
 
89
102
  ---
90
103
 
91
- ## Supported scope
104
+ ## What is never auto-rewritten
105
+
106
+ FlagLint is intentionally conservative. These are always skipped and reported for manual review:
92
107
 
93
- LaunchDarkly Node.js server-side SDK calls from `launchdarkly-node-server-sdk` and
94
- `@launchdarkly/node-server-sdk`. Both ESM import and CJS `require()` forms.
108
+ - **Dynamic keys** `ldClient.boolVariation(getFlagKey(user), ctx, false)`
109
+ - **Detail evaluations** `boolVariationDetail`, `variationDetail`
110
+ - **Bulk calls** — `allFlags()`, `allFlagsState()`
111
+ - **Unknown fallback types**
112
+ - **Configured wrappers**
113
+ - **Ambiguous OpenFeature client bindings**
114
+ - **Browser SDKs, React SDKs, non-Node SDKs**
95
115
 
96
- `--apply` rewrites `boolVariation`, `stringVariation`, `numberVariation`, `jsonVariation`
97
- where the flag key, fallback, and OpenFeature client binding are statically explicit.
98
- Detail methods, dynamic keys, bulk calls, and unknown fallback types are reported for manual review.
99
- Browser SDKs, React SDKs, and non-LaunchDarkly providers are outside current scope.
116
+ ---
117
+
118
+ ## Supported scope
119
+
120
+ LaunchDarkly Node.js server-side SDK calls from `@launchdarkly/node-server-sdk` and `launchdarkly-node-server-sdk`. Both ESM and CommonJS. Node.js 20 or newer.
100
121
 
101
122
  Full coverage table: [Supported Scope](https://flaglint.dev/docs/reference/supported-scope)
102
123
 
@@ -104,29 +125,30 @@ Full coverage table: [Supported Scope](https://flaglint.dev/docs/reference/suppo
104
125
 
105
126
  ## Provider setup (one-time, manual)
106
127
 
107
- Before `--apply`, complete bootstrap setup once. Full instructions:
108
- [OpenFeature Provider Setup](https://flaglint.dev/docs/integrations/openfeature-provider)
128
+ Before `migrate --apply`, complete provider bootstrap once:
109
129
 
110
- Key points:
111
- - `new LaunchDarklyProvider(process.env.LD_SDK_KEY!)` SDK key constructor
112
- - Evaluation context accepts either `targetingKey` (OpenFeature-native) or an existing LaunchDarkly `key`
113
- - **Do not remove LaunchDarkly packages** — the OpenFeature provider depends on them at runtime
130
+ ```ts
131
+ import { OpenFeature } from "@openfeature/server-sdk";
132
+ import { LaunchDarklyProvider } from "@launchdarkly/openfeature-node-server";
114
133
 
115
- ---
134
+ await OpenFeature.setProviderAndWait(
135
+ new LaunchDarklyProvider(process.env.LD_SDK_KEY!)
136
+ );
137
+ export const openFeatureClient = OpenFeature.getClient();
138
+ ```
116
139
 
117
- ## Requirements
140
+ Evaluation context accepts either `targetingKey` (OpenFeature-native) or an existing LaunchDarkly `key`. Do not remove LaunchDarkly packages — the OpenFeature provider depends on them at runtime.
118
141
 
119
- Node.js 20 or newer. No LaunchDarkly SDK key or credentials required for scan or migrate.
142
+ Full instructions: [OpenFeature Provider Setup](https://flaglint.dev/docs/tutorials/add-openfeature-provider)
120
143
 
121
144
  ---
122
145
 
123
146
  ## Local analysis
124
147
 
125
- FlagLint runs entirely on your machine. No source code, flag keys, or file paths are
126
- transmitted to any external service. No outbound network connections during scan or migration.
148
+ FlagLint runs entirely on your machine. No source code, flag keys, or file paths leave your environment. No LaunchDarkly API key or credentials are required for `audit`, `scan`, `migrate`, or `validate`.
127
149
 
128
150
  ---
129
151
 
130
152
  ## Links
131
153
 
132
- [Security](./SECURITY.md) · [Contributing](./CONTRIBUTING.md) · [Changelog](./CHANGELOG.md) · [License](./LICENSE) · [Full docs](https://flaglint.dev/docs)
154
+ **[Docs](https://flaglint.dev/docs)** · **[Quickstart](https://flaglint.dev/docs/quickstart)** · **[Blog](https://flaglint.dev/blog)** · [Security](./SECURITY.md) · [Contributing](./CONTRIBUTING.md) · [Changelog](./CHANGELOG.md) · [License](./LICENSE)
@@ -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.7.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),
@@ -1087,6 +1116,89 @@ import { resolve as resolve5 } from "path";
1087
1116
  import chalk2 from "chalk";
1088
1117
  import ora2 from "ora";
1089
1118
 
1119
+ // src/readiness/readiness.ts
1120
+ var ISSUE_SPECS = [
1121
+ {
1122
+ code: "dynamic-key",
1123
+ label: "Dynamic flag keys",
1124
+ explanation: "Flag keys determined at runtime cannot be safely rewritten."
1125
+ },
1126
+ {
1127
+ code: "detail-evaluation",
1128
+ label: "Detail evaluations",
1129
+ explanation: "Detail evaluation methods require manual migration to OpenFeature equivalents."
1130
+ },
1131
+ {
1132
+ code: "bulk-evaluation",
1133
+ label: "Bulk evaluations",
1134
+ explanation: "Bulk flag evaluation calls have no direct OpenFeature equivalent."
1135
+ },
1136
+ {
1137
+ code: "unknown-fallback",
1138
+ label: "Unknown fallback values",
1139
+ explanation: "Calls without a known fallback value type cannot be safely auto-migrated."
1140
+ },
1141
+ {
1142
+ code: "ambiguous-client",
1143
+ label: "Ambiguous client bindings",
1144
+ explanation: "Calls without a proven OpenFeature client binding require manual setup."
1145
+ }
1146
+ ];
1147
+ function gradeFromScore(score) {
1148
+ if (score >= 80) return "ready";
1149
+ if (score >= 50) return "moderate";
1150
+ return "complex";
1151
+ }
1152
+ function computeReadiness(usages, inventory) {
1153
+ const totalCalls = inventory.length > 0 ? inventory.length : usages.length;
1154
+ if (totalCalls === 0) {
1155
+ return {
1156
+ score: null,
1157
+ grade: "not-applicable",
1158
+ totalCalls: 0,
1159
+ automatableCalls: 0,
1160
+ manualReviewCalls: 0,
1161
+ manualReviewBreakdown: []
1162
+ };
1163
+ }
1164
+ const automatableCalls = inventory.length > 0 ? inventory.filter((i) => i.safelyAutomatable).length : usages.filter(
1165
+ (u) => !u.isDynamic && u.callType !== "allFlags" && u.callType !== "allFlagsState" && !u.callType.includes("Detail") && u.callType !== "variationDetail"
1166
+ ).length;
1167
+ const manualReviewCalls = totalCalls - automatableCalls;
1168
+ const score = totalCalls > 0 ? Math.max(0, Math.round(automatableCalls / totalCalls * 100)) : 0;
1169
+ const dynamicKeyCount = inventory.length > 0 ? inventory.filter((i) => i.manualReviewReason === "dynamic-key").length : usages.filter((u) => u.isDynamic).length;
1170
+ const detailEvalCount = inventory.length > 0 ? inventory.filter((i) => i.manualReviewReason === "detail-method").length : usages.filter((u) => u.callType.includes("Detail")).length;
1171
+ const bulkEvalCount = inventory.length > 0 ? inventory.filter((i) => i.manualReviewReason === "bulk-inventory-call").length : usages.filter((u) => u.callType === "allFlags" || u.callType === "allFlagsState").length;
1172
+ const unknownFallbackCount = inventory.length > 0 ? inventory.filter((i) => i.manualReviewReason === "unknown-fallback").length : 0;
1173
+ const ambiguousClientCount = inventory.length > 0 ? inventory.filter((i) => !i.safelyAutomatable && i.manualReviewReason === void 0).length : 0;
1174
+ const occurrences = {
1175
+ "dynamic-key": dynamicKeyCount,
1176
+ "detail-evaluation": detailEvalCount,
1177
+ "bulk-evaluation": bulkEvalCount,
1178
+ "unknown-fallback": unknownFallbackCount,
1179
+ "ambiguous-client": ambiguousClientCount
1180
+ };
1181
+ const manualReviewBreakdown = [];
1182
+ for (const spec of ISSUE_SPECS) {
1183
+ const count = occurrences[spec.code] ?? 0;
1184
+ if (count === 0) continue;
1185
+ manualReviewBreakdown.push({
1186
+ code: spec.code,
1187
+ label: spec.label,
1188
+ count,
1189
+ explanation: spec.explanation
1190
+ });
1191
+ }
1192
+ return {
1193
+ score,
1194
+ grade: gradeFromScore(score),
1195
+ totalCalls,
1196
+ automatableCalls,
1197
+ manualReviewCalls,
1198
+ manualReviewBreakdown
1199
+ };
1200
+ }
1201
+
1090
1202
  // src/migrator/index.ts
1091
1203
  function legacyInventoryFromUsage(usage) {
1092
1204
  const isBulk = usage.flagKey === "*" || usage.callType === "allFlags" || usage.callType === "allFlagsState";
@@ -1165,12 +1277,6 @@ function buildEvidenceItem(item) {
1165
1277
  reviewReason: item.safelyAutomatable ? void 0 : reasonLabel(item.manualReviewReason)
1166
1278
  };
1167
1279
  }
1168
- function calcReadinessScore(items) {
1169
- if (items.length === 0) return 0;
1170
- const safeCount = items.filter((item) => item.safelyAutomatable).length;
1171
- const score = Math.round(safeCount / items.length * 100);
1172
- return safeCount === items.length ? 100 : Math.min(score, 99);
1173
- }
1174
1280
  function calcRequiredPackages(items) {
1175
1281
  return items.some(isServerInventoryItem) ? ["@openfeature/server-sdk"] : [];
1176
1282
  }
@@ -1188,7 +1294,7 @@ function analyze(result) {
1188
1294
  ).length;
1189
1295
  const manualReviewCount = totalLaunchDarklyUsages - safelyAutomatableCount;
1190
1296
  const autoMigrateCount = safelyAutomatableCount;
1191
- const readinessScore = calcReadinessScore(inventoryItems);
1297
+ const readinessScore = computeReadiness(result.usages, inventoryItems).score ?? 0;
1192
1298
  const requiredPackages = calcRequiredPackages(inventoryItems);
1193
1299
  return {
1194
1300
  readinessScore,
@@ -1216,7 +1322,7 @@ function formatMigrationReport(analysis) {
1216
1322
  unsupportedUnknownCount
1217
1323
  } = analysis;
1218
1324
  const date = (/* @__PURE__ */ new Date()).toLocaleDateString();
1219
- const version = true ? "0.5.4" : "0.1.0";
1325
+ const version = true ? "0.7.0" : "0.1.0";
1220
1326
  const lines = [];
1221
1327
  lines.push("# OpenFeature Migration Inventory");
1222
1328
  lines.push(`Generated by FlagLint v${version} on ${date}`);
@@ -1713,7 +1819,7 @@ function validateScanResult(result, options = {}) {
1713
1819
  };
1714
1820
  }
1715
1821
  function violationLabel(v) {
1716
- if (v.isDynamic) return `${v.callType}(dynamic key \u2014 manual review required)`;
1822
+ if (v.isDynamic) return `${v.callType}("(dynamic key)")`;
1717
1823
  if (v.flagKey === "*") return `${v.callType}(bulk inventory)`;
1718
1824
  return `${v.callType}("${v.flagKey}")`;
1719
1825
  }
@@ -1977,10 +2083,724 @@ Examples:
1977
2083
  );
1978
2084
  }
1979
2085
 
2086
+ // src/commands/audit.ts
2087
+ import { writeFile as writeFile5 } from "fs/promises";
2088
+ import { stat as stat4 } from "fs/promises";
2089
+ import { resolve as resolve8 } from "path";
2090
+ import chalk4 from "chalk";
2091
+ import ora4 from "ora";
2092
+
2093
+ // src/auditor/index.ts
2094
+ function scoreFlag(usages, inventoryItems) {
2095
+ const highReasons = [];
2096
+ const mediumReasons = [];
2097
+ if (usages.some((u) => u.isDynamic)) {
2098
+ highReasons.push("dynamic key");
2099
+ }
2100
+ if (inventoryItems.some((i) => i.manualReviewReason === "detail-method")) {
2101
+ highReasons.push("detail evaluation");
2102
+ }
2103
+ if (inventoryItems.some((i) => i.manualReviewReason === "bulk-inventory-call")) {
2104
+ highReasons.push("bulk call");
2105
+ }
2106
+ if (usages.some((u) => u.stalenessSignals.length > 0)) {
2107
+ highReasons.push("stale signal");
2108
+ }
2109
+ if (usages.some((u) => u.callType === "variation" && u.isDynamic)) {
2110
+ if (!highReasons.includes("wrapper usage")) {
2111
+ highReasons.push("wrapper usage");
2112
+ }
2113
+ }
2114
+ if (usages.some(
2115
+ (u) => u.callType === "hook-useFlags" || u.callType === "hook-useLDClient" || u.callType === "hoc" || u.callType === "provider"
2116
+ )) {
2117
+ highReasons.push("react/browser hook");
2118
+ }
2119
+ if (highReasons.length > 0) {
2120
+ return { riskLevel: "high", riskReasons: highReasons };
2121
+ }
2122
+ if (inventoryItems.some((i) => i.safelyAutomatable)) {
2123
+ mediumReasons.push("safely automatable");
2124
+ }
2125
+ if (inventoryItems.some((i) => i.valueType === "object")) {
2126
+ mediumReasons.push("json variation");
2127
+ }
2128
+ if (inventoryItems.some((i) => i.manualReviewReason === "unknown-fallback")) {
2129
+ mediumReasons.push("unknown fallback");
2130
+ }
2131
+ if (mediumReasons.length > 0) {
2132
+ return { riskLevel: "medium", riskReasons: mediumReasons };
2133
+ }
2134
+ return { riskLevel: "low", riskReasons: [] };
2135
+ }
2136
+ function buildAuditReport(scanResult, inventoryItems) {
2137
+ const usagesByFlag = /* @__PURE__ */ new Map();
2138
+ for (const u of scanResult.usages) {
2139
+ const key = u.flagKey;
2140
+ if (!usagesByFlag.has(key)) usagesByFlag.set(key, []);
2141
+ usagesByFlag.get(key).push(u);
2142
+ }
2143
+ const inventoryByFlag = /* @__PURE__ */ new Map();
2144
+ for (const item of inventoryItems) {
2145
+ const key = item.staticFlagKey ?? "*";
2146
+ if (!inventoryByFlag.has(key)) inventoryByFlag.set(key, []);
2147
+ inventoryByFlag.get(key).push(item);
2148
+ }
2149
+ const allFlagKeys = /* @__PURE__ */ new Set([...usagesByFlag.keys()]);
2150
+ const flags = [];
2151
+ for (const flagKey of allFlagKeys) {
2152
+ const usages = usagesByFlag.get(flagKey) ?? [];
2153
+ const items = inventoryByFlag.get(flagKey) ?? [];
2154
+ const { riskLevel, riskReasons } = scoreFlag(usages, items);
2155
+ const files = [...new Set(usages.map((u) => u.file))];
2156
+ const callTypes = [...new Set(usages.map((u) => u.callType))];
2157
+ flags.push({
2158
+ flagKey,
2159
+ riskLevel,
2160
+ riskReasons,
2161
+ callTypes,
2162
+ fileCount: files.length,
2163
+ usageCount: usages.length,
2164
+ safelyAutomatable: items.some((i) => i.safelyAutomatable),
2165
+ hasStaleSignal: usages.some((u) => u.stalenessSignals.length > 0),
2166
+ isDynamic: usages.some((u) => u.isDynamic),
2167
+ files
2168
+ });
2169
+ }
2170
+ const riskOrder = { high: 0, medium: 1, low: 2 };
2171
+ flags.sort((a, b) => {
2172
+ const levelDiff = riskOrder[a.riskLevel] - riskOrder[b.riskLevel];
2173
+ if (levelDiff !== 0) return levelDiff;
2174
+ return b.usageCount - a.usageCount;
2175
+ });
2176
+ const highRisk = flags.filter((f) => f.riskLevel === "high").length;
2177
+ const mediumRisk = flags.filter((f) => f.riskLevel === "medium").length;
2178
+ const lowRisk = flags.filter((f) => f.riskLevel === "low").length;
2179
+ const summary = {
2180
+ totalFlags: flags.length,
2181
+ highRisk,
2182
+ mediumRisk,
2183
+ lowRisk,
2184
+ totalUsages: scanResult.totalUsages,
2185
+ dynamicKeys: scanResult.usages.filter((u) => u.isDynamic).length,
2186
+ detailEvaluations: inventoryItems.filter((i) => i.manualReviewReason === "detail-method").length,
2187
+ bulkCalls: inventoryItems.filter((i) => i.manualReviewReason === "bulk-inventory-call").length,
2188
+ wrapperUsages: scanResult.usages.filter((u) => u.callType === "variation").length,
2189
+ staleSignals: scanResult.usages.filter((u) => u.stalenessSignals.length > 0).length,
2190
+ safelyAutomatable: inventoryItems.filter((i) => i.safelyAutomatable).length,
2191
+ manualReview: inventoryItems.filter((i) => !i.safelyAutomatable).length,
2192
+ scannedFiles: scanResult.scannedFiles,
2193
+ scanDurationMs: scanResult.scanDurationMs,
2194
+ scannedAt: scanResult.scannedAt,
2195
+ scanRoot: scanResult.scanRoot
2196
+ };
2197
+ const readiness = computeReadiness(scanResult.usages, inventoryItems);
2198
+ return { summary, flags, readiness };
2199
+ }
2200
+
2201
+ // src/readiness/readiness-bar.ts
2202
+ function renderReadinessBar(score) {
2203
+ const filled = Math.round(score / 100 * 25);
2204
+ const empty = 25 - filled;
2205
+ return "[" + "\u2588".repeat(filled) + "\u2591".repeat(empty) + "] " + score + "%";
2206
+ }
2207
+
2208
+ // src/auditor/reporter.ts
2209
+ function esc2(s) {
2210
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
2211
+ }
2212
+ function displayFlagKey(flag) {
2213
+ return flag.isDynamic ? "<dynamic key>" : flag.flagKey;
2214
+ }
2215
+ function formatAuditJson(report, options) {
2216
+ const { summary, flags, readiness } = report;
2217
+ const obj = { summary, flags, readiness };
2218
+ if (options !== void 0) {
2219
+ obj["estimate"] = options.estimate ?? null;
2220
+ }
2221
+ return JSON.stringify(obj, null, 2);
2222
+ }
2223
+ function formatAuditMarkdown(report, options) {
2224
+ const { summary, flags, readiness } = report;
2225
+ const lines = [];
2226
+ lines.push("# FlagLint Audit Report");
2227
+ lines.push("");
2228
+ lines.push(`**Scanned at:** ${summary.scannedAt} `);
2229
+ lines.push(`**Scan root:** ${summary.scanRoot} `);
2230
+ lines.push(`**Files scanned:** ${summary.scannedFiles} `);
2231
+ lines.push(`**Duration:** ${summary.scanDurationMs}ms`);
2232
+ lines.push("");
2233
+ lines.push("## Summary");
2234
+ lines.push("");
2235
+ if (summary.lowRisk > 0) {
2236
+ lines.push("| Total Flags | High Risk | Medium Risk | Low Risk | Total Usages |");
2237
+ lines.push("|-------------|-----------|-------------|----------|--------------|");
2238
+ lines.push(
2239
+ `| ${summary.totalFlags} | ${summary.highRisk} | ${summary.mediumRisk} | ${summary.lowRisk} | ${summary.totalUsages} |`
2240
+ );
2241
+ } else {
2242
+ lines.push("| Total Flags | High Risk | Medium Risk | Total Usages |");
2243
+ lines.push("|-------------|-----------|-------------|--------------|");
2244
+ lines.push(
2245
+ `| ${summary.totalFlags} | ${summary.highRisk} | ${summary.mediumRisk} | ${summary.totalUsages} |`
2246
+ );
2247
+ }
2248
+ lines.push("");
2249
+ lines.push(
2250
+ "| Dynamic Keys | Detail Evals | Bulk Calls | Stale Signals | Safely Automatable | Manual Review |"
2251
+ );
2252
+ lines.push(
2253
+ "|--------------|--------------|------------|---------------|-------------------|---------------|"
2254
+ );
2255
+ lines.push(
2256
+ `| ${summary.dynamicKeys} | ${summary.detailEvaluations} | ${summary.bulkCalls} | ${summary.staleSignals} | ${summary.safelyAutomatable} | ${summary.manualReview} |`
2257
+ );
2258
+ lines.push("");
2259
+ lines.push("## Migration Readiness");
2260
+ lines.push("");
2261
+ if (readiness.grade === "not-applicable") {
2262
+ lines.push("Migration readiness: **N/A** \u2014 no direct LaunchDarkly calls detected.");
2263
+ } else {
2264
+ lines.push(`Migration readiness: **${readiness.score}/100** \xB7 ${readiness.grade}`);
2265
+ lines.push("");
2266
+ lines.push(renderReadinessBar(readiness.score));
2267
+ lines.push("");
2268
+ lines.push(
2269
+ `${readiness.automatableCalls} safely automatable \xB7 ${readiness.manualReviewCalls} require manual review`
2270
+ );
2271
+ }
2272
+ lines.push("");
2273
+ if (options?.estimate !== void 0) {
2274
+ const est = options.estimate;
2275
+ lines.push("## Estimated Migration Effort");
2276
+ lines.push("");
2277
+ if (est === null) {
2278
+ lines.push("N/A \u2014 no direct LaunchDarkly calls detected.");
2279
+ } else {
2280
+ lines.push("| | Low | High |");
2281
+ lines.push("|---|---|---|");
2282
+ for (const item of est.breakdown) {
2283
+ const callsLabel = item.calls > 0 ? ` (${item.calls} calls)` : "";
2284
+ lines.push(`| ${item.label}${callsLabel} | ${item.hoursLow}h | ${item.hoursHigh}h |`);
2285
+ }
2286
+ lines.push(`| **Total** | **${est.hoursLow}h** | **${est.hoursHigh}h** |`);
2287
+ lines.push("");
2288
+ if (est.costLow !== void 0 && est.costHigh !== void 0) {
2289
+ const fmt = (n) => "$" + n.toLocaleString("en-US");
2290
+ lines.push(`Estimated cost: **${fmt(est.costLow)} \u2013 ${fmt(est.costHigh)}** (at $${est.hourlyRate}/hr)`);
2291
+ lines.push("");
2292
+ }
2293
+ lines.push(`> ${est.disclaimer}`);
2294
+ lines.push("");
2295
+ lines.push("_Assumptions: configurable planning heuristics, not observed industry benchmarks._");
2296
+ }
2297
+ lines.push("");
2298
+ }
2299
+ lines.push("## Flag Debt Inventory");
2300
+ lines.push("");
2301
+ lines.push("| Flag Key | Risk | Usages | Files | Call Types | Reasons |");
2302
+ lines.push("|----------|------|--------|-------|------------|---------|");
2303
+ const riskLabel = {
2304
+ high: "\u{1F534} High",
2305
+ medium: "\u{1F7E1} Medium",
2306
+ low: "\u{1F7E2} Low"
2307
+ };
2308
+ for (const flag of flags) {
2309
+ const reasons = flag.riskReasons.join(", ") || "\u2014";
2310
+ const flagKey = displayFlagKey(flag);
2311
+ lines.push(
2312
+ `| \`${flagKey}\` | ${riskLabel[flag.riskLevel]} | ${flag.usageCount} | ${flag.fileCount} | ${flag.callTypes.join(", ")} | ${reasons} |`
2313
+ );
2314
+ }
2315
+ lines.push("");
2316
+ lines.push("## Next Steps");
2317
+ lines.push("");
2318
+ lines.push(
2319
+ "- Run `flaglint migrate --dry-run` to preview safe OpenFeature rewrites"
2320
+ );
2321
+ lines.push(
2322
+ "- Run `flaglint validate --no-direct-launchdarkly` to enforce OF boundary in CI"
2323
+ );
2324
+ lines.push(
2325
+ "- Review HIGH risk flags manually before any automated migration"
2326
+ );
2327
+ lines.push("");
2328
+ return lines.join("\n");
2329
+ }
2330
+ function formatAuditHtml(report, options) {
2331
+ const { summary, flags, readiness } = report;
2332
+ const version = true ? "0.7.0" : "0.1.0";
2333
+ const date = new Date(summary.scannedAt).toLocaleString();
2334
+ const riskBadge = (level) => {
2335
+ if (level === "high")
2336
+ return '<span class="badge badge-high">High</span>';
2337
+ if (level === "medium")
2338
+ return '<span class="badge badge-medium">Medium</span>';
2339
+ return '<span class="badge badge-low">Low</span>';
2340
+ };
2341
+ const rows = flags.map((f) => {
2342
+ const reasons = f.riskReasons.length > 0 ? esc2(f.riskReasons.join(", ")) : "\u2014";
2343
+ const flagKey = displayFlagKey(f);
2344
+ return `<tr>
2345
+ <td><code>${esc2(flagKey)}</code></td>
2346
+ <td>${riskBadge(f.riskLevel)}</td>
2347
+ <td>${f.usageCount}</td>
2348
+ <td>${f.fileCount}</td>
2349
+ <td>${f.callTypes.map(esc2).join(", ")}</td>
2350
+ <td>${reasons}</td>
2351
+ </tr>`;
2352
+ }).join("\n ");
2353
+ const readinessScore = readiness.score ?? 0;
2354
+ const readinessColor = readiness.grade === "not-applicable" ? "#6c757d" : readinessScore >= 80 ? "#16a34a" : readinessScore >= 50 ? "#d97706" : "#dc2626";
2355
+ const readinessFillPct = readinessScore;
2356
+ let estimateSection = "";
2357
+ if (options?.estimate !== void 0) {
2358
+ const est = options.estimate;
2359
+ if (est === null) {
2360
+ estimateSection = `
2361
+ <h2>Estimated Migration Effort</h2>
2362
+ <div class="estimate-block"><p style="color:var(--muted)">N/A \u2014 no direct LaunchDarkly calls detected.</p></div>`;
2363
+ } else {
2364
+ const fmtCost = (n) => "$" + n.toLocaleString("en-US");
2365
+ const costLine = est.costLow !== void 0 && est.costHigh !== void 0 ? `<div class="estimate-cost">${fmtCost(est.costLow)} \u2013 ${fmtCost(est.costHigh)} <span style="font-weight:400;font-size:.8em">at $${est.hourlyRate}/hr</span></div>` : "";
2366
+ const bRows = est.breakdown.map((item) => {
2367
+ const callsLabel = item.calls > 0 ? ` <span style="color:var(--muted);font-size:.85em">(${item.calls} calls)</span>` : "";
2368
+ return `<tr><td>${esc2(item.label)}${callsLabel}</td><td>${item.hoursLow}h</td><td>${item.hoursHigh}h</td><td style="color:var(--muted);font-size:.8em">${esc2(item.basis)}</td></tr>`;
2369
+ }).join("\n ");
2370
+ const { assumptions } = est;
2371
+ estimateSection = `
2372
+ <h2>Estimated Migration Effort</h2>
2373
+ <div class="estimate-block">
2374
+ <div class="estimate-total">${est.hoursLow}h \u2013 ${est.hoursHigh}h</div>
2375
+ ${costLine}
2376
+ <table style="margin-top:.75rem">
2377
+ <thead><tr><th>Work Item</th><th>Low</th><th>High</th><th>Basis</th></tr></thead>
2378
+ <tbody>
2379
+ ${bRows}
2380
+ </tbody>
2381
+ </table>
2382
+ <details style="margin-top:.75rem;font-size:.8125rem">
2383
+ <summary style="cursor:pointer;color:var(--muted);user-select:none">Estimation assumptions</summary>
2384
+ <table style="margin-top:.5rem">
2385
+ <thead><tr><th>Parameter</th><th>Value</th></tr></thead>
2386
+ <tbody>
2387
+ <tr><td>Automatable call</td><td>${assumptions.automationHoursPerCall}h</td></tr>
2388
+ <tr><td>Manual-review call</td><td>${assumptions.manualReviewHoursPerCall}h</td></tr>
2389
+ <tr><td>Validation</td><td>${assumptions.validationMultiplier * 100}% of migration work</td></tr>
2390
+ <tr><td>Minimum estimate</td><td>${assumptions.minimumHours}h</td></tr>
2391
+ </tbody>
2392
+ </table>
2393
+ <p style="margin-top:.5rem;color:var(--muted)">These are configurable planning heuristics, not observed industry benchmarks.</p>
2394
+ </details>
2395
+ <p class="estimate-disclaimer">${esc2(est.disclaimer)}</p>
2396
+ </div>`;
2397
+ }
2398
+ }
2399
+ const breakdownRows = readiness.manualReviewBreakdown.map(
2400
+ (d) => `<tr><td>${esc2(d.label)}</td><td>${d.count}</td><td style="color:var(--muted);font-size:.8em">${esc2(d.explanation)}</td></tr>`
2401
+ ).join("\n ");
2402
+ return `<!DOCTYPE html>
2403
+ <html lang="en">
2404
+ <head>
2405
+ <meta charset="UTF-8">
2406
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
2407
+ <title>FlagLint Audit Report</title>
2408
+ <style>
2409
+ :root{--bg:#fff;--surface:#f8f9fa;--border:#dee2e6;--text:#212529;--muted:#6c757d;--card-shadow:0 1px 3px rgba(0,0,0,.1)}
2410
+ @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)}}
2411
+ *{box-sizing:border-box;margin:0;padding:0}
2412
+ 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}
2413
+ h1{font-size:1.75rem;margin-bottom:.25rem}
2414
+ h2{font-size:1.125rem;margin:2rem 0 .75rem;padding-bottom:.5rem;border-bottom:1px solid var(--border)}
2415
+ .subtitle{color:var(--muted);margin-bottom:1.5rem;font-size:.875rem}
2416
+ .cards{display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:2rem}
2417
+ .card{flex:1;min-width:140px;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:1rem;box-shadow:var(--card-shadow)}
2418
+ .card-num{font-size:1.875rem;font-weight:700;line-height:1}
2419
+ .card-num.red{color:#dc2626}
2420
+ .card-num.amber{color:#d97706}
2421
+ .card-num.green{color:#16a34a}
2422
+ .card-num.blue{color:#3b82f6}
2423
+ .card-num.purple{color:#7c3aed}
2424
+ .card-num.orange{color:#ea580c}
2425
+ .card-label{color:var(--muted);font-size:.75rem;margin-top:.375rem;text-transform:uppercase;letter-spacing:.05em}
2426
+ .readiness-block{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:1.25rem;margin-bottom:1.5rem}
2427
+ .readiness-score-line{display:flex;align-items:baseline;gap:.75rem;margin-bottom:.75rem}
2428
+ .readiness-score-num{font-size:2.5rem;font-weight:700;line-height:1}
2429
+ .readiness-grade{font-size:.875rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em}
2430
+ .readiness-bar-track{height:12px;background:var(--border);border-radius:6px;overflow:hidden;margin-bottom:.75rem}
2431
+ .readiness-bar-fill{height:100%;border-radius:6px;transition:width .3s}
2432
+ .readiness-stats{display:flex;gap:2rem;font-size:.875rem;color:var(--muted);flex-wrap:wrap}
2433
+ .readiness-norm{margin-top:.5rem;font-size:.8em;color:var(--muted)}
2434
+ table{width:100%;border-collapse:collapse;font-size:.8125rem}
2435
+ th{text-align:left;padding:.625rem .75rem;background:var(--surface);border-bottom:2px solid var(--border);font-weight:600;white-space:nowrap}
2436
+ td{padding:.625rem .75rem;border-bottom:1px solid var(--border);vertical-align:top}
2437
+ code{font-family:ui-monospace,monospace;font-size:.8em;background:var(--surface);padding:.1em .3em;border-radius:3px}
2438
+ .badge{display:inline-block;padding:.2em .6em;border-radius:4px;font-size:.75rem;font-weight:600}
2439
+ .badge-high{background:#fee2e2;color:#991b1b}
2440
+ .badge-medium{background:#fef3c7;color:#92400e}
2441
+ .badge-low{background:#dcfce7;color:#166534}
2442
+ @media(prefers-color-scheme:dark){
2443
+ .badge-high{background:#7f1d1d;color:#fca5a5}
2444
+ .badge-medium{background:#78350f;color:#fcd34d}
2445
+ .badge-low{background:#14532d;color:#86efac}
2446
+ }
2447
+ .estimate-block{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:1.25rem;margin-bottom:1.5rem}
2448
+ .estimate-total{font-size:1.5rem;font-weight:700;margin-bottom:.75rem}
2449
+ .estimate-cost{font-size:1rem;font-weight:600;margin:.75rem 0;color:#3b82f6}
2450
+ .estimate-disclaimer{margin-top:.75rem;font-size:.75rem;color:var(--muted);font-style:italic}
2451
+ .steps{margin:.75rem 0 1rem 1.25rem;line-height:2}
2452
+ footer{margin-top:3rem;padding-top:1rem;border-top:1px solid var(--border);color:var(--muted);font-size:.75rem;text-align:center}
2453
+ footer a{color:var(--muted)}
2454
+ </style>
2455
+ </head>
2456
+ <body>
2457
+ <h1>FlagLint Audit Report</h1>
2458
+ <p class="subtitle">
2459
+ ${esc2(summary.scanRoot)} &middot; ${esc2(summary.scannedFiles.toString())} files &middot; ${esc2(summary.scanDurationMs.toString())}ms &middot; ${esc2(date)}
2460
+ </p>
2461
+
2462
+ <h2>Summary</h2>
2463
+ <div class="cards">
2464
+ <div class="card"><div class="card-num">${summary.totalFlags}</div><div class="card-label">Total Flags</div></div>
2465
+ <div class="card"><div class="card-num red">${summary.highRisk}</div><div class="card-label">High Risk</div></div>
2466
+ <div class="card"><div class="card-num amber">${summary.mediumRisk}</div><div class="card-label">Medium Risk</div></div>
2467
+ ${summary.lowRisk > 0 ? `<div class="card"><div class="card-num green">${summary.lowRisk}</div><div class="card-label">Low Risk</div></div>` : ""}
2468
+ <div class="card"><div class="card-num purple">${summary.safelyAutomatable}</div><div class="card-label">Safely Automatable</div></div>
2469
+ <div class="card"><div class="card-num orange">${summary.manualReview}</div><div class="card-label">Manual Review</div></div>
2470
+ </div>
2471
+
2472
+ <h2>Migration Readiness</h2>
2473
+ <div class="readiness-block">
2474
+ ${readiness.grade === "not-applicable" ? `
2475
+ <div class="readiness-stats">N/A \u2014 no direct LaunchDarkly calls detected.</div>` : `
2476
+ <div class="readiness-score-line">
2477
+ <span class="readiness-score-num" style="color:${readinessColor}">${readinessScore}</span>
2478
+ <span style="color:var(--muted);font-size:1.25rem">/100</span>
2479
+ <span class="readiness-grade" style="color:${readinessColor}">${readiness.grade}</span>
2480
+ </div>
2481
+ <div class="readiness-bar-track">
2482
+ <div class="readiness-bar-fill" style="width:${readinessFillPct}%;background:${readinessColor}"></div>
2483
+ </div>
2484
+ <div class="readiness-stats">
2485
+ <span>${readiness.automatableCalls} safely automatable</span>
2486
+ <span>${readiness.manualReviewCalls} require manual review</span>
2487
+ <span>${readiness.totalCalls} total calls</span>
2488
+ </div>
2489
+ ${readiness.manualReviewBreakdown.length > 0 ? `
2490
+ <table style="margin-top:1rem">
2491
+ <thead><tr><th>Issue</th><th>Occurrences</th><th>Explanation</th></tr></thead>
2492
+ <tbody>
2493
+ ${breakdownRows}
2494
+ </tbody>
2495
+ </table>` : ""}`}
2496
+ </div>
2497
+ ${estimateSection}
2498
+
2499
+ <h2>Flag Debt Inventory</h2>
2500
+ <table>
2501
+ <thead>
2502
+ <tr>
2503
+ <th>Flag Key</th>
2504
+ <th>Risk</th>
2505
+ <th>Usages</th>
2506
+ <th>Files</th>
2507
+ <th>Call Types</th>
2508
+ <th>Risk Reasons</th>
2509
+ </tr>
2510
+ </thead>
2511
+ <tbody>
2512
+ ${rows}
2513
+ </tbody>
2514
+ </table>
2515
+
2516
+ <h2>Next Steps</h2>
2517
+ <ol class="steps">
2518
+ <li>Run <code>flaglint migrate --dry-run</code> to preview safe OpenFeature rewrites</li>
2519
+ <li>Run <code>flaglint validate --no-direct-launchdarkly</code> to enforce OF boundary in CI</li>
2520
+ <li>Review HIGH risk flags manually before any automated migration</li>
2521
+ </ol>
2522
+
2523
+ <footer>
2524
+ Generated by <a href="https://flaglint.dev">FlagLint</a> ${esc2(version)}
2525
+ </footer>
2526
+ </body>
2527
+ </html>`;
2528
+ }
2529
+
2530
+ // src/estimate/estimate.ts
2531
+ var ESTIMATE_DISCLAIMER = "Estimates are directional planning guides based on call-site complexity. Actual effort depends on test coverage, team familiarity, and provider setup. FlagLint does not access runtime data or LaunchDarkly billing.";
2532
+ var DEFAULT_ASSUMPTIONS = {
2533
+ automationHoursPerCall: 0.25,
2534
+ manualReviewHoursPerCall: 1.5,
2535
+ validationMultiplier: 0.3,
2536
+ minimumHours: 4
2537
+ };
2538
+ function round1(n) {
2539
+ return Math.round(n * 10) / 10;
2540
+ }
2541
+ function computeEstimate(readiness, overrides, hourlyRate) {
2542
+ if (readiness.grade === "not-applicable") return null;
2543
+ const assumptions = { ...DEFAULT_ASSUMPTIONS, ...overrides };
2544
+ const { automatableCalls, manualReviewCalls } = readiness;
2545
+ const automationLow = automatableCalls * assumptions.automationHoursPerCall;
2546
+ const automationHigh = automationLow * 1.5;
2547
+ const manualLow = manualReviewCalls * assumptions.manualReviewHoursPerCall;
2548
+ const manualHigh = manualLow * 2;
2549
+ const validationLow = (automationLow + manualLow) * assumptions.validationMultiplier;
2550
+ const validationHigh = (automationHigh + manualHigh) * assumptions.validationMultiplier;
2551
+ const rawLow = automationLow + manualLow + validationLow;
2552
+ const rawHigh = automationHigh + manualHigh + validationHigh;
2553
+ const hoursLow = Math.max(assumptions.minimumHours, round1(rawLow));
2554
+ const hoursHigh = Math.max(assumptions.minimumHours, round1(rawHigh));
2555
+ const breakdown = [
2556
+ {
2557
+ label: "Automatable calls",
2558
+ calls: automatableCalls,
2559
+ hoursLow: round1(automationLow),
2560
+ hoursHigh: round1(automationHigh),
2561
+ basis: `${assumptions.automationHoursPerCall}h per automatable call`
2562
+ },
2563
+ {
2564
+ label: "Manual review calls",
2565
+ calls: manualReviewCalls,
2566
+ hoursLow: round1(manualLow),
2567
+ hoursHigh: round1(manualHigh),
2568
+ basis: `${assumptions.manualReviewHoursPerCall}h per manual-review call`
2569
+ },
2570
+ {
2571
+ label: "Validation & testing",
2572
+ calls: 0,
2573
+ hoursLow: round1(validationLow),
2574
+ hoursHigh: round1(validationHigh),
2575
+ basis: `${assumptions.validationMultiplier * 100}% of migration work`
2576
+ }
2577
+ ];
2578
+ const result = {
2579
+ hoursLow,
2580
+ hoursHigh,
2581
+ breakdown,
2582
+ assumptions,
2583
+ disclaimer: ESTIMATE_DISCLAIMER
2584
+ };
2585
+ if (hourlyRate !== void 0) {
2586
+ result.hourlyRate = hourlyRate;
2587
+ result.costLow = Math.round(hoursLow * hourlyRate);
2588
+ result.costHigh = Math.round(hoursHigh * hourlyRate);
2589
+ }
2590
+ return result;
2591
+ }
2592
+
2593
+ // src/commands/audit.ts
2594
+ var VALID_AUDIT_FORMATS = ["json", "markdown", "html"];
2595
+ function registerAuditCommand(program2) {
2596
+ 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/)").option("--cost-estimate", "include a migration effort estimate in the report. The default assumptions are configurable planning heuristics, not observed industry benchmarks.").option("--hourly-rate <rate>", "hourly rate for cost projection (requires --cost-estimate)").addHelpText(
2597
+ "after",
2598
+ `
2599
+ Examples:
2600
+ $ flaglint audit generate flag debt audit report
2601
+ $ flaglint audit --format html shareable HTML report
2602
+ $ flaglint audit --output audit.md save to file
2603
+ $ flaglint audit --cost-estimate include migration effort estimate
2604
+ $ flaglint audit --cost-estimate --hourly-rate 125 include cost projection`
2605
+ ).action(
2606
+ async (dir, options) => {
2607
+ if (!VALID_AUDIT_FORMATS.includes(options.format)) {
2608
+ process.stderr.write(
2609
+ chalk4.red(
2610
+ `Error: Invalid format '${options.format}'. Must be one of: ${VALID_AUDIT_FORMATS.join(", ")}
2611
+ `
2612
+ )
2613
+ );
2614
+ process.exit(2);
2615
+ }
2616
+ let hourlyRate;
2617
+ if (options.hourlyRate !== void 0) {
2618
+ if (!options.costEstimate) {
2619
+ process.stderr.write(
2620
+ chalk4.yellow("warn: --hourly-rate has no effect without --cost-estimate\n")
2621
+ );
2622
+ } else {
2623
+ const parsed = Number(options.hourlyRate);
2624
+ if (!Number.isFinite(parsed) || parsed <= 0) {
2625
+ process.stderr.write(
2626
+ chalk4.red(
2627
+ `Error: --hourly-rate must be a positive number, got: ${options.hourlyRate}
2628
+ `
2629
+ )
2630
+ );
2631
+ process.exit(2);
2632
+ }
2633
+ hourlyRate = parsed;
2634
+ }
2635
+ }
2636
+ try {
2637
+ const s = await stat4(resolve8(dir));
2638
+ if (!s.isDirectory()) {
2639
+ process.stderr.write(chalk4.red(`Error: Not a directory: ${dir}
2640
+ `));
2641
+ process.exit(1);
2642
+ }
2643
+ } catch (err) {
2644
+ const code = err.code;
2645
+ if (code === "ENOENT") {
2646
+ process.stderr.write(chalk4.red(`Error: Directory not found: ${dir}
2647
+ `));
2648
+ } else if (code === "EACCES") {
2649
+ process.stderr.write(chalk4.red(`Error: Permission denied: ${dir}
2650
+ `));
2651
+ } else {
2652
+ process.stderr.write(chalk4.red(`Error: Cannot access directory: ${dir}
2653
+ `));
2654
+ }
2655
+ process.exit(1);
2656
+ }
2657
+ let config;
2658
+ try {
2659
+ config = await loadConfig(options.config);
2660
+ } catch (err) {
2661
+ process.stderr.write(
2662
+ chalk4.red(String(err instanceof Error ? err.message : err)) + "\n"
2663
+ );
2664
+ process.exit(1);
2665
+ }
2666
+ const TEST_PATTERNS = [
2667
+ "**/*.test.ts",
2668
+ "**/*.test.tsx",
2669
+ "**/*.spec.ts",
2670
+ "**/*.spec.tsx",
2671
+ "**/__tests__/**",
2672
+ "**/tests/**"
2673
+ ];
2674
+ const scanConfig = options.excludeTests ? { ...config, exclude: [...config.exclude, ...TEST_PATTERNS] } : config;
2675
+ const format = options.format;
2676
+ const spinner = ora4(`Auditing ${dir}...`).start();
2677
+ process.once("SIGINT", () => {
2678
+ spinner.stop();
2679
+ process.exit(130);
2680
+ });
2681
+ let lastSpinnerUpdate = 0;
2682
+ let scanResult;
2683
+ try {
2684
+ scanResult = await scan(new LocalFileSource(dir), scanConfig, (filesScanned) => {
2685
+ if (filesScanned - lastSpinnerUpdate >= 50) {
2686
+ spinner.text = `Scanning... (${filesScanned} files)`;
2687
+ lastSpinnerUpdate = filesScanned;
2688
+ }
2689
+ });
2690
+ spinner.stop();
2691
+ } catch (err) {
2692
+ spinner.fail("Audit scan failed");
2693
+ process.stderr.write(chalk4.red(String(err)) + "\n");
2694
+ process.exit(1);
2695
+ }
2696
+ for (const w of scanResult.warnings) {
2697
+ const msg = w.kind === "read-failure" ? `warn: could not read ${w.file} (${w.fsCode})` : `warn: failed to parse ${w.file}`;
2698
+ process.stderr.write(chalk4.yellow(msg + "\n"));
2699
+ }
2700
+ if (scanResult.scannedFiles === 0) {
2701
+ process.stderr.write(
2702
+ chalk4.yellow("No matching files found. Check your .flaglintrc include patterns.\n")
2703
+ );
2704
+ process.exit(0);
2705
+ }
2706
+ if (scanResult.totalUsages === 0) {
2707
+ process.stderr.write(
2708
+ chalk4.dim(
2709
+ `No LaunchDarkly SDK usage detected in ${scanResult.scannedFiles} files.
2710
+ `
2711
+ )
2712
+ );
2713
+ if (format === "json" && options.costEstimate) {
2714
+ const emptyReport = buildAuditReport(scanResult, []);
2715
+ process.stdout.write(formatAuditJson(emptyReport, { estimate: null }) + "\n");
2716
+ process.stderr.write(chalk4.dim("Migration estimate: N/A\n"));
2717
+ } else if (options.costEstimate) {
2718
+ process.stderr.write(chalk4.dim("Migration estimate: N/A\n"));
2719
+ }
2720
+ process.exit(0);
2721
+ }
2722
+ const auditReport = buildAuditReport(
2723
+ scanResult,
2724
+ scanResult.migrationInventory ?? []
2725
+ );
2726
+ const renderOptions = options.costEstimate ? { estimate: computeEstimate(auditReport.readiness, void 0, hourlyRate) } : void 0;
2727
+ let output;
2728
+ if (format === "json") {
2729
+ output = formatAuditJson(auditReport, renderOptions);
2730
+ } else if (format === "html") {
2731
+ output = formatAuditHtml(auditReport, renderOptions);
2732
+ } else {
2733
+ output = formatAuditMarkdown(auditReport, renderOptions);
2734
+ }
2735
+ if (options.output) {
2736
+ const outPath = resolve8(options.output);
2737
+ try {
2738
+ await writeFile5(outPath, output, "utf8");
2739
+ process.stderr.write(chalk4.dim(` Report written to ${options.output}
2740
+ `));
2741
+ } catch (err) {
2742
+ process.stderr.write(
2743
+ chalk4.red(
2744
+ `Error: Failed to write report to ${options.output}: ${err instanceof Error ? err.message : String(err)}
2745
+ `
2746
+ )
2747
+ );
2748
+ process.exit(1);
2749
+ }
2750
+ } else {
2751
+ process.stdout.write(output + "\n");
2752
+ }
2753
+ const { totalFlags, highRisk, mediumRisk, lowRisk } = auditReport.summary;
2754
+ const lowRiskSegment = lowRisk > 0 ? `, ${lowRisk} low risk` : "";
2755
+ process.stderr.write(
2756
+ chalk4.green(
2757
+ `\u2713 Audit complete: ${totalFlags} flags \u2014 ${highRisk} high risk, ${mediumRisk} medium risk${lowRiskSegment}
2758
+ `
2759
+ )
2760
+ );
2761
+ const { readiness } = auditReport;
2762
+ process.stderr.write("\n");
2763
+ if (readiness.grade === "not-applicable") {
2764
+ process.stderr.write(chalk4.dim("Migration readiness: N/A \u2014 no direct LaunchDarkly calls detected.\n"));
2765
+ if (options.costEstimate) {
2766
+ process.stderr.write(chalk4.dim("Migration estimate: N/A\n"));
2767
+ }
2768
+ } else {
2769
+ const score = readiness.score;
2770
+ const scoreColor = score >= 80 ? chalk4.green : score >= 50 ? chalk4.yellow : chalk4.red;
2771
+ process.stderr.write(scoreColor(`Migration readiness: ${score}/100 \xB7 ${readiness.grade}
2772
+ `));
2773
+ process.stderr.write(scoreColor(renderReadinessBar(score) + "\n"));
2774
+ process.stderr.write(
2775
+ chalk4.dim(
2776
+ `${readiness.automatableCalls} safely automatable \xB7 ${readiness.manualReviewCalls} require manual review
2777
+ `
2778
+ )
2779
+ );
2780
+ if (options.costEstimate && renderOptions?.estimate) {
2781
+ const est = renderOptions.estimate;
2782
+ process.stderr.write("\n");
2783
+ process.stderr.write(
2784
+ chalk4.cyan(`Estimated migration effort: ${est.hoursLow}h \u2013 ${est.hoursHigh}h
2785
+ `)
2786
+ );
2787
+ if (est.costLow !== void 0 && est.costHigh !== void 0) {
2788
+ const fmtCost = (n) => "$" + n.toLocaleString("en-US");
2789
+ process.stderr.write(chalk4.cyan(`Estimated cost: ${fmtCost(est.costLow)} \u2013 ${fmtCost(est.costHigh)}
2790
+ `));
2791
+ }
2792
+ process.stderr.write(chalk4.dim("Estimates are directional. See the report for assumptions.\n"));
2793
+ }
2794
+ }
2795
+ process.exit(0);
2796
+ }
2797
+ );
2798
+ }
2799
+
1980
2800
  // src/cli.ts
1981
2801
  function createCLI() {
1982
2802
  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(
2803
+ program2.name("flaglint").description("Scan LaunchDarkly Node.js SDK usage, generate safe OpenFeature migration diffs, and enforce the vendor boundary in CI.").version("0.7.0", "-v, --version", "output the current version").addHelpText(
1984
2804
  "after",
1985
2805
  `
1986
2806
  Examples:
@@ -1990,11 +2810,15 @@ Examples:
1990
2810
  $ flaglint scan --output report.md save to file
1991
2811
  $ flaglint migrate generate migration plan
1992
2812
  $ flaglint migrate --dry-run preview without writing
1993
- $ flaglint validate --no-direct-launchdarkly enforce OF migration in CI`
2813
+ $ flaglint validate --no-direct-launchdarkly enforce OF migration in CI
2814
+ $ flaglint audit generate flag debt audit report
2815
+ $ flaglint audit --format html shareable HTML report
2816
+ $ flaglint audit --output audit.md save to file`
1994
2817
  );
1995
2818
  registerScanCommand(program2);
1996
2819
  registerMigrateCommand(program2);
1997
2820
  registerValidateCommand(program2);
2821
+ registerAuditCommand(program2);
1998
2822
  return program2;
1999
2823
  }
2000
2824
 
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.7.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"
@@ -48,6 +48,9 @@
48
48
  "test:run": "vitest run",
49
49
  "pretest:coverage": "npm run build:cli",
50
50
  "test:coverage": "vitest run --coverage",
51
+ "preview:docs": "ASTRO_TELEMETRY_DISABLED=1 astro preview --port 4321",
52
+ "test:package": "tsx scripts/test-package.ts",
53
+ "test:smoke": "playwright test",
51
54
  "new-branch": "tsx scripts/new-branch.ts",
52
55
  "release:patch": "tsx scripts/release.ts patch",
53
56
  "release:minor": "tsx scripts/release.ts minor",
@@ -66,11 +69,14 @@
66
69
  },
67
70
  "devDependencies": {
68
71
  "@astrojs/starlight": "^0.39.2",
72
+ "@playwright/test": "^1.60.0",
69
73
  "@types/micromatch": "^4.0.10",
70
74
  "@types/node": "^22.0.0",
71
75
  "@vitest/coverage-v8": "^4.1.6",
72
76
  "astro": "^6.3.8",
73
77
  "clipboardy": "^4.0.0",
78
+ "png-to-ico": "^3.0.1",
79
+ "starlight-blog": "^0.26.1",
74
80
  "tsup": "^8.2.4",
75
81
  "tsx": "^4.19.0",
76
82
  "typescript": "^5.5.4",