flaglint 0.5.3 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +66 -0
- package/README.md +43 -3
- package/dist/bin/flaglint.js +471 -6
- package/package.json +5 -3
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,72 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.6.0] - 2026-06-02
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- add flaglint audit command with risk-scored flag debt report
|
|
13
|
+
- migrate homepage into Astro, unify site into single build
|
|
14
|
+
- add dark/light/auto theme toggle + search to homepage
|
|
15
|
+
- add Mermaid diagrams to Safety Model and How FlagLint Works
|
|
16
|
+
- add Mermaid diagrams to Safety Model and How FlagLint Works
|
|
17
|
+
- complete favicon stack for Google Search and mobile
|
|
18
|
+
- replace plain-text architecture diagram with styled HTML flow
|
|
19
|
+
- add JSON-LD SoftwareApplication structured data to homepage
|
|
20
|
+
- align trust and privacy page nav with homepage nav
|
|
21
|
+
- add Further Reading section to quickstart linking to blog posts
|
|
22
|
+
- add cross-links between blog posts
|
|
23
|
+
- rename blog to FlagLint Blog and set focused description
|
|
24
|
+
- add Docs and Blog links to homepage nav and footer
|
|
25
|
+
- add Blog link to marketing homepage nav
|
|
26
|
+
- add blog to flaglint.dev using starlight-blog
|
|
27
|
+
- add terminal demo GIF (scan, migrate --dry-run, validate CI gate)
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- docs UX series P1-P9 (GIF, TOC, title, sidebar, cards, blog frames)
|
|
32
|
+
- UX and codebase series (A1–A5, B1–B5)
|
|
33
|
+
- sync package-lock.json after rebase (add png-to-ico)
|
|
34
|
+
- UX and codebase series (A1–A5, B1–B5)
|
|
35
|
+
- align dynamic key label with scan reporter terminology
|
|
36
|
+
- restore branded README header and correct docs links
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- trigger cloudflare rebuild
|
|
41
|
+
- untrack www/ build output + fix GIF paths + add Why FlagLint CTA
|
|
42
|
+
- add excerpt markers to both blog posts for blog index truncation
|
|
43
|
+
- rebuild after rebase onto main (PR #83 docs UX changes)
|
|
44
|
+
- rebuild docs after rebase onto main (A1-A5/B1-B5 + favicon state)
|
|
45
|
+
- rebuild docs after rebase onto main (favicon + blog state)
|
|
46
|
+
- rebuild docs site with blog and demo GIF
|
|
47
|
+
- add LinkedIn URL to blog post authors
|
|
48
|
+
- remove debug artifacts from demo fixture folder
|
|
49
|
+
- update version references to 0.5.4
|
|
50
|
+
|
|
51
|
+
### Other
|
|
52
|
+
|
|
53
|
+
- Merge feat/audit-command: add flaglint audit command
|
|
54
|
+
- Merge pull request #86 from flaglint/feat/docs-ux-fixes
|
|
55
|
+
- Merge pull request #85 from flaglint/fix/blog-excerpt-markers
|
|
56
|
+
- Merge pull request #84 from flaglint/feat/homepage-theme-search
|
|
57
|
+
- Merge pull request #83 from flaglint/fix/complete-series-p1-p12
|
|
58
|
+
- Merge pull request #82 from flaglint/feat/favicon-stack
|
|
59
|
+
- Merge pull request #80 from flaglint/fix/validate-dynamic-key-label
|
|
60
|
+
- Merge pull request #78 from flaglint/feat/architecture-diagram
|
|
61
|
+
- Merge pull request #77 from flaglint/feat/structured-data-jsonld
|
|
62
|
+
- Merge pull request #76 from flaglint/feat/consistent-nav
|
|
63
|
+
- Merge pull request #75 from flaglint/feat/quickstart-further-reading
|
|
64
|
+
- Merge branch 'main' into feat/quickstart-further-reading
|
|
65
|
+
- Merge pull request #74 from flaglint/feat/blog-cross-links
|
|
66
|
+
- Merge pull request #73 from flaglint/feat/blog-title-description
|
|
67
|
+
- Merge pull request #72 from flaglint/feat/nav-footer-blog-docs
|
|
68
|
+
- Merge pull request #71 from flaglint/chore/bump-version-0.5.4
|
|
69
|
+
- Update README.md
|
|
70
|
+
- Merge pull request #70 from flaglint/chore/bump-version-0.5.4
|
|
71
|
+
- Merge pull request #69 from flaglint/chore/bump-version-0.5.4
|
|
72
|
+
- chore/bump-version-0.5.4
|
|
73
|
+
|
|
8
74
|
## Unreleased
|
|
9
75
|
|
|
10
76
|
### Fixed
|
package/README.md
CHANGED
|
@@ -1,12 +1,38 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="docs/assets/logo.png" alt="FlagLint" width="400" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<strong>Standardize LaunchDarkly usage on OpenFeature.</strong>
|
|
7
|
+
</p>
|
|
8
|
+
|
|
9
|
+
<p align="center">
|
|
10
|
+
<a href="https://github.com/flaglint/flaglint/actions/workflows/ci.yml">
|
|
11
|
+
<img src="https://github.com/flaglint/flaglint/actions/workflows/ci.yml/badge.svg" alt="CI" />
|
|
12
|
+
</a>
|
|
13
|
+
<a href="https://www.npmjs.com/package/flaglint">
|
|
14
|
+
<img src="https://img.shields.io/npm/v/flaglint.svg" alt="npm version" />
|
|
15
|
+
</a>
|
|
16
|
+
<a href="https://www.npmjs.com/package/flaglint">
|
|
17
|
+
<img src="https://img.shields.io/npm/dm/flaglint.svg" alt="downloads" />
|
|
18
|
+
</a>
|
|
19
|
+
<a href="https://opensource.org/licenses/MIT">
|
|
20
|
+
<img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="MIT License" />
|
|
21
|
+
</a>
|
|
22
|
+
</p>
|
|
23
|
+
|
|
1
24
|
# FlagLint
|
|
2
25
|
|
|
3
|
-
**
|
|
26
|
+
**The argument-order difference between LaunchDarkly and OpenFeature
|
|
27
|
+
silently breaks flag evaluations in production. FlagLint catches it.**
|
|
4
28
|
|
|
5
29
|
FlagLint inventories direct LaunchDarkly Node.js SDK calls, generates reviewable migration plans,
|
|
6
30
|
and prevents new vendor-coupled flag access from entering your codebase.
|
|
7
31
|
LaunchDarkly remains your provider. OpenFeature becomes the evaluation API your application code calls.
|
|
8
32
|
|
|
9
|
-
**[Documentation](https://flaglint.dev/docs
|
|
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
|
+

|
|
10
36
|
|
|
11
37
|
---
|
|
12
38
|
|
|
@@ -44,6 +70,20 @@ npx flaglint migrate ./src --dry-run
|
|
|
44
70
|
|
|
45
71
|
---
|
|
46
72
|
|
|
73
|
+
### `flaglint audit [dir]`
|
|
74
|
+
|
|
75
|
+
Generates a local flag debt audit report. No API keys or credentials needed.
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
flaglint audit ./src
|
|
79
|
+
flaglint audit ./src --format html --output audit.html
|
|
80
|
+
flaglint audit ./src --format json
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Produces a risk-scored inventory of every LaunchDarkly flag in your codebase — sorted by risk level (high / medium / low) with the reasons for each rating. Useful for planning migration scope before running `migrate --dry-run`.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
47
87
|
## Before → After (real output from enterprise demo)
|
|
48
88
|
|
|
49
89
|
```diff
|
|
@@ -106,4 +146,4 @@ transmitted to any external service. No outbound network connections during scan
|
|
|
106
146
|
|
|
107
147
|
## Links
|
|
108
148
|
|
|
109
|
-
[Security](./SECURITY.md) · [Contributing](./CONTRIBUTING.md) · [Changelog](./CHANGELOG.md) · [License](./LICENSE) · [Full docs](https://flaglint.dev/docs
|
|
149
|
+
[Security](./SECURITY.md) · [Contributing](./CONTRIBUTING.md) · [Changelog](./CHANGELOG.md) · [License](./LICENSE) · [Full docs](https://flaglint.dev/docs)
|
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.6.0" : "0.1.0";
|
|
767
793
|
return `<!DOCTYPE html>
|
|
768
794
|
<html lang="en">
|
|
769
795
|
<head>
|
|
@@ -894,6 +920,9 @@ var FlagLintConfigSchema = z.object({
|
|
|
894
920
|
"**/coverage/**",
|
|
895
921
|
"**/*.d.ts"
|
|
896
922
|
]),
|
|
923
|
+
// Governs which vendor SDK the scanner targets.
|
|
924
|
+
// Currently only "launchdarkly" is wired in the scanner.
|
|
925
|
+
// Other values are accepted for forward compatibility (v0.7+).
|
|
897
926
|
provider: z.enum(["launchdarkly", "unleash", "growthbook", "custom"]).default("launchdarkly"),
|
|
898
927
|
// TODO v0.3: replace minFileCount with real date-based staleness via git log
|
|
899
928
|
minFileCount: z.number().int().min(0).default(0),
|
|
@@ -1216,7 +1245,7 @@ function formatMigrationReport(analysis) {
|
|
|
1216
1245
|
unsupportedUnknownCount
|
|
1217
1246
|
} = analysis;
|
|
1218
1247
|
const date = (/* @__PURE__ */ new Date()).toLocaleDateString();
|
|
1219
|
-
const version = true ? "0.
|
|
1248
|
+
const version = true ? "0.6.0" : "0.1.0";
|
|
1220
1249
|
const lines = [];
|
|
1221
1250
|
lines.push("# OpenFeature Migration Inventory");
|
|
1222
1251
|
lines.push(`Generated by FlagLint v${version} on ${date}`);
|
|
@@ -1713,7 +1742,7 @@ function validateScanResult(result, options = {}) {
|
|
|
1713
1742
|
};
|
|
1714
1743
|
}
|
|
1715
1744
|
function violationLabel(v) {
|
|
1716
|
-
if (v.isDynamic) return `${v.callType}(dynamic key
|
|
1745
|
+
if (v.isDynamic) return `${v.callType}("(dynamic key)")`;
|
|
1717
1746
|
if (v.flagKey === "*") return `${v.callType}(bulk inventory)`;
|
|
1718
1747
|
return `${v.callType}("${v.flagKey}")`;
|
|
1719
1748
|
}
|
|
@@ -1977,10 +2006,442 @@ Examples:
|
|
|
1977
2006
|
);
|
|
1978
2007
|
}
|
|
1979
2008
|
|
|
2009
|
+
// src/commands/audit.ts
|
|
2010
|
+
import { writeFile as writeFile5 } from "fs/promises";
|
|
2011
|
+
import { stat as stat4 } from "fs/promises";
|
|
2012
|
+
import { resolve as resolve8 } from "path";
|
|
2013
|
+
import chalk4 from "chalk";
|
|
2014
|
+
import ora4 from "ora";
|
|
2015
|
+
|
|
2016
|
+
// src/auditor/index.ts
|
|
2017
|
+
function scoreFlag(usages, inventoryItems) {
|
|
2018
|
+
const highReasons = [];
|
|
2019
|
+
const mediumReasons = [];
|
|
2020
|
+
if (usages.some((u) => u.isDynamic)) {
|
|
2021
|
+
highReasons.push("dynamic key");
|
|
2022
|
+
}
|
|
2023
|
+
if (inventoryItems.some((i) => i.manualReviewReason === "detail-method")) {
|
|
2024
|
+
highReasons.push("detail evaluation");
|
|
2025
|
+
}
|
|
2026
|
+
if (inventoryItems.some((i) => i.manualReviewReason === "bulk-inventory-call")) {
|
|
2027
|
+
highReasons.push("bulk call");
|
|
2028
|
+
}
|
|
2029
|
+
if (usages.some((u) => u.stalenessSignals.length > 0)) {
|
|
2030
|
+
highReasons.push("stale signal");
|
|
2031
|
+
}
|
|
2032
|
+
if (usages.some((u) => u.callType === "variation" && u.isDynamic)) {
|
|
2033
|
+
if (!highReasons.includes("wrapper usage")) {
|
|
2034
|
+
highReasons.push("wrapper usage");
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
if (usages.some(
|
|
2038
|
+
(u) => u.callType === "hook-useFlags" || u.callType === "hook-useLDClient" || u.callType === "hoc" || u.callType === "provider"
|
|
2039
|
+
)) {
|
|
2040
|
+
highReasons.push("react/browser hook");
|
|
2041
|
+
}
|
|
2042
|
+
if (highReasons.length > 0) {
|
|
2043
|
+
return { riskLevel: "high", riskReasons: highReasons };
|
|
2044
|
+
}
|
|
2045
|
+
if (inventoryItems.some((i) => i.safelyAutomatable)) {
|
|
2046
|
+
mediumReasons.push("safely automatable");
|
|
2047
|
+
}
|
|
2048
|
+
if (inventoryItems.some((i) => i.valueType === "object")) {
|
|
2049
|
+
mediumReasons.push("json variation");
|
|
2050
|
+
}
|
|
2051
|
+
if (inventoryItems.some((i) => i.manualReviewReason === "unknown-fallback")) {
|
|
2052
|
+
mediumReasons.push("unknown fallback");
|
|
2053
|
+
}
|
|
2054
|
+
if (mediumReasons.length > 0) {
|
|
2055
|
+
return { riskLevel: "medium", riskReasons: mediumReasons };
|
|
2056
|
+
}
|
|
2057
|
+
return { riskLevel: "low", riskReasons: [] };
|
|
2058
|
+
}
|
|
2059
|
+
function buildAuditReport(scanResult, inventoryItems) {
|
|
2060
|
+
const usagesByFlag = /* @__PURE__ */ new Map();
|
|
2061
|
+
for (const u of scanResult.usages) {
|
|
2062
|
+
const key = u.flagKey;
|
|
2063
|
+
if (!usagesByFlag.has(key)) usagesByFlag.set(key, []);
|
|
2064
|
+
usagesByFlag.get(key).push(u);
|
|
2065
|
+
}
|
|
2066
|
+
const inventoryByFlag = /* @__PURE__ */ new Map();
|
|
2067
|
+
for (const item of inventoryItems) {
|
|
2068
|
+
const key = item.staticFlagKey ?? "*";
|
|
2069
|
+
if (!inventoryByFlag.has(key)) inventoryByFlag.set(key, []);
|
|
2070
|
+
inventoryByFlag.get(key).push(item);
|
|
2071
|
+
}
|
|
2072
|
+
const allFlagKeys = /* @__PURE__ */ new Set([...usagesByFlag.keys()]);
|
|
2073
|
+
const flags = [];
|
|
2074
|
+
for (const flagKey of allFlagKeys) {
|
|
2075
|
+
const usages = usagesByFlag.get(flagKey) ?? [];
|
|
2076
|
+
const items = inventoryByFlag.get(flagKey) ?? [];
|
|
2077
|
+
const { riskLevel, riskReasons } = scoreFlag(usages, items);
|
|
2078
|
+
const files = [...new Set(usages.map((u) => u.file))];
|
|
2079
|
+
const callTypes = [...new Set(usages.map((u) => u.callType))];
|
|
2080
|
+
flags.push({
|
|
2081
|
+
flagKey,
|
|
2082
|
+
riskLevel,
|
|
2083
|
+
riskReasons,
|
|
2084
|
+
callTypes,
|
|
2085
|
+
fileCount: files.length,
|
|
2086
|
+
usageCount: usages.length,
|
|
2087
|
+
safelyAutomatable: items.some((i) => i.safelyAutomatable),
|
|
2088
|
+
hasStaleSignal: usages.some((u) => u.stalenessSignals.length > 0),
|
|
2089
|
+
isDynamic: usages.some((u) => u.isDynamic),
|
|
2090
|
+
files
|
|
2091
|
+
});
|
|
2092
|
+
}
|
|
2093
|
+
const riskOrder = { high: 0, medium: 1, low: 2 };
|
|
2094
|
+
flags.sort((a, b) => {
|
|
2095
|
+
const levelDiff = riskOrder[a.riskLevel] - riskOrder[b.riskLevel];
|
|
2096
|
+
if (levelDiff !== 0) return levelDiff;
|
|
2097
|
+
return b.usageCount - a.usageCount;
|
|
2098
|
+
});
|
|
2099
|
+
const highRisk = flags.filter((f) => f.riskLevel === "high").length;
|
|
2100
|
+
const mediumRisk = flags.filter((f) => f.riskLevel === "medium").length;
|
|
2101
|
+
const lowRisk = flags.filter((f) => f.riskLevel === "low").length;
|
|
2102
|
+
const summary = {
|
|
2103
|
+
totalFlags: flags.length,
|
|
2104
|
+
highRisk,
|
|
2105
|
+
mediumRisk,
|
|
2106
|
+
lowRisk,
|
|
2107
|
+
totalUsages: scanResult.totalUsages,
|
|
2108
|
+
dynamicKeys: scanResult.usages.filter((u) => u.isDynamic).length,
|
|
2109
|
+
detailEvaluations: inventoryItems.filter((i) => i.manualReviewReason === "detail-method").length,
|
|
2110
|
+
bulkCalls: inventoryItems.filter((i) => i.manualReviewReason === "bulk-inventory-call").length,
|
|
2111
|
+
wrapperUsages: scanResult.usages.filter((u) => u.callType === "variation").length,
|
|
2112
|
+
staleSignals: scanResult.usages.filter((u) => u.stalenessSignals.length > 0).length,
|
|
2113
|
+
safelyAutomatable: inventoryItems.filter((i) => i.safelyAutomatable).length,
|
|
2114
|
+
manualReview: inventoryItems.filter((i) => !i.safelyAutomatable).length,
|
|
2115
|
+
scannedFiles: scanResult.scannedFiles,
|
|
2116
|
+
scanDurationMs: scanResult.scanDurationMs,
|
|
2117
|
+
scannedAt: scanResult.scannedAt,
|
|
2118
|
+
scanRoot: scanResult.scanRoot
|
|
2119
|
+
};
|
|
2120
|
+
return { summary, flags };
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
// src/auditor/reporter.ts
|
|
2124
|
+
function esc2(s) {
|
|
2125
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
2126
|
+
}
|
|
2127
|
+
function formatAuditJson(report) {
|
|
2128
|
+
return JSON.stringify(report, null, 2);
|
|
2129
|
+
}
|
|
2130
|
+
function formatAuditMarkdown(report) {
|
|
2131
|
+
const { summary, flags } = report;
|
|
2132
|
+
const lines = [];
|
|
2133
|
+
lines.push("# FlagLint Audit Report");
|
|
2134
|
+
lines.push("");
|
|
2135
|
+
lines.push(`**Scanned at:** ${summary.scannedAt} `);
|
|
2136
|
+
lines.push(`**Scan root:** ${summary.scanRoot} `);
|
|
2137
|
+
lines.push(`**Files scanned:** ${summary.scannedFiles} `);
|
|
2138
|
+
lines.push(`**Duration:** ${summary.scanDurationMs}ms`);
|
|
2139
|
+
lines.push("");
|
|
2140
|
+
lines.push("## Summary");
|
|
2141
|
+
lines.push("");
|
|
2142
|
+
lines.push("| Total Flags | High Risk | Medium Risk | Low Risk | Total Usages |");
|
|
2143
|
+
lines.push("|-------------|-----------|-------------|----------|--------------|");
|
|
2144
|
+
lines.push(
|
|
2145
|
+
`| ${summary.totalFlags} | ${summary.highRisk} | ${summary.mediumRisk} | ${summary.lowRisk} | ${summary.totalUsages} |`
|
|
2146
|
+
);
|
|
2147
|
+
lines.push("");
|
|
2148
|
+
lines.push(
|
|
2149
|
+
"| Dynamic Keys | Detail Evals | Bulk Calls | Stale Signals | Safely Automatable | Manual Review |"
|
|
2150
|
+
);
|
|
2151
|
+
lines.push(
|
|
2152
|
+
"|--------------|--------------|------------|---------------|-------------------|---------------|"
|
|
2153
|
+
);
|
|
2154
|
+
lines.push(
|
|
2155
|
+
`| ${summary.dynamicKeys} | ${summary.detailEvaluations} | ${summary.bulkCalls} | ${summary.staleSignals} | ${summary.safelyAutomatable} | ${summary.manualReview} |`
|
|
2156
|
+
);
|
|
2157
|
+
lines.push("");
|
|
2158
|
+
lines.push("## Flag Debt Inventory");
|
|
2159
|
+
lines.push("");
|
|
2160
|
+
lines.push("| Flag Key | Risk | Usages | Files | Call Types | Reasons |");
|
|
2161
|
+
lines.push("|----------|------|--------|-------|------------|---------|");
|
|
2162
|
+
const riskLabel = {
|
|
2163
|
+
high: "\u{1F534} High",
|
|
2164
|
+
medium: "\u{1F7E1} Medium",
|
|
2165
|
+
low: "\u{1F7E2} Low"
|
|
2166
|
+
};
|
|
2167
|
+
for (const flag of flags) {
|
|
2168
|
+
const reasons = flag.riskReasons.join(", ") || "\u2014";
|
|
2169
|
+
lines.push(
|
|
2170
|
+
`| \`${flag.flagKey}\` | ${riskLabel[flag.riskLevel]} | ${flag.usageCount} | ${flag.fileCount} | ${flag.callTypes.join(", ")} | ${reasons} |`
|
|
2171
|
+
);
|
|
2172
|
+
}
|
|
2173
|
+
lines.push("");
|
|
2174
|
+
lines.push("## Next Steps");
|
|
2175
|
+
lines.push("");
|
|
2176
|
+
lines.push(
|
|
2177
|
+
"- Run `flaglint migrate --dry-run` to preview safe OpenFeature rewrites"
|
|
2178
|
+
);
|
|
2179
|
+
lines.push(
|
|
2180
|
+
"- Run `flaglint validate --no-direct-launchdarkly` to enforce OF boundary in CI"
|
|
2181
|
+
);
|
|
2182
|
+
lines.push(
|
|
2183
|
+
"- Review HIGH risk flags manually before any automated migration"
|
|
2184
|
+
);
|
|
2185
|
+
lines.push("");
|
|
2186
|
+
return lines.join("\n");
|
|
2187
|
+
}
|
|
2188
|
+
function formatAuditHtml(report) {
|
|
2189
|
+
const { summary, flags } = report;
|
|
2190
|
+
const version = true ? "0.6.0" : "0.1.0";
|
|
2191
|
+
const date = new Date(summary.scannedAt).toLocaleString();
|
|
2192
|
+
const riskBadge = (level) => {
|
|
2193
|
+
if (level === "high")
|
|
2194
|
+
return '<span class="badge badge-high">High</span>';
|
|
2195
|
+
if (level === "medium")
|
|
2196
|
+
return '<span class="badge badge-medium">Medium</span>';
|
|
2197
|
+
return '<span class="badge badge-low">Low</span>';
|
|
2198
|
+
};
|
|
2199
|
+
const rows = flags.map((f) => {
|
|
2200
|
+
const reasons = f.riskReasons.length > 0 ? esc2(f.riskReasons.join(", ")) : "\u2014";
|
|
2201
|
+
return `<tr>
|
|
2202
|
+
<td><code>${esc2(f.flagKey)}</code></td>
|
|
2203
|
+
<td>${riskBadge(f.riskLevel)}</td>
|
|
2204
|
+
<td>${f.usageCount}</td>
|
|
2205
|
+
<td>${f.fileCount}</td>
|
|
2206
|
+
<td>${f.callTypes.map(esc2).join(", ")}</td>
|
|
2207
|
+
<td>${reasons}</td>
|
|
2208
|
+
</tr>`;
|
|
2209
|
+
}).join("\n ");
|
|
2210
|
+
return `<!DOCTYPE html>
|
|
2211
|
+
<html lang="en">
|
|
2212
|
+
<head>
|
|
2213
|
+
<meta charset="UTF-8">
|
|
2214
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
2215
|
+
<title>FlagLint Audit Report</title>
|
|
2216
|
+
<style>
|
|
2217
|
+
:root{--bg:#fff;--surface:#f8f9fa;--border:#dee2e6;--text:#212529;--muted:#6c757d;--card-shadow:0 1px 3px rgba(0,0,0,.1)}
|
|
2218
|
+
@media(prefers-color-scheme:dark){:root{--bg:#0f172a;--surface:#1e293b;--border:#334155;--text:#e2e8f0;--muted:#94a3b8;--card-shadow:0 1px 3px rgba(0,0,0,.4)}}
|
|
2219
|
+
*{box-sizing:border-box;margin:0;padding:0}
|
|
2220
|
+
body{background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,sans-serif;padding:2rem;max-width:1200px;margin:0 auto;line-height:1.5}
|
|
2221
|
+
h1{font-size:1.75rem;margin-bottom:.25rem}
|
|
2222
|
+
h2{font-size:1.125rem;margin:2rem 0 .75rem;padding-bottom:.5rem;border-bottom:1px solid var(--border)}
|
|
2223
|
+
.subtitle{color:var(--muted);margin-bottom:1.5rem;font-size:.875rem}
|
|
2224
|
+
.cards{display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:2rem}
|
|
2225
|
+
.card{flex:1;min-width:140px;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:1rem;box-shadow:var(--card-shadow)}
|
|
2226
|
+
.card-num{font-size:1.875rem;font-weight:700;line-height:1}
|
|
2227
|
+
.card-num.red{color:#dc2626}
|
|
2228
|
+
.card-num.amber{color:#d97706}
|
|
2229
|
+
.card-num.green{color:#16a34a}
|
|
2230
|
+
.card-num.blue{color:#3b82f6}
|
|
2231
|
+
.card-num.purple{color:#7c3aed}
|
|
2232
|
+
.card-num.orange{color:#ea580c}
|
|
2233
|
+
.card-label{color:var(--muted);font-size:.75rem;margin-top:.375rem;text-transform:uppercase;letter-spacing:.05em}
|
|
2234
|
+
table{width:100%;border-collapse:collapse;font-size:.8125rem}
|
|
2235
|
+
th{text-align:left;padding:.625rem .75rem;background:var(--surface);border-bottom:2px solid var(--border);font-weight:600;white-space:nowrap}
|
|
2236
|
+
td{padding:.625rem .75rem;border-bottom:1px solid var(--border);vertical-align:top}
|
|
2237
|
+
code{font-family:ui-monospace,monospace;font-size:.8em;background:var(--surface);padding:.1em .3em;border-radius:3px}
|
|
2238
|
+
.badge{display:inline-block;padding:.2em .6em;border-radius:4px;font-size:.75rem;font-weight:600}
|
|
2239
|
+
.badge-high{background:#fee2e2;color:#991b1b}
|
|
2240
|
+
.badge-medium{background:#fef3c7;color:#92400e}
|
|
2241
|
+
.badge-low{background:#dcfce7;color:#166534}
|
|
2242
|
+
@media(prefers-color-scheme:dark){
|
|
2243
|
+
.badge-high{background:#7f1d1d;color:#fca5a5}
|
|
2244
|
+
.badge-medium{background:#78350f;color:#fcd34d}
|
|
2245
|
+
.badge-low{background:#14532d;color:#86efac}
|
|
2246
|
+
}
|
|
2247
|
+
.steps{margin:.75rem 0 1rem 1.25rem;line-height:2}
|
|
2248
|
+
footer{margin-top:3rem;padding-top:1rem;border-top:1px solid var(--border);color:var(--muted);font-size:.75rem;text-align:center}
|
|
2249
|
+
footer a{color:var(--muted)}
|
|
2250
|
+
</style>
|
|
2251
|
+
</head>
|
|
2252
|
+
<body>
|
|
2253
|
+
<h1>FlagLint Audit Report</h1>
|
|
2254
|
+
<p class="subtitle">
|
|
2255
|
+
${esc2(summary.scanRoot)} · ${esc2(summary.scannedFiles.toString())} files · ${esc2(summary.scanDurationMs.toString())}ms · ${esc2(date)}
|
|
2256
|
+
</p>
|
|
2257
|
+
|
|
2258
|
+
<h2>Summary</h2>
|
|
2259
|
+
<div class="cards">
|
|
2260
|
+
<div class="card"><div class="card-num">${summary.totalFlags}</div><div class="card-label">Total Flags</div></div>
|
|
2261
|
+
<div class="card"><div class="card-num red">${summary.highRisk}</div><div class="card-label">High Risk</div></div>
|
|
2262
|
+
<div class="card"><div class="card-num amber">${summary.mediumRisk}</div><div class="card-label">Medium Risk</div></div>
|
|
2263
|
+
<div class="card"><div class="card-num green">${summary.lowRisk}</div><div class="card-label">Low Risk</div></div>
|
|
2264
|
+
<div class="card"><div class="card-num purple">${summary.safelyAutomatable}</div><div class="card-label">Safely Automatable</div></div>
|
|
2265
|
+
<div class="card"><div class="card-num orange">${summary.manualReview}</div><div class="card-label">Manual Review</div></div>
|
|
2266
|
+
</div>
|
|
2267
|
+
|
|
2268
|
+
<h2>Flag Debt Inventory</h2>
|
|
2269
|
+
<table>
|
|
2270
|
+
<thead>
|
|
2271
|
+
<tr>
|
|
2272
|
+
<th>Flag Key</th>
|
|
2273
|
+
<th>Risk</th>
|
|
2274
|
+
<th>Usages</th>
|
|
2275
|
+
<th>Files</th>
|
|
2276
|
+
<th>Call Types</th>
|
|
2277
|
+
<th>Risk Reasons</th>
|
|
2278
|
+
</tr>
|
|
2279
|
+
</thead>
|
|
2280
|
+
<tbody>
|
|
2281
|
+
${rows}
|
|
2282
|
+
</tbody>
|
|
2283
|
+
</table>
|
|
2284
|
+
|
|
2285
|
+
<h2>Next Steps</h2>
|
|
2286
|
+
<ol class="steps">
|
|
2287
|
+
<li>Run <code>flaglint migrate --dry-run</code> to preview safe OpenFeature rewrites</li>
|
|
2288
|
+
<li>Run <code>flaglint validate --no-direct-launchdarkly</code> to enforce OF boundary in CI</li>
|
|
2289
|
+
<li>Review HIGH risk flags manually before any automated migration</li>
|
|
2290
|
+
</ol>
|
|
2291
|
+
|
|
2292
|
+
<footer>
|
|
2293
|
+
Generated by <a href="https://flaglint.dev">FlagLint</a> ${esc2(version)}
|
|
2294
|
+
</footer>
|
|
2295
|
+
</body>
|
|
2296
|
+
</html>`;
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
// src/commands/audit.ts
|
|
2300
|
+
var VALID_AUDIT_FORMATS = ["json", "markdown", "html"];
|
|
2301
|
+
function registerAuditCommand(program2) {
|
|
2302
|
+
program2.command("audit").description("Generate a flag debt audit report").argument("[dir]", "directory to audit", process.cwd()).option("-f, --format <format>", "output format: json | markdown | html", "markdown").option("-o, --output <file>", "write report to file").option("-c, --config <path>", "path to .flaglintrc config file").option("--exclude-tests", "exclude test files (*.test.*, *.spec.*, __tests__/, tests/)").addHelpText(
|
|
2303
|
+
"after",
|
|
2304
|
+
`
|
|
2305
|
+
Examples:
|
|
2306
|
+
$ flaglint audit generate flag debt audit report
|
|
2307
|
+
$ flaglint audit --format html shareable HTML report
|
|
2308
|
+
$ flaglint audit --output audit.md save to file`
|
|
2309
|
+
).action(
|
|
2310
|
+
async (dir, options) => {
|
|
2311
|
+
if (!VALID_AUDIT_FORMATS.includes(options.format)) {
|
|
2312
|
+
process.stderr.write(
|
|
2313
|
+
chalk4.red(
|
|
2314
|
+
`Error: Invalid format '${options.format}'. Must be one of: ${VALID_AUDIT_FORMATS.join(", ")}
|
|
2315
|
+
`
|
|
2316
|
+
)
|
|
2317
|
+
);
|
|
2318
|
+
process.exit(2);
|
|
2319
|
+
}
|
|
2320
|
+
try {
|
|
2321
|
+
const s = await stat4(resolve8(dir));
|
|
2322
|
+
if (!s.isDirectory()) {
|
|
2323
|
+
process.stderr.write(chalk4.red(`Error: Not a directory: ${dir}
|
|
2324
|
+
`));
|
|
2325
|
+
process.exit(1);
|
|
2326
|
+
}
|
|
2327
|
+
} catch (err) {
|
|
2328
|
+
const code = err.code;
|
|
2329
|
+
if (code === "ENOENT") {
|
|
2330
|
+
process.stderr.write(chalk4.red(`Error: Directory not found: ${dir}
|
|
2331
|
+
`));
|
|
2332
|
+
} else if (code === "EACCES") {
|
|
2333
|
+
process.stderr.write(chalk4.red(`Error: Permission denied: ${dir}
|
|
2334
|
+
`));
|
|
2335
|
+
} else {
|
|
2336
|
+
process.stderr.write(chalk4.red(`Error: Cannot access directory: ${dir}
|
|
2337
|
+
`));
|
|
2338
|
+
}
|
|
2339
|
+
process.exit(1);
|
|
2340
|
+
}
|
|
2341
|
+
let config;
|
|
2342
|
+
try {
|
|
2343
|
+
config = await loadConfig(options.config);
|
|
2344
|
+
} catch (err) {
|
|
2345
|
+
process.stderr.write(
|
|
2346
|
+
chalk4.red(String(err instanceof Error ? err.message : err)) + "\n"
|
|
2347
|
+
);
|
|
2348
|
+
process.exit(1);
|
|
2349
|
+
}
|
|
2350
|
+
const TEST_PATTERNS = [
|
|
2351
|
+
"**/*.test.ts",
|
|
2352
|
+
"**/*.test.tsx",
|
|
2353
|
+
"**/*.spec.ts",
|
|
2354
|
+
"**/*.spec.tsx",
|
|
2355
|
+
"**/__tests__/**",
|
|
2356
|
+
"**/tests/**"
|
|
2357
|
+
];
|
|
2358
|
+
const scanConfig = options.excludeTests ? { ...config, exclude: [...config.exclude, ...TEST_PATTERNS] } : config;
|
|
2359
|
+
const format = options.format;
|
|
2360
|
+
const spinner = ora4(`Auditing ${dir}...`).start();
|
|
2361
|
+
process.once("SIGINT", () => {
|
|
2362
|
+
spinner.stop();
|
|
2363
|
+
process.exit(130);
|
|
2364
|
+
});
|
|
2365
|
+
let lastSpinnerUpdate = 0;
|
|
2366
|
+
let scanResult;
|
|
2367
|
+
try {
|
|
2368
|
+
scanResult = await scan(new LocalFileSource(dir), scanConfig, (filesScanned) => {
|
|
2369
|
+
if (filesScanned - lastSpinnerUpdate >= 50) {
|
|
2370
|
+
spinner.text = `Scanning... (${filesScanned} files)`;
|
|
2371
|
+
lastSpinnerUpdate = filesScanned;
|
|
2372
|
+
}
|
|
2373
|
+
});
|
|
2374
|
+
spinner.stop();
|
|
2375
|
+
} catch (err) {
|
|
2376
|
+
spinner.fail("Audit scan failed");
|
|
2377
|
+
process.stderr.write(chalk4.red(String(err)) + "\n");
|
|
2378
|
+
process.exit(1);
|
|
2379
|
+
}
|
|
2380
|
+
for (const w of scanResult.warnings) {
|
|
2381
|
+
const msg = w.kind === "read-failure" ? `warn: could not read ${w.file} (${w.fsCode})` : `warn: failed to parse ${w.file}`;
|
|
2382
|
+
process.stderr.write(chalk4.yellow(msg + "\n"));
|
|
2383
|
+
}
|
|
2384
|
+
if (scanResult.scannedFiles === 0) {
|
|
2385
|
+
process.stderr.write(
|
|
2386
|
+
chalk4.yellow("No matching files found. Check your .flaglintrc include patterns.\n")
|
|
2387
|
+
);
|
|
2388
|
+
process.exit(0);
|
|
2389
|
+
}
|
|
2390
|
+
if (scanResult.totalUsages === 0) {
|
|
2391
|
+
process.stderr.write(
|
|
2392
|
+
chalk4.dim(
|
|
2393
|
+
`No LaunchDarkly SDK usage detected in ${scanResult.scannedFiles} files.
|
|
2394
|
+
`
|
|
2395
|
+
)
|
|
2396
|
+
);
|
|
2397
|
+
process.exit(0);
|
|
2398
|
+
}
|
|
2399
|
+
const auditReport = buildAuditReport(
|
|
2400
|
+
scanResult,
|
|
2401
|
+
scanResult.migrationInventory ?? []
|
|
2402
|
+
);
|
|
2403
|
+
let output;
|
|
2404
|
+
if (format === "json") {
|
|
2405
|
+
output = formatAuditJson(auditReport);
|
|
2406
|
+
} else if (format === "html") {
|
|
2407
|
+
output = formatAuditHtml(auditReport);
|
|
2408
|
+
} else {
|
|
2409
|
+
output = formatAuditMarkdown(auditReport);
|
|
2410
|
+
}
|
|
2411
|
+
if (options.output) {
|
|
2412
|
+
const outPath = resolve8(options.output);
|
|
2413
|
+
try {
|
|
2414
|
+
await writeFile5(outPath, output, "utf8");
|
|
2415
|
+
process.stderr.write(chalk4.dim(` Report written to ${options.output}
|
|
2416
|
+
`));
|
|
2417
|
+
} catch (err) {
|
|
2418
|
+
process.stderr.write(
|
|
2419
|
+
chalk4.red(
|
|
2420
|
+
`Error: Failed to write report to ${options.output}: ${err instanceof Error ? err.message : String(err)}
|
|
2421
|
+
`
|
|
2422
|
+
)
|
|
2423
|
+
);
|
|
2424
|
+
process.exit(1);
|
|
2425
|
+
}
|
|
2426
|
+
} else {
|
|
2427
|
+
process.stdout.write(output + "\n");
|
|
2428
|
+
}
|
|
2429
|
+
const { totalFlags, highRisk, mediumRisk, lowRisk } = auditReport.summary;
|
|
2430
|
+
process.stderr.write(
|
|
2431
|
+
chalk4.green(
|
|
2432
|
+
`\u2713 Audit complete: ${totalFlags} flags \u2014 ${highRisk} high risk, ${mediumRisk} medium risk, ${lowRisk} low risk
|
|
2433
|
+
`
|
|
2434
|
+
)
|
|
2435
|
+
);
|
|
2436
|
+
process.exit(0);
|
|
2437
|
+
}
|
|
2438
|
+
);
|
|
2439
|
+
}
|
|
2440
|
+
|
|
1980
2441
|
// src/cli.ts
|
|
1981
2442
|
function createCLI() {
|
|
1982
2443
|
const program2 = new Command();
|
|
1983
|
-
program2.name("flaglint").description("LaunchDarkly Node.js
|
|
2444
|
+
program2.name("flaglint").description("Scan LaunchDarkly Node.js SDK usage, generate safe OpenFeature migration diffs, and enforce the vendor boundary in CI.").version("0.6.0", "-v, --version", "output the current version").addHelpText(
|
|
1984
2445
|
"after",
|
|
1985
2446
|
`
|
|
1986
2447
|
Examples:
|
|
@@ -1990,11 +2451,15 @@ Examples:
|
|
|
1990
2451
|
$ flaglint scan --output report.md save to file
|
|
1991
2452
|
$ flaglint migrate generate migration plan
|
|
1992
2453
|
$ flaglint migrate --dry-run preview without writing
|
|
1993
|
-
$ flaglint validate --no-direct-launchdarkly enforce OF migration in CI
|
|
2454
|
+
$ flaglint validate --no-direct-launchdarkly enforce OF migration in CI
|
|
2455
|
+
$ flaglint audit generate flag debt audit report
|
|
2456
|
+
$ flaglint audit --format html shareable HTML report
|
|
2457
|
+
$ flaglint audit --output audit.md save to file`
|
|
1994
2458
|
);
|
|
1995
2459
|
registerScanCommand(program2);
|
|
1996
2460
|
registerMigrateCommand(program2);
|
|
1997
2461
|
registerValidateCommand(program2);
|
|
2462
|
+
registerAuditCommand(program2);
|
|
1998
2463
|
return program2;
|
|
1999
2464
|
}
|
|
2000
2465
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flaglint",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "LaunchDarkly Node.js
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Scan LaunchDarkly Node.js SDK usage, generate safe OpenFeature migration diffs, and enforce the vendor boundary in CI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"flaglint": "dist/bin/flaglint.js"
|
|
@@ -71,9 +71,11 @@
|
|
|
71
71
|
"@vitest/coverage-v8": "^4.1.6",
|
|
72
72
|
"astro": "^6.3.8",
|
|
73
73
|
"clipboardy": "^4.0.0",
|
|
74
|
+
"png-to-ico": "^3.0.1",
|
|
74
75
|
"tsup": "^8.2.4",
|
|
75
76
|
"tsx": "^4.19.0",
|
|
76
77
|
"typescript": "^5.5.4",
|
|
77
|
-
"vitest": "^4.1.6"
|
|
78
|
+
"vitest": "^4.1.6",
|
|
79
|
+
"starlight-blog": "^0.26.1"
|
|
78
80
|
}
|
|
79
81
|
}
|