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 +82 -0
- package/README.md +72 -50
- package/dist/bin/flaglint.js +837 -13
- package/package.json +8 -2
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>
|
|
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
|
-
|
|
24
|
+
---
|
|
25
25
|
|
|
26
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
```bash
|
|
29
|
+
npx flaglint audit ./src
|
|
30
|
+
```
|
|
31
31
|
|
|
32
|
-
|
|
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
|
-
|
|
40
|
+
Add `--cost-estimate` to include a directional planning estimate:
|
|
37
41
|
|
|
38
42
|
```bash
|
|
39
|
-
npx flaglint
|
|
43
|
+
npx flaglint audit ./src --cost-estimate
|
|
40
44
|
```
|
|
41
45
|
|
|
42
46
|
```
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
|
62
|
-
|
|
63
|
-
| 1 | `flaglint
|
|
64
|
-
| 2 | `flaglint
|
|
65
|
-
| 3 | `flaglint migrate --
|
|
66
|
-
| 4 | `flaglint
|
|
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
|
|
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,
|
|
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
|
-
##
|
|
104
|
+
## What is never auto-rewritten
|
|
105
|
+
|
|
106
|
+
FlagLint is intentionally conservative. These are always skipped and reported for manual review:
|
|
92
107
|
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
108
|
-
[OpenFeature Provider Setup](https://flaglint.dev/docs/integrations/openfeature-provider)
|
|
128
|
+
Before `migrate --apply`, complete provider bootstrap once:
|
|
109
129
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
@@ -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
|
-
`| ${
|
|
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.
|
|
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 =
|
|
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.
|
|
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
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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)} · ${esc2(summary.scannedFiles.toString())} files · ${esc2(summary.scanDurationMs.toString())}ms · ${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
|
|
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.
|
|
4
|
-
"description": "LaunchDarkly Node.js
|
|
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",
|