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 +16 -0
- package/README.md +69 -64
- package/dist/bin/flaglint.js +391 -32
- package/package.json +7 -3
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>
|
|
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
|
-

|
|
36
|
-
|
|
37
24
|
---
|
|
38
25
|
|
|
39
|
-
|
|
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
|
|
29
|
+
npx flaglint audit ./src
|
|
43
30
|
```
|
|
44
31
|
|
|
45
32
|
```
|
|
46
|
-
|
|
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
|
-
|
|
35
|
+
Migration readiness: 50/100 · moderate
|
|
36
|
+
[█████████████░░░░░░░░░░░░] 50%
|
|
37
|
+
10 safely automatable · 10 require manual review
|
|
52
38
|
```
|
|
53
39
|
|
|
54
|
-
|
|
40
|
+
Add `--cost-estimate` to include a directional planning estimate:
|
|
55
41
|
|
|
56
42
|
```bash
|
|
57
|
-
npx flaglint
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
63
|
+
## The problem FlagLint solves
|
|
74
64
|
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
##
|
|
104
|
+
## What is never auto-rewritten
|
|
105
|
+
|
|
106
|
+
FlagLint is intentionally conservative. These are always skipped and reported for manual review:
|
|
109
107
|
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
125
|
-
[OpenFeature Provider Setup](https://flaglint.dev/docs/integrations/openfeature-provider)
|
|
128
|
+
Before `migrate --apply`, complete provider bootstrap once:
|
|
126
129
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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)
|
package/dist/bin/flaglint.js
CHANGED
|
@@ -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.
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
2126
2211
|
}
|
|
2127
|
-
function
|
|
2128
|
-
return
|
|
2212
|
+
function displayFlagKey(flag) {
|
|
2213
|
+
return flag.isDynamic ? "<dynamic key>" : flag.flagKey;
|
|
2129
2214
|
}
|
|
2130
|
-
function
|
|
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
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
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
|
-
`| \`${
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
}
|