flaglint 0.6.0 → 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,22 @@ 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
+
8
24
  ## [0.6.0] - 2026-06-02
9
25
 
10
26
  ### Added
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,99 +21,103 @@
21
21
  </a>
22
22
  </p>
23
23
 
24
- # FlagLint
25
-
26
- **The argument-order difference between LaunchDarkly and OpenFeature
27
- silently breaks flag evaluations in production. FlagLint catches it.**
28
-
29
- FlagLint inventories direct LaunchDarkly Node.js SDK calls, generates reviewable migration plans,
30
- and prevents new vendor-coupled flag access from entering your codebase.
31
- LaunchDarkly remains your provider. OpenFeature becomes the evaluation API your application code calls.
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)**
34
-
35
- ![FlagLint demo](./flaglint-demo.gif)
36
-
37
24
  ---
38
25
 
39
- ## Quick start
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.
40
27
 
41
28
  ```bash
42
- npx flaglint scan ./src
29
+ npx flaglint audit ./src
43
30
  ```
44
31
 
45
32
  ```
46
- Scanning 5 files...
47
- ✔ Found 20 direct LaunchDarkly Node SDK calls across 11 unique flags
48
- ⚡ 6 dynamic flag keys — manual review required
49
- ↳ 2 detail method calls — manual review required
33
+ Audit complete: 13 flags — 3 high risk, 10 medium risk
50
34
 
51
- Run flaglint migrate --dry-run for a reviewable migration diff
35
+ Migration readiness: 50/100 · moderate
36
+ [█████████████░░░░░░░░░░░░] 50%
37
+ 10 safely automatable · 10 require manual review
52
38
  ```
53
39
 
54
- Preview the migration before changing anything:
40
+ Add `--cost-estimate` to include a directional planning estimate:
55
41
 
56
42
  ```bash
57
- npx flaglint migrate ./src --dry-run
43
+ npx flaglint audit ./src --cost-estimate
58
44
  ```
59
45
 
60
- ---
46
+ ```
47
+ ✓ Audit complete: 13 flags — 3 high risk, 10 medium risk
61
48
 
62
- ## Workflow
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.
55
+ ```
63
56
 
64
- | Step | Command | What happens |
65
- |------|---------|-------------|
66
- | 1 | `flaglint scan ./src` | AST inventory of every direct LD Node server SDK call |
67
- | 2 | `flaglint migrate --dry-run` | Reviewable before/after diffs; inline provider setup guidance |
68
- | 3 | `flaglint migrate --apply` | Rewrites only guarded, provably automatable call-sites |
69
- | 4 | `flaglint validate --no-direct-launchdarkly` | CI gate: exit 1 if direct LD evaluation calls remain |
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)
58
+
59
+ No API key. No source upload. LaunchDarkly stays your provider OpenFeature becomes the evaluation API your application calls.
70
60
 
71
61
  ---
72
62
 
73
- ### `flaglint audit [dir]`
63
+ ## The problem FlagLint solves
74
64
 
75
- Generates a local flag debt audit report. No API keys or credentials needed.
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.
76
66
 
77
- ```bash
78
- flaglint audit ./src
79
- flaglint audit ./src --format html --output audit.html
80
- flaglint audit ./src --format json
81
- ```
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.
82
68
 
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`.
69
+ ---
70
+
71
+ ## Workflow
72
+
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. |
84
80
 
85
81
  ---
86
82
 
87
- ## Before After (real output from enterprise demo)
83
+ ## Before and after
88
84
 
89
85
  ```diff
90
- --- a/checkout.ts
91
- +++ b/checkout.ts
86
+ --- a/src/routes/checkout.ts
87
+ +++ b/src/routes/checkout.ts
92
88
  - return ldClient.boolVariation("checkout-v2", ctx, false);
93
89
  + return openFeatureClient.getBooleanValue("checkout-v2", false, ctx);
94
90
 
95
91
  - return ldClient.stringVariation("payment-provider", ctx, "stripe");
96
92
  + return openFeatureClient.getStringValue("payment-provider", "stripe", ctx);
97
93
 
98
- --- a/pricing.ts
99
- +++ b/pricing.ts
94
+ --- a/src/services/pricing.ts
95
+ +++ b/src/services/pricing.ts
100
96
  - return ldClient.numberVariation("discount-percentage", ctx, 0);
101
97
  + return openFeatureClient.getNumberValue("discount-percentage", 0, ctx);
102
98
  ```
103
99
 
104
- 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.
105
101
 
106
102
  ---
107
103
 
108
- ## Supported scope
104
+ ## What is never auto-rewritten
105
+
106
+ FlagLint is intentionally conservative. These are always skipped and reported for manual review:
109
107
 
110
- LaunchDarkly Node.js server-side SDK calls from `launchdarkly-node-server-sdk` and
111
- `@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**
112
115
 
113
- `--apply` rewrites `boolVariation`, `stringVariation`, `numberVariation`, `jsonVariation`
114
- where the flag key, fallback, and OpenFeature client binding are statically explicit.
115
- Detail methods, dynamic keys, bulk calls, and unknown fallback types are reported for manual review.
116
- 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.
117
121
 
118
122
  Full coverage table: [Supported Scope](https://flaglint.dev/docs/reference/supported-scope)
119
123
 
@@ -121,29 +125,30 @@ Full coverage table: [Supported Scope](https://flaglint.dev/docs/reference/suppo
121
125
 
122
126
  ## Provider setup (one-time, manual)
123
127
 
124
- Before `--apply`, complete bootstrap setup once. Full instructions:
125
- [OpenFeature Provider Setup](https://flaglint.dev/docs/integrations/openfeature-provider)
128
+ Before `migrate --apply`, complete provider bootstrap once:
126
129
 
127
- Key points:
128
- - `new LaunchDarklyProvider(process.env.LD_SDK_KEY!)` SDK key constructor
129
- - Evaluation context accepts either `targetingKey` (OpenFeature-native) or an existing LaunchDarkly `key`
130
- - **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";
131
133
 
132
- ---
134
+ await OpenFeature.setProviderAndWait(
135
+ new LaunchDarklyProvider(process.env.LD_SDK_KEY!)
136
+ );
137
+ export const openFeatureClient = OpenFeature.getClient();
138
+ ```
133
139
 
134
- ## 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.
135
141
 
136
- 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)
137
143
 
138
144
  ---
139
145
 
140
146
  ## Local analysis
141
147
 
142
- FlagLint runs entirely on your machine. No source code, flag keys, or file paths are
143
- 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`.
144
149
 
145
150
  ---
146
151
 
147
152
  ## Links
148
153
 
149
- [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)
@@ -789,7 +789,7 @@ function formatHTML(result, options) {
789
789
  <div class="card"><div class="card-num orange">${manualCount}</div><div class="card-label">Manual Review (${manualPct}%)</div></div>
790
790
  <div class="card"><div class="card-num blue">${detailBulkCount}</div><div class="card-label">Detail/Bulk Calls</div></div>` : "";
791
791
  const title = options.title ? esc(options.title) : "FlagLint Scan Report";
792
- const version = true ? "0.6.0" : "0.1.0";
792
+ const version = true ? "0.7.0" : "0.1.0";
793
793
  return `<!DOCTYPE html>
794
794
  <html lang="en">
795
795
  <head>
@@ -1116,6 +1116,89 @@ import { resolve as resolve5 } from "path";
1116
1116
  import chalk2 from "chalk";
1117
1117
  import ora2 from "ora";
1118
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
+
1119
1202
  // src/migrator/index.ts
1120
1203
  function legacyInventoryFromUsage(usage) {
1121
1204
  const isBulk = usage.flagKey === "*" || usage.callType === "allFlags" || usage.callType === "allFlagsState";
@@ -1194,12 +1277,6 @@ function buildEvidenceItem(item) {
1194
1277
  reviewReason: item.safelyAutomatable ? void 0 : reasonLabel(item.manualReviewReason)
1195
1278
  };
1196
1279
  }
1197
- function calcReadinessScore(items) {
1198
- if (items.length === 0) return 0;
1199
- const safeCount = items.filter((item) => item.safelyAutomatable).length;
1200
- const score = Math.round(safeCount / items.length * 100);
1201
- return safeCount === items.length ? 100 : Math.min(score, 99);
1202
- }
1203
1280
  function calcRequiredPackages(items) {
1204
1281
  return items.some(isServerInventoryItem) ? ["@openfeature/server-sdk"] : [];
1205
1282
  }
@@ -1217,7 +1294,7 @@ function analyze(result) {
1217
1294
  ).length;
1218
1295
  const manualReviewCount = totalLaunchDarklyUsages - safelyAutomatableCount;
1219
1296
  const autoMigrateCount = safelyAutomatableCount;
1220
- const readinessScore = calcReadinessScore(inventoryItems);
1297
+ const readinessScore = computeReadiness(result.usages, inventoryItems).score ?? 0;
1221
1298
  const requiredPackages = calcRequiredPackages(inventoryItems);
1222
1299
  return {
1223
1300
  readinessScore,
@@ -1245,7 +1322,7 @@ function formatMigrationReport(analysis) {
1245
1322
  unsupportedUnknownCount
1246
1323
  } = analysis;
1247
1324
  const date = (/* @__PURE__ */ new Date()).toLocaleDateString();
1248
- const version = true ? "0.6.0" : "0.1.0";
1325
+ const version = true ? "0.7.0" : "0.1.0";
1249
1326
  const lines = [];
1250
1327
  lines.push("# OpenFeature Migration Inventory");
1251
1328
  lines.push(`Generated by FlagLint v${version} on ${date}`);
@@ -2117,18 +2194,34 @@ function buildAuditReport(scanResult, inventoryItems) {
2117
2194
  scannedAt: scanResult.scannedAt,
2118
2195
  scanRoot: scanResult.scanRoot
2119
2196
  };
2120
- return { summary, flags };
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 + "%";
2121
2206
  }
2122
2207
 
2123
2208
  // src/auditor/reporter.ts
2124
2209
  function esc2(s) {
2125
2210
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
2126
2211
  }
2127
- function formatAuditJson(report) {
2128
- return JSON.stringify(report, null, 2);
2212
+ function displayFlagKey(flag) {
2213
+ return flag.isDynamic ? "<dynamic key>" : flag.flagKey;
2129
2214
  }
2130
- function formatAuditMarkdown(report) {
2131
- const { summary, flags } = report;
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;
2132
2225
  const lines = [];
2133
2226
  lines.push("# FlagLint Audit Report");
2134
2227
  lines.push("");
@@ -2139,11 +2232,19 @@ function formatAuditMarkdown(report) {
2139
2232
  lines.push("");
2140
2233
  lines.push("## Summary");
2141
2234
  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
- );
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
+ }
2147
2248
  lines.push("");
2148
2249
  lines.push(
2149
2250
  "| Dynamic Keys | Detail Evals | Bulk Calls | Stale Signals | Safely Automatable | Manual Review |"
@@ -2155,6 +2256,46 @@ function formatAuditMarkdown(report) {
2155
2256
  `| ${summary.dynamicKeys} | ${summary.detailEvaluations} | ${summary.bulkCalls} | ${summary.staleSignals} | ${summary.safelyAutomatable} | ${summary.manualReview} |`
2156
2257
  );
2157
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
+ }
2158
2299
  lines.push("## Flag Debt Inventory");
2159
2300
  lines.push("");
2160
2301
  lines.push("| Flag Key | Risk | Usages | Files | Call Types | Reasons |");
@@ -2166,8 +2307,9 @@ function formatAuditMarkdown(report) {
2166
2307
  };
2167
2308
  for (const flag of flags) {
2168
2309
  const reasons = flag.riskReasons.join(", ") || "\u2014";
2310
+ const flagKey = displayFlagKey(flag);
2169
2311
  lines.push(
2170
- `| \`${flag.flagKey}\` | ${riskLabel[flag.riskLevel]} | ${flag.usageCount} | ${flag.fileCount} | ${flag.callTypes.join(", ")} | ${reasons} |`
2312
+ `| \`${flagKey}\` | ${riskLabel[flag.riskLevel]} | ${flag.usageCount} | ${flag.fileCount} | ${flag.callTypes.join(", ")} | ${reasons} |`
2171
2313
  );
2172
2314
  }
2173
2315
  lines.push("");
@@ -2185,9 +2327,9 @@ function formatAuditMarkdown(report) {
2185
2327
  lines.push("");
2186
2328
  return lines.join("\n");
2187
2329
  }
2188
- function formatAuditHtml(report) {
2189
- const { summary, flags } = report;
2190
- const version = true ? "0.6.0" : "0.1.0";
2330
+ function formatAuditHtml(report, options) {
2331
+ const { summary, flags, readiness } = report;
2332
+ const version = true ? "0.7.0" : "0.1.0";
2191
2333
  const date = new Date(summary.scannedAt).toLocaleString();
2192
2334
  const riskBadge = (level) => {
2193
2335
  if (level === "high")
@@ -2198,8 +2340,9 @@ function formatAuditHtml(report) {
2198
2340
  };
2199
2341
  const rows = flags.map((f) => {
2200
2342
  const reasons = f.riskReasons.length > 0 ? esc2(f.riskReasons.join(", ")) : "\u2014";
2343
+ const flagKey = displayFlagKey(f);
2201
2344
  return `<tr>
2202
- <td><code>${esc2(f.flagKey)}</code></td>
2345
+ <td><code>${esc2(flagKey)}</code></td>
2203
2346
  <td>${riskBadge(f.riskLevel)}</td>
2204
2347
  <td>${f.usageCount}</td>
2205
2348
  <td>${f.fileCount}</td>
@@ -2207,6 +2350,55 @@ function formatAuditHtml(report) {
2207
2350
  <td>${reasons}</td>
2208
2351
  </tr>`;
2209
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 ");
2210
2402
  return `<!DOCTYPE html>
2211
2403
  <html lang="en">
2212
2404
  <head>
@@ -2231,6 +2423,14 @@ function formatAuditHtml(report) {
2231
2423
  .card-num.purple{color:#7c3aed}
2232
2424
  .card-num.orange{color:#ea580c}
2233
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)}
2234
2434
  table{width:100%;border-collapse:collapse;font-size:.8125rem}
2235
2435
  th{text-align:left;padding:.625rem .75rem;background:var(--surface);border-bottom:2px solid var(--border);font-weight:600;white-space:nowrap}
2236
2436
  td{padding:.625rem .75rem;border-bottom:1px solid var(--border);vertical-align:top}
@@ -2244,6 +2444,10 @@ function formatAuditHtml(report) {
2244
2444
  .badge-medium{background:#78350f;color:#fcd34d}
2245
2445
  .badge-low{background:#14532d;color:#86efac}
2246
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}
2247
2451
  .steps{margin:.75rem 0 1rem 1.25rem;line-height:2}
2248
2452
  footer{margin-top:3rem;padding-top:1rem;border-top:1px solid var(--border);color:var(--muted);font-size:.75rem;text-align:center}
2249
2453
  footer a{color:var(--muted)}
@@ -2260,11 +2464,38 @@ function formatAuditHtml(report) {
2260
2464
  <div class="card"><div class="card-num">${summary.totalFlags}</div><div class="card-label">Total Flags</div></div>
2261
2465
  <div class="card"><div class="card-num red">${summary.highRisk}</div><div class="card-label">High Risk</div></div>
2262
2466
  <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>
2467
+ ${summary.lowRisk > 0 ? `<div class="card"><div class="card-num green">${summary.lowRisk}</div><div class="card-label">Low Risk</div></div>` : ""}
2264
2468
  <div class="card"><div class="card-num purple">${summary.safelyAutomatable}</div><div class="card-label">Safely Automatable</div></div>
2265
2469
  <div class="card"><div class="card-num orange">${summary.manualReview}</div><div class="card-label">Manual Review</div></div>
2266
2470
  </div>
2267
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
+
2268
2499
  <h2>Flag Debt Inventory</h2>
2269
2500
  <table>
2270
2501
  <thead>
@@ -2296,16 +2527,81 @@ function formatAuditHtml(report) {
2296
2527
  </html>`;
2297
2528
  }
2298
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
+
2299
2593
  // src/commands/audit.ts
2300
2594
  var VALID_AUDIT_FORMATS = ["json", "markdown", "html"];
2301
2595
  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(
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(
2303
2597
  "after",
2304
2598
  `
2305
2599
  Examples:
2306
2600
  $ flaglint audit generate flag debt audit report
2307
2601
  $ flaglint audit --format html shareable HTML report
2308
- $ flaglint audit --output audit.md save to file`
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`
2309
2605
  ).action(
2310
2606
  async (dir, options) => {
2311
2607
  if (!VALID_AUDIT_FORMATS.includes(options.format)) {
@@ -2317,6 +2613,26 @@ Examples:
2317
2613
  );
2318
2614
  process.exit(2);
2319
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
+ }
2320
2636
  try {
2321
2637
  const s = await stat4(resolve8(dir));
2322
2638
  if (!s.isDirectory()) {
@@ -2394,19 +2710,27 @@ Examples:
2394
2710
  `
2395
2711
  )
2396
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
+ }
2397
2720
  process.exit(0);
2398
2721
  }
2399
2722
  const auditReport = buildAuditReport(
2400
2723
  scanResult,
2401
2724
  scanResult.migrationInventory ?? []
2402
2725
  );
2726
+ const renderOptions = options.costEstimate ? { estimate: computeEstimate(auditReport.readiness, void 0, hourlyRate) } : void 0;
2403
2727
  let output;
2404
2728
  if (format === "json") {
2405
- output = formatAuditJson(auditReport);
2729
+ output = formatAuditJson(auditReport, renderOptions);
2406
2730
  } else if (format === "html") {
2407
- output = formatAuditHtml(auditReport);
2731
+ output = formatAuditHtml(auditReport, renderOptions);
2408
2732
  } else {
2409
- output = formatAuditMarkdown(auditReport);
2733
+ output = formatAuditMarkdown(auditReport, renderOptions);
2410
2734
  }
2411
2735
  if (options.output) {
2412
2736
  const outPath = resolve8(options.output);
@@ -2427,12 +2751,47 @@ Examples:
2427
2751
  process.stdout.write(output + "\n");
2428
2752
  }
2429
2753
  const { totalFlags, highRisk, mediumRisk, lowRisk } = auditReport.summary;
2754
+ const lowRiskSegment = lowRisk > 0 ? `, ${lowRisk} low risk` : "";
2430
2755
  process.stderr.write(
2431
2756
  chalk4.green(
2432
- `\u2713 Audit complete: ${totalFlags} flags \u2014 ${highRisk} high risk, ${mediumRisk} medium risk, ${lowRisk} low risk
2757
+ `\u2713 Audit complete: ${totalFlags} flags \u2014 ${highRisk} high risk, ${mediumRisk} medium risk${lowRiskSegment}
2433
2758
  `
2434
2759
  )
2435
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
+ }
2436
2795
  process.exit(0);
2437
2796
  }
2438
2797
  );
@@ -2441,7 +2800,7 @@ Examples:
2441
2800
  // src/cli.ts
2442
2801
  function createCLI() {
2443
2802
  const program2 = new Command();
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(
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(
2445
2804
  "after",
2446
2805
  `
2447
2806
  Examples:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flaglint",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
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": {
@@ -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,16 +69,17 @@
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",
74
78
  "png-to-ico": "^3.0.1",
79
+ "starlight-blog": "^0.26.1",
75
80
  "tsup": "^8.2.4",
76
81
  "tsx": "^4.19.0",
77
82
  "typescript": "^5.5.4",
78
- "vitest": "^4.1.6",
79
- "starlight-blog": "^0.26.1"
83
+ "vitest": "^4.1.6"
80
84
  }
81
85
  }