flaglint 0.4.1 → 0.5.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 +81 -1
- package/README.md +155 -27
- package/dist/apply-ZYLA2N7Y.js +13 -0
- package/dist/bin/flaglint.js +273 -227
- package/dist/chunk-MJLXM6GZ.js +263 -0
- package/package.json +7 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,7 +5,87 @@ 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
|
-
##
|
|
8
|
+
## Unreleased
|
|
9
|
+
|
|
10
|
+
## [0.5.0] - 2026-05-26
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Configured imported OpenFeature client bindings** (`openFeatureClientBindings` in `.flaglintrc`):
|
|
15
|
+
Declare shared OpenFeature client exports by import name and glob module pattern.
|
|
16
|
+
`migrate --apply` recognises these imports as proven bindings without requiring every
|
|
17
|
+
service file to call `OpenFeature.getClient()` locally.
|
|
18
|
+
```json
|
|
19
|
+
{
|
|
20
|
+
"openFeatureClientBindings": [
|
|
21
|
+
{ "importName": "openFeatureClient", "modulePatterns": ["**/platform/feature-flags"] }
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
- **TypeScript ESM `.js` import compatibility**: `modulePatterns` globs now match TypeScript
|
|
27
|
+
source imports that carry a `.js` extension at the specifier level
|
|
28
|
+
(`import { openFeatureClient } from "../platform/feature-flags.js"`) even when the
|
|
29
|
+
configured pattern omits the extension (`**/platform/feature-flags`).
|
|
30
|
+
|
|
31
|
+
- **`validate --format sarif`**: `flaglint validate --no-direct-launchdarkly --format sarif
|
|
32
|
+
--output flaglint.sarif` emits SARIF 2.1.0 with rule id `flaglint.direct-launchdarkly`
|
|
33
|
+
and level `error`. Designed for GitHub Code Scanning upload — each direct LaunchDarkly
|
|
34
|
+
evaluation call produces a PR annotation. Zero violations produces a valid SARIF document
|
|
35
|
+
that GitHub Code Scanning interprets as "all clear".
|
|
36
|
+
|
|
37
|
+
- **Enterprise HTML audit report**: `flaglint scan --format html` now includes an Executive
|
|
38
|
+
Summary (total call-sites, unique flags, auto-migratable vs. manual-review breakdown),
|
|
39
|
+
Findings by Directory table, Recommended Next Steps workflow, and a Copy Markdown Summary
|
|
40
|
+
clipboard button.
|
|
41
|
+
|
|
42
|
+
- **Enterprise OpenFeature migration demo** (`examples/enterprise-checkout-service/`): end-to-end
|
|
43
|
+
walkthrough across five Node.js services (checkout, pricing, analytics, product, flags-wrapper).
|
|
44
|
+
Includes `before/`/`after/` snapshots, `after-complete/` (fully migrated, passes hard gate),
|
|
45
|
+
generated reports, `.flaglintrc` config, and a sample GitHub Actions CI workflow.
|
|
46
|
+
|
|
47
|
+
- **Docs site** (`www/docs/`): nine documentation pages covering getting started, all three
|
|
48
|
+
commands, supported scope, OpenFeature provider setup, CI/GitHub Actions integration,
|
|
49
|
+
OpenTelemetry observability guidance, safety model, and the enterprise demo.
|
|
50
|
+
|
|
51
|
+
- **Enterprise trust documentation**: `SECURITY.md`, `CONTRIBUTING.md`, and `CODE_OF_CONDUCT.md`
|
|
52
|
+
with SARIF rule-ID reference for the `flaglint.direct-launchdarkly` policy rule.
|
|
53
|
+
|
|
54
|
+
- **OpenFeature + OpenTelemetry observability guidance** (`www/docs/opentelemetry.html`):
|
|
55
|
+
documents how to instrument OpenFeature flag evaluations with OpenTelemetry using the
|
|
56
|
+
OpenFeature hooks API. FlagLint does not emit runtime telemetry; this page explains the
|
|
57
|
+
complementary integration pattern.
|
|
58
|
+
|
|
59
|
+
### Fixed
|
|
60
|
+
|
|
61
|
+
- **Deterministic test execution from clean checkout**: `vitest` configuration no longer
|
|
62
|
+
relies on `process.env.INIT_CWD` for test file discovery, ensuring the test suite
|
|
63
|
+
is reproducible on first-time `npm ci && npm test` runs.
|
|
64
|
+
|
|
65
|
+
### Changed
|
|
66
|
+
|
|
67
|
+
- Reposition README and homepage messaging around standardizing LaunchDarkly usage on
|
|
68
|
+
OpenFeature while keeping LaunchDarkly as the provider.
|
|
69
|
+
- Document the focused automation scope: LaunchDarkly Node.js server-side evaluation calls
|
|
70
|
+
in TypeScript and JavaScript, with dynamic keys, detail evaluations, bulk calls, browser
|
|
71
|
+
SDKs, React usage, and ambiguous patterns reported for manual review.
|
|
72
|
+
- Align supported runtime documentation and package metadata to Node.js `>=20`.
|
|
73
|
+
Resolves the Node.js engine metadata mismatch in `flaglint@0.4.1` (published as `>=22`).
|
|
74
|
+
|
|
75
|
+
### Security
|
|
76
|
+
|
|
77
|
+
- Add Node.js 20/22 CI coverage, CodeQL analysis, Dependabot configuration, and vulnerability
|
|
78
|
+
reporting instructions.
|
|
79
|
+
- Update npm release workflow for Trusted Publishing/OIDC without long-lived npm publish tokens.
|
|
80
|
+
|
|
81
|
+
### Scope boundaries (non-claims)
|
|
82
|
+
|
|
83
|
+
The following are explicitly out of scope for this release:
|
|
84
|
+
- LaunchDarkly replacement — LaunchDarkly remains the feature flag provider throughout.
|
|
85
|
+
- Automatic provider/bootstrap setup — `migrate --apply` never generates bootstrap files.
|
|
86
|
+
- Flag deletion or billing reduction — FlagLint does not evaluate live flag values.
|
|
87
|
+
- Built-in runtime OpenTelemetry instrumentation — see `www/docs/opentelemetry.html` for
|
|
88
|
+
the complementary integration pattern using OpenFeature hooks.
|
|
9
89
|
|
|
10
90
|
## [0.4.1] - 2026-05-25
|
|
11
91
|
|
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
6
|
-
<strong>LaunchDarkly
|
|
6
|
+
<strong>Standardize LaunchDarkly usage on OpenFeature.</strong>
|
|
7
7
|
</p>
|
|
8
8
|
|
|
9
9
|
<p align="center">
|
|
@@ -21,21 +21,19 @@
|
|
|
21
21
|
</a>
|
|
22
22
|
</p>
|
|
23
23
|
|
|
24
|
-
> [!WARNING]
|
|
25
|
-
> FlagLint is currently an early preview.
|
|
26
|
-
>
|
|
27
|
-
> Automatic migration currently supports LaunchDarkly Node.js server-side SDK evaluation calls only. Generated changes must be reviewed and tested before merging.
|
|
28
|
-
>
|
|
29
|
-
> React hooks, higher-order components, browser/client-side SDK usage, bulk flag-state calls, detail evaluations, dynamic keys, and custom wrappers are not automatically migrated in this release.
|
|
30
|
-
|
|
31
24
|
# FlagLint
|
|
32
25
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
26
|
+
Standardize LaunchDarkly usage on OpenFeature.
|
|
27
|
+
|
|
28
|
+
FlagLint inventories direct LaunchDarkly Node.js SDK calls, generates reviewable migration
|
|
29
|
+
plans, and prevents new vendor-coupled flag access from entering your codebase.
|
|
36
30
|
|
|
37
31
|
**LaunchDarkly remains your provider. OpenFeature becomes the evaluation API your application code calls.**
|
|
38
32
|
|
|
33
|
+
Docs: [Getting Started](https://flaglint.dev/docs/getting-started) · [Commands](https://flaglint.dev/docs/commands/scan) · [Supported Scope](https://flaglint.dev/docs/supported-scope) · [CI Integration](https://flaglint.dev/docs/ci-github-actions) · [Enterprise Demo](https://flaglint.dev/docs/demo)
|
|
34
|
+
|
|
35
|
+
[View enterprise demo source ->](./examples/enterprise-checkout-service)
|
|
36
|
+
|
|
39
37
|
---
|
|
40
38
|
|
|
41
39
|
## Workflow
|
|
@@ -98,6 +96,8 @@ npm install -g flaglint
|
|
|
98
96
|
npx flaglint
|
|
99
97
|
```
|
|
100
98
|
|
|
99
|
+
Requires Node.js 20 or newer. CI validates FlagLint on Node.js 20 and 22.
|
|
100
|
+
|
|
101
101
|
---
|
|
102
102
|
|
|
103
103
|
## Commands
|
|
@@ -149,8 +149,13 @@ flaglint migrate --exclude-tests # skip test and spec files
|
|
|
149
149
|
|
|
150
150
|
**`--apply` safety contracts:**
|
|
151
151
|
- Refuses on a dirty git working tree unless `--allow-dirty`
|
|
152
|
-
- Skips any file that does not
|
|
153
|
-
|
|
152
|
+
- Skips any file that does not contain a proven OpenFeature client binding:
|
|
153
|
+
either a local `OpenFeature.getClient()` binding or a configured imported
|
|
154
|
+
shared client allowlisted with `openFeatureClientBindings`
|
|
155
|
+
- Imported client matching uses glob-safe `modulePatterns`; aliased imports
|
|
156
|
+
preserve the local identifier; TypeScript ESM `.js` runtime import specifiers
|
|
157
|
+
are recognized; ambiguous or unconfigured imports skip safely
|
|
158
|
+
- Provider/bootstrap setup is never inserted automatically
|
|
154
159
|
- Never touches detail methods, dynamic keys, unknown fallbacks, or bulk calls
|
|
155
160
|
- Preserves `await` and original call arguments exactly
|
|
156
161
|
- Idempotent: re-running with the same analysis has no effect
|
|
@@ -198,6 +203,17 @@ Run `flaglint migrate --dry-run` to review the migration plan.
|
|
|
198
203
|
|
|
199
204
|
---
|
|
200
205
|
|
|
206
|
+
## Focused scope for safe automation
|
|
207
|
+
|
|
208
|
+
Automatic migration currently supports LaunchDarkly Node.js server-side evaluation calls in
|
|
209
|
+
TypeScript and JavaScript. That narrow scope is intentional: FlagLint only rewrites call sites
|
|
210
|
+
where the value type, static flag key, fallback, evaluation context, and OpenFeature client
|
|
211
|
+
binding are explicit enough to preserve.
|
|
212
|
+
|
|
213
|
+
Dynamic keys, detail evaluations, bulk flag-state calls, browser SDKs, React usage, and ambiguous
|
|
214
|
+
patterns are reported for manual review. They are inventoried so teams can plan the migration,
|
|
215
|
+
but they are not automatically transformed.
|
|
216
|
+
|
|
201
217
|
## Supported API matrix
|
|
202
218
|
|
|
203
219
|
**Scope: LaunchDarkly Node.js server-side SDK** (`launchdarkly-node-server-sdk`).
|
|
@@ -249,6 +265,62 @@ export const openFeatureClient = OpenFeature.getClient();
|
|
|
249
265
|
|
|
250
266
|
---
|
|
251
267
|
|
|
268
|
+
## Using an existing shared OpenFeature client
|
|
269
|
+
|
|
270
|
+
If your platform team already exports an OpenFeature client from a shared internal module
|
|
271
|
+
(e.g. `platform/feature-flags.ts`), FlagLint can use that import as the proven binding for
|
|
272
|
+
`--apply` — no local `OpenFeature.getClient()` call is required in every file.
|
|
273
|
+
|
|
274
|
+
Declare the allowed import in `.flaglintrc`:
|
|
275
|
+
|
|
276
|
+
```json
|
|
277
|
+
{
|
|
278
|
+
"openFeatureClientBindings": [
|
|
279
|
+
{
|
|
280
|
+
"importName": "openFeatureClient",
|
|
281
|
+
"modulePatterns": ["**/platform/feature-flags"]
|
|
282
|
+
}
|
|
283
|
+
]
|
|
284
|
+
}
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
`modulePatterns` are **glob patterns** matched against the module import specifier
|
|
288
|
+
(leading `./` and `../` traversal is stripped before matching). A pattern of
|
|
289
|
+
`"**/platform/feature-flags"` matches `"../platform/feature-flags"` and
|
|
290
|
+
`"../../shared/platform/feature-flags"`, but **not** `"../platform/feature-flags-legacy"` or
|
|
291
|
+
`"../other/platform/feature-flags-backup"`.
|
|
292
|
+
For TypeScript ESM projects, configured module patterns without `.js` also recognize
|
|
293
|
+
the corresponding `.js` runtime import specifier.
|
|
294
|
+
|
|
295
|
+
**Before:**
|
|
296
|
+
```typescript
|
|
297
|
+
// services/checkout.ts
|
|
298
|
+
import { openFeatureClient } from "../platform/feature-flags";
|
|
299
|
+
import LaunchDarkly from "launchdarkly-node-server-sdk";
|
|
300
|
+
|
|
301
|
+
const enabled = ldClient.boolVariation("checkout-v2", { key: user.id }, false);
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
**After (`flaglint migrate --apply`):**
|
|
305
|
+
```typescript
|
|
306
|
+
// services/checkout.ts
|
|
307
|
+
import { openFeatureClient } from "../platform/feature-flags";
|
|
308
|
+
import LaunchDarkly from "launchdarkly-node-server-sdk";
|
|
309
|
+
|
|
310
|
+
const enabled = openFeatureClient.getBooleanValue("checkout-v2", false, { key: user.id });
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
If the import is aliased (e.g. `import { openFeatureClient as flags } from "..."`), FlagLint
|
|
314
|
+
previews and applies the transformation using the **local alias name** (`flags.getBooleanValue(...)`).
|
|
315
|
+
|
|
316
|
+
When two configured bindings both match a file, FlagLint considers the result ambiguous and
|
|
317
|
+
**skips that file** rather than guessing. Skipped files are reported in `--dry-run` output.
|
|
318
|
+
|
|
319
|
+
Provider initialization remains the platform team's responsibility. FlagLint never inserts or
|
|
320
|
+
modifies bootstrap/provider setup code.
|
|
321
|
+
|
|
322
|
+
---
|
|
323
|
+
|
|
252
324
|
## Example transformation
|
|
253
325
|
|
|
254
326
|
**Before — direct LaunchDarkly Node.js server SDK:**
|
|
@@ -260,13 +332,16 @@ const timeout = await ldClient.numberVariation("timeout-ms", { key: user.id },
|
|
|
260
332
|
|
|
261
333
|
**After — OpenFeature via LaunchDarkly provider:**
|
|
262
334
|
```typescript
|
|
263
|
-
const enabled = await openFeatureClient.getBooleanValue("checkout-v2", false, {
|
|
264
|
-
const theme = await openFeatureClient.getStringValue("color-theme", "light", {
|
|
265
|
-
const timeout = await openFeatureClient.getNumberValue("timeout-ms", 5000, {
|
|
335
|
+
const enabled = await openFeatureClient.getBooleanValue("checkout-v2", false, { key: user.id });
|
|
336
|
+
const theme = await openFeatureClient.getStringValue("color-theme", "light", { key: user.id });
|
|
337
|
+
const timeout = await openFeatureClient.getNumberValue("timeout-ms", 5000, { key: user.id });
|
|
266
338
|
```
|
|
267
339
|
|
|
268
340
|
Flag key, fallback value, `await`, and evaluation context are preserved exactly.
|
|
269
341
|
LaunchDarkly continues to serve the flags — only the call-site API changes.
|
|
342
|
+
When authoring new OpenFeature-native bootstrap or application code, you may use
|
|
343
|
+
OpenFeature `targetingKey`; FlagLint does not silently rewrite existing
|
|
344
|
+
LaunchDarkly `key` contexts.
|
|
270
345
|
|
|
271
346
|
---
|
|
272
347
|
|
|
@@ -291,6 +366,7 @@ Create `.flaglintrc`, `.flaglintrc.json`, or `flaglint.config.json` in your proj
|
|
|
291
366
|
| `provider` | `string` | `"launchdarkly"` | Feature flag provider |
|
|
292
367
|
| `minFileCount` | `number` | `0` | Opt-in staleness heuristic. When set above 0, a flag is a staleness candidate if it appears in ≤ N files |
|
|
293
368
|
| `wrappers` | `string[]` | `[]` | Function names wrapping LD SDK calls. Example: `["flagPredicate", "useFlag"]` |
|
|
369
|
+
| `openFeatureClientBindings` | `{ importName: string; modulePatterns: string[] }[]` | `[]` | Allowlist shared imported OpenFeature client bindings for `--apply` eligibility. See [Using an existing shared OpenFeature client](#using-an-existing-shared-openfeature-client). |
|
|
294
370
|
| `reportTitle` | `string` | — | Custom title for generated reports |
|
|
295
371
|
| `outputDir` | `string` | `"."` | Default output directory |
|
|
296
372
|
|
|
@@ -328,22 +404,34 @@ jobs:
|
|
|
328
404
|
with:
|
|
329
405
|
node-version: 20
|
|
330
406
|
|
|
331
|
-
- name:
|
|
332
|
-
run: npx flaglint scan --format
|
|
407
|
+
- name: Inventory LaunchDarkly SDK usage
|
|
408
|
+
run: npx flaglint scan ./src --format html --output flaglint-inventory.html
|
|
409
|
+
continue-on-error: true
|
|
410
|
+
|
|
411
|
+
- name: Plan OpenFeature migration
|
|
412
|
+
run: npx flaglint migrate ./src --dry-run --output flaglint-migration.md
|
|
413
|
+
continue-on-error: true
|
|
414
|
+
|
|
415
|
+
- name: Validate direct LaunchDarkly policy
|
|
416
|
+
run: |
|
|
417
|
+
npx flaglint validate ./src \
|
|
418
|
+
--no-direct-launchdarkly \
|
|
419
|
+
--bootstrap-exclude "src/provider/setup.ts" \
|
|
420
|
+
--format sarif \
|
|
421
|
+
--output flaglint-validation.sarif
|
|
333
422
|
continue-on-error: true
|
|
334
423
|
|
|
335
424
|
- name: Upload to GitHub Code Scanning
|
|
336
425
|
uses: github/codeql-action/upload-sarif@v3
|
|
337
426
|
with:
|
|
338
|
-
sarif_file: flaglint.sarif
|
|
339
|
-
|
|
340
|
-
- name: Enforce OpenFeature migration
|
|
341
|
-
run: |
|
|
342
|
-
npx flaglint validate --no-direct-launchdarkly \
|
|
343
|
-
--bootstrap-exclude "src/provider/setup.ts"
|
|
427
|
+
sarif_file: flaglint-validation.sarif
|
|
344
428
|
```
|
|
345
429
|
|
|
346
|
-
|
|
430
|
+
`scan` is for inventory and reporting. `migrate --dry-run` is for migration
|
|
431
|
+
planning. `validate --no-direct-launchdarkly --format sarif` is for CI policy
|
|
432
|
+
annotations and enforcement. Code Scanning alerts show the exact file and line of
|
|
433
|
+
each direct LD call under the SARIF rule id `flaglint.direct-launchdarkly` —
|
|
434
|
+
reviewers see them in the PR without running anything locally.
|
|
347
435
|
|
|
348
436
|
---
|
|
349
437
|
|
|
@@ -351,8 +439,48 @@ Code Scanning alerts show the exact file and line of each direct LD call — rev
|
|
|
351
439
|
|
|
352
440
|
Validated against 120 deterministic benchmark cases within the supported LaunchDarkly Node.js server-side SDK scope. 100% precision and recall are limited to those 120 tested cases and to the Node.js server-side SDK call patterns explicitly listed in the Supported API matrix above.
|
|
353
441
|
|
|
354
|
-
Detection is AST-based, not regex: client binding patterns, import aliases, CJS require
|
|
355
|
-
|
|
442
|
+
Detection is AST-based, not regex: client binding patterns, import aliases, and CJS require
|
|
443
|
+
forms are resolved before matching.
|
|
444
|
+
|
|
445
|
+
---
|
|
446
|
+
|
|
447
|
+
## Enterprise demo
|
|
448
|
+
|
|
449
|
+
See a realistic end-to-end migration walkthrough — multiple Node.js services,
|
|
450
|
+
mixed automatable and manual-review patterns, SARIF output, and CI enforcement:
|
|
451
|
+
|
|
452
|
+
**[View enterprise demo](./examples/enterprise-checkout-service/README.md)**
|
|
453
|
+
|
|
454
|
+
The demo shows scan inventory, exact dry-run preview, guarded apply,
|
|
455
|
+
migration-in-progress advisory findings, and a completed-state validation hard
|
|
456
|
+
gate.
|
|
457
|
+
|
|
458
|
+
---
|
|
459
|
+
|
|
460
|
+
## Security and trust
|
|
461
|
+
|
|
462
|
+
FlagLint runs entirely on your machine. No source code, flag keys, or file paths
|
|
463
|
+
are transmitted to any external service. The tool makes no outbound network
|
|
464
|
+
connections during a flag scan or migration. No LaunchDarkly SDK key or any
|
|
465
|
+
credentials are required.
|
|
466
|
+
|
|
467
|
+
`flaglint migrate --apply` refuses to write files on a dirty git working tree
|
|
468
|
+
(unless `--allow-dirty` is passed), requires a proven OpenFeature client binding
|
|
469
|
+
before touching a file, and verifies each source range against the original call
|
|
470
|
+
expression before rewriting.
|
|
471
|
+
|
|
472
|
+
The project release workflow is configured to publish through GitHub Actions
|
|
473
|
+
using npm Trusted Publishing/OIDC. The publish job uses Node 24 because npm
|
|
474
|
+
Trusted Publishing has stricter runtime requirements; FlagLint runtime support
|
|
475
|
+
remains Node.js >=20. npm-side Trusted Publisher configuration must be completed
|
|
476
|
+
before the first OIDC-based publication unless it has already been configured
|
|
477
|
+
and verified.
|
|
478
|
+
|
|
479
|
+
Core behavior is covered by automated tests executed in CI on supported Node
|
|
480
|
+
versions.
|
|
481
|
+
|
|
482
|
+
For vulnerability reports, see [SECURITY.md](./SECURITY.md).
|
|
483
|
+
For a full trust and provenance statement, see [docs/trust.md](./docs/trust.md).
|
|
356
484
|
|
|
357
485
|
---
|
|
358
486
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
ApplyError,
|
|
4
|
+
applyMigration,
|
|
5
|
+
getOpenFeatureClientBindingName,
|
|
6
|
+
hasOpenFeatureClientBinding
|
|
7
|
+
} from "./chunk-MJLXM6GZ.js";
|
|
8
|
+
export {
|
|
9
|
+
ApplyError,
|
|
10
|
+
applyMigration,
|
|
11
|
+
getOpenFeatureClientBindingName,
|
|
12
|
+
hasOpenFeatureClientBinding
|
|
13
|
+
};
|
package/dist/bin/flaglint.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
ApplyError,
|
|
4
|
+
applyMigration
|
|
5
|
+
} from "../chunk-MJLXM6GZ.js";
|
|
2
6
|
|
|
3
7
|
// src/cli.ts
|
|
4
8
|
import { Command } from "commander";
|
|
@@ -672,6 +676,16 @@ function formatSARIF(result) {
|
|
|
672
676
|
2
|
|
673
677
|
);
|
|
674
678
|
}
|
|
679
|
+
function usagesByDirectory(usages) {
|
|
680
|
+
const map = /* @__PURE__ */ new Map();
|
|
681
|
+
for (const u of usages) {
|
|
682
|
+
const parts = u.file.replace(/\\/g, "/").split("/");
|
|
683
|
+
const dir = parts.length > 1 ? parts.slice(0, Math.min(2, parts.length - 1)).join("/") : ".";
|
|
684
|
+
if (!map.has(dir)) map.set(dir, []);
|
|
685
|
+
map.get(dir).push(u);
|
|
686
|
+
}
|
|
687
|
+
return new Map([...map.entries()].sort(([a], [b]) => a.localeCompare(b)));
|
|
688
|
+
}
|
|
675
689
|
function formatHTML(result, options) {
|
|
676
690
|
const { scannedFiles, totalUsages, uniqueFlags, usages, scanDurationMs } = result;
|
|
677
691
|
const staleCount = new Set(
|
|
@@ -679,6 +693,34 @@ function formatHTML(result, options) {
|
|
|
679
693
|
).size;
|
|
680
694
|
const dynamicCount = usages.filter((u) => u.isDynamic).length;
|
|
681
695
|
const date = new Date(result.scannedAt).toLocaleString();
|
|
696
|
+
const inv = result.migrationInventory ?? [];
|
|
697
|
+
const automatableCount = inv.filter((i) => i.safelyAutomatable).length;
|
|
698
|
+
const manualCount = inv.filter((i) => !i.safelyAutomatable).length;
|
|
699
|
+
const detailBulkCount = inv.filter(
|
|
700
|
+
(i) => i.manualReviewReason === "detail-method" || i.manualReviewReason === "bulk-inventory-call"
|
|
701
|
+
).length;
|
|
702
|
+
const affectedFiles = new Set(usages.map((u) => u.file)).size;
|
|
703
|
+
const automatablePct = inv.length > 0 ? Math.round(automatableCount / inv.length * 100) : 0;
|
|
704
|
+
const manualPct = inv.length > 0 ? Math.round(manualCount / inv.length * 100) : 0;
|
|
705
|
+
const markdownSummary = [
|
|
706
|
+
"## FlagLint Audit Summary",
|
|
707
|
+
"",
|
|
708
|
+
`- **Total call-sites:** ${totalUsages}`,
|
|
709
|
+
`- **Unique flags:** ${uniqueFlags.length}`,
|
|
710
|
+
`- **Files affected:** ${affectedFiles}`,
|
|
711
|
+
...inv.length > 0 ? [
|
|
712
|
+
`- **Safely automatable:** ${automatableCount} (${automatablePct}%)`,
|
|
713
|
+
`- **Manual review required:** ${manualCount} (${manualPct}%)`,
|
|
714
|
+
`- **Dynamic keys:** ${dynamicCount}`,
|
|
715
|
+
`- **Detail/bulk calls:** ${detailBulkCount}`
|
|
716
|
+
] : [`- **Dynamic keys:** ${dynamicCount}`, `- **Stale candidates:** ${staleCount}`],
|
|
717
|
+
"",
|
|
718
|
+
"### Recommended next steps",
|
|
719
|
+
"1. Configure OpenFeature provider (one-time manual step)",
|
|
720
|
+
"2. Review migration plan: `flaglint migrate --dry-run`",
|
|
721
|
+
"3. Apply automatable transformations: `flaglint migrate --apply`",
|
|
722
|
+
"4. Add CI enforcement: `flaglint validate --no-direct-launchdarkly`"
|
|
723
|
+
].join("\\n");
|
|
682
724
|
const flagMap = buildFlagMap(usages);
|
|
683
725
|
const sorted = sortedFlagEntries(flagMap);
|
|
684
726
|
const rows = sorted.map(([key, data]) => {
|
|
@@ -687,8 +729,18 @@ function formatHTML(result, options) {
|
|
|
687
729
|
const fileList = [...data.files].map((f) => esc(f)).join("<br>");
|
|
688
730
|
return `<tr class="${cls}"><td><code>${esc(key)}</code></td><td>${data.usages.length}</td><td>${fileList}</td><td>${[...data.callTypes].map(esc).join(", ")}</td><td>${status}</td></tr>`;
|
|
689
731
|
}).join("\n ");
|
|
732
|
+
const byDir = usagesByDirectory(usages);
|
|
733
|
+
const dirRows = [...byDir.entries()].map(([dir, dirUsages]) => {
|
|
734
|
+
const flagKeys = new Set(dirUsages.map((u) => u.flagKey)).size;
|
|
735
|
+
const callTypes = new Set(dirUsages.map((u) => u.callType));
|
|
736
|
+
return `<tr><td><code>${esc(dir)}</code></td><td>${dirUsages.length}</td><td>${flagKeys}</td><td>${[...callTypes].map(esc).join(", ")}</td></tr>`;
|
|
737
|
+
}).join("\n ");
|
|
738
|
+
const auditCards = inv.length > 0 ? `
|
|
739
|
+
<div class="card"><div class="card-num green">${automatableCount}</div><div class="card-label">Auto-Migratable (${automatablePct}%)</div></div>
|
|
740
|
+
<div class="card"><div class="card-num orange">${manualCount}</div><div class="card-label">Manual Review (${manualPct}%)</div></div>
|
|
741
|
+
<div class="card"><div class="card-num blue">${detailBulkCount}</div><div class="card-label">Detail/Bulk Calls</div></div>` : "";
|
|
690
742
|
const title = options.title ? esc(options.title) : "FlagLint Scan Report";
|
|
691
|
-
const version = true ? "0.
|
|
743
|
+
const version = true ? "0.5.0" : "0.1.0";
|
|
692
744
|
return `<!DOCTYPE html>
|
|
693
745
|
<html lang="en">
|
|
694
746
|
<head>
|
|
@@ -702,12 +754,15 @@ function formatHTML(result, options) {
|
|
|
702
754
|
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}
|
|
703
755
|
h1{font-size:1.75rem;margin-bottom:.25rem}
|
|
704
756
|
h2{font-size:1.125rem;margin:2rem 0 .75rem;padding-bottom:.5rem;border-bottom:1px solid var(--border)}
|
|
757
|
+
h3{font-size:.9375rem;margin:1.5rem 0 .5rem;color:var(--muted)}
|
|
705
758
|
.subtitle{color:var(--muted);margin-bottom:1.5rem;font-size:.875rem}
|
|
706
759
|
.cards{display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:2rem}
|
|
707
760
|
.card{flex:1;min-width:140px;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:1rem;box-shadow:var(--card-shadow)}
|
|
708
761
|
.card-num{font-size:1.875rem;font-weight:700;line-height:1}
|
|
709
762
|
.card-num.yellow{color:#d97706}
|
|
710
763
|
.card-num.blue{color:#3b82f6}
|
|
764
|
+
.card-num.green{color:#16a34a}
|
|
765
|
+
.card-num.orange{color:#ea580c}
|
|
711
766
|
.card-label{color:var(--muted);font-size:.75rem;margin-top:.375rem;text-transform:uppercase;letter-spacing:.05em}
|
|
712
767
|
.filter-wrap{margin-bottom:.75rem}
|
|
713
768
|
#filter{width:100%;padding:.5rem .75rem;border:1px solid var(--border);border-radius:6px;background:var(--surface);color:var(--text);font-size:.875rem;outline:none}
|
|
@@ -718,19 +773,25 @@ function formatHTML(result, options) {
|
|
|
718
773
|
tr.stale td{background:var(--stale-bg)}
|
|
719
774
|
tr.dynamic td{background:var(--dyn-bg)}
|
|
720
775
|
code{font-family:ui-monospace,monospace;font-size:.8em;background:var(--surface);padding:.1em .3em;border-radius:3px}
|
|
776
|
+
.steps{margin:.75rem 0 1rem 1.25rem;line-height:2}
|
|
777
|
+
.steps li{margin-bottom:.25rem}
|
|
778
|
+
.btn{display:inline-flex;align-items:center;gap:.4rem;background:#6366f1;color:#fff;border:none;border-radius:6px;padding:.5rem 1rem;font-size:.8125rem;cursor:pointer;margin-top:.75rem}
|
|
779
|
+
.btn:hover{background:#4f46e5}
|
|
780
|
+
.btn.copied{background:#16a34a}
|
|
721
781
|
footer{margin-top:3rem;padding-top:1rem;border-top:1px solid var(--border);color:var(--muted);font-size:.75rem;text-align:center}
|
|
722
782
|
</style>
|
|
723
783
|
</head>
|
|
724
784
|
<body>
|
|
725
785
|
<h1>${title}</h1>
|
|
726
|
-
<p class="subtitle">Scanned ${scannedFiles} files in ${scanDurationMs}ms</p>
|
|
786
|
+
<p class="subtitle">Scanned ${scannedFiles} files in ${scanDurationMs}ms \xB7 ${esc(date)}</p>
|
|
727
787
|
|
|
788
|
+
<h2>Executive Summary</h2>
|
|
728
789
|
<div class="cards">
|
|
729
|
-
<div class="card"><div class="card-num">${
|
|
790
|
+
<div class="card"><div class="card-num">${totalUsages}</div><div class="card-label">Total Call-Sites</div></div>
|
|
730
791
|
<div class="card"><div class="card-num">${uniqueFlags.length}</div><div class="card-label">Unique Flags</div></div>
|
|
731
|
-
<div class="card"><div class="card-num">${
|
|
792
|
+
<div class="card"><div class="card-num">${affectedFiles}</div><div class="card-label">Files Affected</div></div>
|
|
732
793
|
<div class="card"><div class="card-num yellow">${staleCount}</div><div class="card-label">Stale Candidates</div></div>
|
|
733
|
-
<div class="card"><div class="card-num blue">${dynamicCount}</div><div class="card-label">Dynamic Keys</div></div
|
|
794
|
+
<div class="card"><div class="card-num blue">${dynamicCount}</div><div class="card-label">Dynamic Keys</div></div>${auditCards}
|
|
734
795
|
</div>
|
|
735
796
|
|
|
736
797
|
<h2>Flag Inventory</h2>
|
|
@@ -744,15 +805,41 @@ function formatHTML(result, options) {
|
|
|
744
805
|
</tbody>
|
|
745
806
|
</table>
|
|
746
807
|
|
|
747
|
-
<
|
|
808
|
+
<h2>Findings by Directory</h2>
|
|
809
|
+
<table id="dir-table">
|
|
810
|
+
<thead><tr><th>Directory</th><th>Call-Sites</th><th>Unique Flags</th><th>Call Types</th></tr></thead>
|
|
811
|
+
<tbody>
|
|
812
|
+
${dirRows}
|
|
813
|
+
</tbody>
|
|
814
|
+
</table>
|
|
815
|
+
|
|
816
|
+
<h2>Recommended Next Steps</h2>
|
|
817
|
+
<ol class="steps">
|
|
818
|
+
<li>Configure the OpenFeature provider once at application startup (manual \u2014 see <code>flaglint migrate --dry-run</code> for guidance)</li>
|
|
819
|
+
<li>Review the migration plan: <code>flaglint migrate --dry-run</code></li>
|
|
820
|
+
<li>Apply automatable transformations: <code>flaglint migrate --apply</code></li>
|
|
821
|
+
<li>Add CI policy enforcement: <code>flaglint validate --no-direct-launchdarkly</code></li>
|
|
822
|
+
</ol>
|
|
823
|
+
<button class="btn" id="copy-btn" onclick="copyMarkdown()">\u{1F4CB} Copy Markdown Summary</button>
|
|
824
|
+
|
|
825
|
+
<footer>Generated by FlagLint ${esc(version)}</footer>
|
|
748
826
|
|
|
749
827
|
<script>
|
|
750
828
|
const input = document.getElementById('filter');
|
|
751
|
-
const
|
|
829
|
+
const tableRows = document.querySelectorAll('#flags-table tbody tr');
|
|
752
830
|
input.addEventListener('input', () => {
|
|
753
831
|
const q = input.value.toLowerCase();
|
|
754
|
-
|
|
832
|
+
tableRows.forEach(r => { r.style.display = r.textContent.toLowerCase().includes(q) ? '' : 'none'; });
|
|
755
833
|
});
|
|
834
|
+
function copyMarkdown() {
|
|
835
|
+
const md = ${JSON.stringify(markdownSummary)}.replace(/\\\\n/g, '\\n');
|
|
836
|
+
navigator.clipboard.writeText(md).then(() => {
|
|
837
|
+
const btn = document.getElementById('copy-btn');
|
|
838
|
+
btn.textContent = '\u2713 Copied!';
|
|
839
|
+
btn.className = 'btn copied';
|
|
840
|
+
setTimeout(() => { btn.textContent = '\u{1F4CB} Copy Markdown Summary'; btn.className = 'btn'; }, 2000);
|
|
841
|
+
});
|
|
842
|
+
}
|
|
756
843
|
</script>
|
|
757
844
|
</body>
|
|
758
845
|
</html>`;
|
|
@@ -788,6 +875,9 @@ var FlagLintConfigSchema = z.object({
|
|
|
788
875
|
// TODO v0.3: replace minFileCount with real date-based staleness via git log
|
|
789
876
|
minFileCount: z.number().int().min(0).default(0),
|
|
790
877
|
wrappers: z.array(z.string()).default([]),
|
|
878
|
+
openFeatureClientBindings: z.array(
|
|
879
|
+
z.object({ importName: z.string(), modulePatterns: z.array(z.string()).default([]) })
|
|
880
|
+
).default([]),
|
|
791
881
|
reportTitle: z.string().optional(),
|
|
792
882
|
outputDir: z.string().default(".")
|
|
793
883
|
});
|
|
@@ -962,7 +1052,7 @@ Examples:
|
|
|
962
1052
|
} else {
|
|
963
1053
|
process.stdout.write(report + "\n");
|
|
964
1054
|
}
|
|
965
|
-
process.
|
|
1055
|
+
process.exitCode = staleCount > 0 ? 1 : 0;
|
|
966
1056
|
}
|
|
967
1057
|
);
|
|
968
1058
|
}
|
|
@@ -1103,7 +1193,7 @@ function formatMigrationReport(analysis) {
|
|
|
1103
1193
|
unsupportedUnknownCount
|
|
1104
1194
|
} = analysis;
|
|
1105
1195
|
const date = (/* @__PURE__ */ new Date()).toLocaleDateString();
|
|
1106
|
-
const version = true ? "0.
|
|
1196
|
+
const version = true ? "0.5.0" : "0.1.0";
|
|
1107
1197
|
const lines = [];
|
|
1108
1198
|
lines.push("# OpenFeature Migration Inventory");
|
|
1109
1199
|
lines.push(`Generated by FlagLint v${version} on ${date}`);
|
|
@@ -1194,7 +1284,7 @@ function manualReason(item) {
|
|
|
1194
1284
|
if (item.manualReviewReason === "bulk-inventory-call") return "bulk inventory call has no single-flag codemod";
|
|
1195
1285
|
return "manual review required";
|
|
1196
1286
|
}
|
|
1197
|
-
function buildReplacement(item) {
|
|
1287
|
+
function buildReplacement(item, clientBindingName) {
|
|
1198
1288
|
if (DETAIL_METHODS.has(item.launchDarklyMethod)) {
|
|
1199
1289
|
return {
|
|
1200
1290
|
item,
|
|
@@ -1212,11 +1302,13 @@ function buildReplacement(item) {
|
|
|
1212
1302
|
}
|
|
1213
1303
|
const method = methodForType(item.valueType);
|
|
1214
1304
|
if (!method) return { item, reason: "unsupported or unknown value type" };
|
|
1215
|
-
const
|
|
1305
|
+
const placeholder = "openFeatureClient";
|
|
1306
|
+
const target = clientBindingName ?? placeholder;
|
|
1307
|
+
const call = `${target}.${method}(${item.flagKeyExpression}, ${item.fallbackExpression}, ${item.evaluationContextExpression})`;
|
|
1216
1308
|
return {
|
|
1217
1309
|
item,
|
|
1218
1310
|
replacement: call,
|
|
1219
|
-
requiresProviderSetup:
|
|
1311
|
+
requiresProviderSetup: clientBindingName == null
|
|
1220
1312
|
};
|
|
1221
1313
|
}
|
|
1222
1314
|
function applyReplacements(code, replacements) {
|
|
@@ -1302,17 +1394,27 @@ function formatProviderSetupSection() {
|
|
|
1302
1394
|
lines.push("");
|
|
1303
1395
|
return lines;
|
|
1304
1396
|
}
|
|
1305
|
-
async function formatDryRunDiff(analysis, source) {
|
|
1397
|
+
async function formatDryRunDiff(analysis, source, allowedBindings = []) {
|
|
1306
1398
|
const replacementsByFile = /* @__PURE__ */ new Map();
|
|
1307
1399
|
const skipped = [];
|
|
1400
|
+
const itemsByFile = /* @__PURE__ */ new Map();
|
|
1308
1401
|
for (const item of analysis.inventoryItems) {
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1402
|
+
if (!itemsByFile.has(item.file)) itemsByFile.set(item.file, []);
|
|
1403
|
+
itemsByFile.get(item.file).push(item);
|
|
1404
|
+
}
|
|
1405
|
+
for (const [file, items] of [...itemsByFile.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
1406
|
+
const before = await source.readFile(file);
|
|
1407
|
+
const { getOpenFeatureClientBindingName } = await import("../apply-ZYLA2N7Y.js");
|
|
1408
|
+
const clientBindingName = getOpenFeatureClientBindingName(before, allowedBindings);
|
|
1409
|
+
for (const item of items) {
|
|
1410
|
+
const result = buildReplacement(item, clientBindingName);
|
|
1411
|
+
if ("reason" in result) {
|
|
1412
|
+
skipped.push(result);
|
|
1413
|
+
continue;
|
|
1414
|
+
}
|
|
1415
|
+
if (!replacementsByFile.has(item.file)) replacementsByFile.set(item.file, []);
|
|
1416
|
+
replacementsByFile.get(item.file).push(result);
|
|
1313
1417
|
}
|
|
1314
|
-
if (!replacementsByFile.has(item.file)) replacementsByFile.set(item.file, []);
|
|
1315
|
-
replacementsByFile.get(item.file).push(result);
|
|
1316
1418
|
}
|
|
1317
1419
|
const lines = [];
|
|
1318
1420
|
lines.push("# FlagLint migrate --dry-run");
|
|
@@ -1350,205 +1452,6 @@ async function formatDryRunDiff(analysis, source) {
|
|
|
1350
1452
|
return lines.join("\n");
|
|
1351
1453
|
}
|
|
1352
1454
|
|
|
1353
|
-
// src/migrator/apply.ts
|
|
1354
|
-
import { execFile } from "child_process";
|
|
1355
|
-
import { promisify } from "util";
|
|
1356
|
-
import { parse as parse2 } from "@typescript-eslint/typescript-estree";
|
|
1357
|
-
var execFileAsync = promisify(execFile);
|
|
1358
|
-
var ApplyError = class extends Error {
|
|
1359
|
-
constructor(kind, message) {
|
|
1360
|
-
super(message);
|
|
1361
|
-
this.kind = kind;
|
|
1362
|
-
this.name = "ApplyError";
|
|
1363
|
-
}
|
|
1364
|
-
kind;
|
|
1365
|
-
};
|
|
1366
|
-
var OF_SERVER_SDK = "@openfeature/server-sdk";
|
|
1367
|
-
function tryParse(code) {
|
|
1368
|
-
for (const jsx of [false, true]) {
|
|
1369
|
-
try {
|
|
1370
|
-
return parse2(code, { jsx, comment: false });
|
|
1371
|
-
} catch {
|
|
1372
|
-
}
|
|
1373
|
-
}
|
|
1374
|
-
return null;
|
|
1375
|
-
}
|
|
1376
|
-
function walkNodes(root, visit) {
|
|
1377
|
-
const stack = [root];
|
|
1378
|
-
while (stack.length > 0) {
|
|
1379
|
-
const node = stack.pop();
|
|
1380
|
-
visit(node);
|
|
1381
|
-
for (const val of Object.values(node)) {
|
|
1382
|
-
if (Array.isArray(val)) {
|
|
1383
|
-
for (const item of val) {
|
|
1384
|
-
if (item !== null && typeof item === "object" && "type" in item) {
|
|
1385
|
-
stack.push(item);
|
|
1386
|
-
}
|
|
1387
|
-
}
|
|
1388
|
-
} else if (val !== null && typeof val === "object" && "type" in val) {
|
|
1389
|
-
stack.push(val);
|
|
1390
|
-
}
|
|
1391
|
-
}
|
|
1392
|
-
}
|
|
1393
|
-
}
|
|
1394
|
-
function hasOpenFeatureClientBinding(code) {
|
|
1395
|
-
const ast = tryParse(code);
|
|
1396
|
-
if (!ast) return false;
|
|
1397
|
-
const ofApiNames = /* @__PURE__ */ new Set();
|
|
1398
|
-
for (const stmt of ast.body) {
|
|
1399
|
-
if (stmt.type === "ImportDeclaration" && stmt.source.value === OF_SERVER_SDK) {
|
|
1400
|
-
for (const spec of stmt.specifiers) {
|
|
1401
|
-
if (spec.type === "ImportSpecifier") {
|
|
1402
|
-
const importedName = spec.imported.type === "Identifier" ? spec.imported.name : spec.imported.value;
|
|
1403
|
-
if (importedName === "OpenFeature") {
|
|
1404
|
-
ofApiNames.add(spec.local.name);
|
|
1405
|
-
}
|
|
1406
|
-
}
|
|
1407
|
-
}
|
|
1408
|
-
continue;
|
|
1409
|
-
}
|
|
1410
|
-
if (stmt.type === "VariableDeclaration") {
|
|
1411
|
-
for (const decl of stmt.declarations) {
|
|
1412
|
-
const init = decl.init;
|
|
1413
|
-
if (init?.type === "CallExpression" && init.callee.type === "Identifier" && init.callee.name === "require" && init.arguments.length === 1 && init.arguments[0]?.type === "Literal" && init.arguments[0].value === OF_SERVER_SDK && decl.id.type === "ObjectPattern") {
|
|
1414
|
-
for (const prop of decl.id.properties) {
|
|
1415
|
-
if (prop.type === "Property" && prop.key.type === "Identifier" && prop.key.name === "OpenFeature" && prop.value.type === "Identifier") {
|
|
1416
|
-
ofApiNames.add(prop.value.name);
|
|
1417
|
-
}
|
|
1418
|
-
}
|
|
1419
|
-
}
|
|
1420
|
-
}
|
|
1421
|
-
}
|
|
1422
|
-
}
|
|
1423
|
-
if (ofApiNames.size === 0) return false;
|
|
1424
|
-
let proven = false;
|
|
1425
|
-
walkNodes(ast, (node) => {
|
|
1426
|
-
if (proven) return;
|
|
1427
|
-
if (node.type !== "VariableDeclarator") return;
|
|
1428
|
-
const decl = node;
|
|
1429
|
-
if (decl.id.type !== "Identifier") return;
|
|
1430
|
-
if (decl.id.name !== "openFeatureClient") return;
|
|
1431
|
-
if (decl.init?.type !== "CallExpression") return;
|
|
1432
|
-
const call = decl.init;
|
|
1433
|
-
if (call.callee.type !== "MemberExpression") return;
|
|
1434
|
-
const member = call.callee;
|
|
1435
|
-
if (member.computed) return;
|
|
1436
|
-
if (member.object.type !== "Identifier") return;
|
|
1437
|
-
if (!ofApiNames.has(member.object.name)) return;
|
|
1438
|
-
if (member.property.type !== "Identifier") return;
|
|
1439
|
-
if (member.property.name !== "getClient") return;
|
|
1440
|
-
proven = true;
|
|
1441
|
-
});
|
|
1442
|
-
return proven;
|
|
1443
|
-
}
|
|
1444
|
-
var DETAIL_METHODS2 = /* @__PURE__ */ new Set([
|
|
1445
|
-
"variationDetail",
|
|
1446
|
-
"boolVariationDetail",
|
|
1447
|
-
"stringVariationDetail",
|
|
1448
|
-
"numberVariationDetail",
|
|
1449
|
-
"jsonVariationDetail"
|
|
1450
|
-
]);
|
|
1451
|
-
function methodForType2(valueType) {
|
|
1452
|
-
switch (valueType) {
|
|
1453
|
-
case "boolean":
|
|
1454
|
-
return "getBooleanValue";
|
|
1455
|
-
case "string":
|
|
1456
|
-
return "getStringValue";
|
|
1457
|
-
case "number":
|
|
1458
|
-
return "getNumberValue";
|
|
1459
|
-
case "object":
|
|
1460
|
-
return "getObjectValue";
|
|
1461
|
-
case "unknown":
|
|
1462
|
-
return null;
|
|
1463
|
-
}
|
|
1464
|
-
}
|
|
1465
|
-
function buildReplacement2(item, code) {
|
|
1466
|
-
if (DETAIL_METHODS2.has(item.launchDarklyMethod)) {
|
|
1467
|
-
return {
|
|
1468
|
-
item,
|
|
1469
|
-
reason: "detail methods skipped: OpenFeature detail APIs exist, but LaunchDarkly/OpenFeature detail result parity requires manual review"
|
|
1470
|
-
};
|
|
1471
|
-
}
|
|
1472
|
-
if (!item.safelyAutomatable) {
|
|
1473
|
-
const reason = item.manualReviewReason === "dynamic-key" ? "dynamic key requires manual review" : item.manualReviewReason === "unknown-fallback" ? "unknown fallback type requires manual review" : item.manualReviewReason === "bulk-inventory-call" ? "bulk inventory call has no single-flag codemod" : "manual review required";
|
|
1474
|
-
return { item, reason };
|
|
1475
|
-
}
|
|
1476
|
-
if (item.rangeStart == null || item.rangeEnd == null || !item.callExpression) {
|
|
1477
|
-
return { item, reason: "missing source range for apply" };
|
|
1478
|
-
}
|
|
1479
|
-
const currentText = code.slice(item.rangeStart, item.rangeEnd);
|
|
1480
|
-
if (currentText !== item.callExpression) {
|
|
1481
|
-
return {
|
|
1482
|
-
item,
|
|
1483
|
-
reason: "range content does not match original call \u2014 already transformed or stale analysis; skipping"
|
|
1484
|
-
};
|
|
1485
|
-
}
|
|
1486
|
-
if (!item.flagKeyExpression || !item.fallbackExpression || !item.evaluationContextExpression) {
|
|
1487
|
-
return { item, reason: "missing flag key, fallback, or evaluation context evidence" };
|
|
1488
|
-
}
|
|
1489
|
-
const method = methodForType2(item.valueType);
|
|
1490
|
-
if (!method) return { item, reason: "unsupported or unknown value type" };
|
|
1491
|
-
const call = `openFeatureClient.${method}(${item.flagKeyExpression}, ${item.fallbackExpression}, ${item.evaluationContextExpression})`;
|
|
1492
|
-
return { item, replacement: call };
|
|
1493
|
-
}
|
|
1494
|
-
function applyReplacements2(code, replacements) {
|
|
1495
|
-
let next = code;
|
|
1496
|
-
for (const r of [...replacements].sort((a, b) => b.item.rangeStart - a.item.rangeStart)) {
|
|
1497
|
-
next = next.slice(0, r.item.rangeStart) + r.replacement + next.slice(r.item.rangeEnd);
|
|
1498
|
-
}
|
|
1499
|
-
return next;
|
|
1500
|
-
}
|
|
1501
|
-
async function defaultIsWorkingTreeDirty() {
|
|
1502
|
-
try {
|
|
1503
|
-
const { stdout } = await execFileAsync("git", ["status", "--porcelain"]);
|
|
1504
|
-
return stdout.trim().length > 0;
|
|
1505
|
-
} catch {
|
|
1506
|
-
return false;
|
|
1507
|
-
}
|
|
1508
|
-
}
|
|
1509
|
-
async function applyMigration(analysis, source, options = {}) {
|
|
1510
|
-
if (!options.allowDirty) {
|
|
1511
|
-
const checkDirty = options.isWorkingTreeDirty ?? defaultIsWorkingTreeDirty;
|
|
1512
|
-
if (await checkDirty()) {
|
|
1513
|
-
throw new ApplyError(
|
|
1514
|
-
"dirty-tree",
|
|
1515
|
-
"Working tree has uncommitted changes.\nCommit or stash your changes first, or pass --allow-dirty to override.\nReview `flaglint migrate --dry-run` for provider setup guidance before applying."
|
|
1516
|
-
);
|
|
1517
|
-
}
|
|
1518
|
-
}
|
|
1519
|
-
const itemsByFile = /* @__PURE__ */ new Map();
|
|
1520
|
-
for (const item of analysis.inventoryItems) {
|
|
1521
|
-
if (!itemsByFile.has(item.file)) itemsByFile.set(item.file, []);
|
|
1522
|
-
itemsByFile.get(item.file).push(item);
|
|
1523
|
-
}
|
|
1524
|
-
const transformedFiles = [];
|
|
1525
|
-
const skippedFiles = [];
|
|
1526
|
-
let transformed = 0;
|
|
1527
|
-
for (const [file, items] of [...itemsByFile.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
1528
|
-
const code = await source.readFile(file);
|
|
1529
|
-
if (!hasOpenFeatureClientBinding(code)) {
|
|
1530
|
-
skippedFiles.push({
|
|
1531
|
-
file,
|
|
1532
|
-
reason: "skipped \u2014 OpenFeature client setup required. Review `flaglint migrate --dry-run` provider guidance first."
|
|
1533
|
-
});
|
|
1534
|
-
continue;
|
|
1535
|
-
}
|
|
1536
|
-
const replacements = [];
|
|
1537
|
-
for (const item of items) {
|
|
1538
|
-
const result = buildReplacement2(item, code);
|
|
1539
|
-
if ("reason" in result) continue;
|
|
1540
|
-
replacements.push(result);
|
|
1541
|
-
}
|
|
1542
|
-
if (replacements.length === 0) continue;
|
|
1543
|
-
const newCode = applyReplacements2(code, replacements);
|
|
1544
|
-
if (newCode === code) continue;
|
|
1545
|
-
await source.writeFile(file, newCode);
|
|
1546
|
-
transformedFiles.push(file);
|
|
1547
|
-
transformed += replacements.length;
|
|
1548
|
-
}
|
|
1549
|
-
return { transformed, skipped: skippedFiles.length, transformedFiles, skippedFiles };
|
|
1550
|
-
}
|
|
1551
|
-
|
|
1552
1455
|
// src/commands/migrate.ts
|
|
1553
1456
|
function registerMigrateCommand(program2) {
|
|
1554
1457
|
program2.command("migrate").description("Analyze migration readiness and generate an OpenFeature migration plan").argument("[dir]", "directory to analyze", process.cwd()).option("-o, --output <file>", "write migration plan to file", "MIGRATION.md").option("-c, --config <path>", "path to .flaglintrc config file").option("--dry-run", "print reviewable diffs to stdout without writing files").option("--apply", "apply safe transformations to source files in-place").option("--allow-dirty", "allow --apply on a dirty git working tree").option("--exclude-tests", "exclude test files (*.test.*, *.spec.*, __tests__/, tests/)").addHelpText(
|
|
@@ -1655,14 +1558,14 @@ Examples:
|
|
|
1655
1558
|
)
|
|
1656
1559
|
);
|
|
1657
1560
|
if (options.dryRun) {
|
|
1658
|
-
const report2 = await formatDryRunDiff(analysis, source);
|
|
1561
|
+
const report2 = await formatDryRunDiff(analysis, source, config.openFeatureClientBindings);
|
|
1659
1562
|
process.stdout.write(report2 + "\n");
|
|
1660
1563
|
process.exit(0);
|
|
1661
1564
|
}
|
|
1662
1565
|
if (options.apply) {
|
|
1663
1566
|
let result;
|
|
1664
1567
|
try {
|
|
1665
|
-
result = await applyMigration(analysis, source, { allowDirty: options.allowDirty });
|
|
1568
|
+
result = await applyMigration(analysis, source, { allowDirty: options.allowDirty, allowedOpenFeatureClientBindings: config.openFeatureClientBindings });
|
|
1666
1569
|
} catch (err) {
|
|
1667
1570
|
if (err instanceof ApplyError && err.kind === "dirty-tree") {
|
|
1668
1571
|
process.stderr.write(chalk2.red(`
|
|
@@ -1725,12 +1628,15 @@ Error: ${err.message}
|
|
|
1725
1628
|
}
|
|
1726
1629
|
|
|
1727
1630
|
// src/commands/validate.ts
|
|
1631
|
+
import { writeFile as writeFile4 } from "fs/promises";
|
|
1728
1632
|
import { stat as stat3 } from "fs/promises";
|
|
1729
|
-
import { resolve as
|
|
1633
|
+
import { resolve as resolve7 } from "path";
|
|
1730
1634
|
import chalk3 from "chalk";
|
|
1731
1635
|
import ora3 from "ora";
|
|
1732
1636
|
|
|
1733
1637
|
// src/validator/index.ts
|
|
1638
|
+
import { resolve as resolve6 } from "path";
|
|
1639
|
+
import { pathToFileURL as pathToFileURL2 } from "url";
|
|
1734
1640
|
function matchesBootstrapPattern(file, patterns) {
|
|
1735
1641
|
const clean = (s) => s.replace(/^\.\//, "");
|
|
1736
1642
|
const cleanFile = clean(file);
|
|
@@ -1801,8 +1707,106 @@ function formatValidationReport(result, options = {}) {
|
|
|
1801
1707
|
lines.push("Run `flaglint migrate --dry-run` to review the migration plan.");
|
|
1802
1708
|
return lines.join("\n") + "\n";
|
|
1803
1709
|
}
|
|
1710
|
+
var SARIF_RULE_NO_DIRECT_LD = {
|
|
1711
|
+
id: "flaglint.direct-launchdarkly",
|
|
1712
|
+
name: "DirectLaunchDarklySDKUsage",
|
|
1713
|
+
shortDescription: {
|
|
1714
|
+
text: "Direct LaunchDarkly SDK evaluation call detected"
|
|
1715
|
+
},
|
|
1716
|
+
fullDescription: {
|
|
1717
|
+
text: "A direct LaunchDarkly Node.js server SDK evaluation call was found. Migrate this call to OpenFeature so the codebase is provider-independent."
|
|
1718
|
+
},
|
|
1719
|
+
helpUri: "https://github.com/flaglint/flaglint#flaglint-validate-dir",
|
|
1720
|
+
properties: {
|
|
1721
|
+
tags: ["openfeature", "migration", "launchdarkly"]
|
|
1722
|
+
}
|
|
1723
|
+
};
|
|
1724
|
+
function sarifViolationUri(file) {
|
|
1725
|
+
return file.split(/[\\/]/).join("/");
|
|
1726
|
+
}
|
|
1727
|
+
function sarifScanRootUri(scanRoot) {
|
|
1728
|
+
const uri = pathToFileURL2(resolve6(scanRoot)).href;
|
|
1729
|
+
return uri.endsWith("/") ? uri : `${uri}/`;
|
|
1730
|
+
}
|
|
1731
|
+
function violationSarifMessage(v) {
|
|
1732
|
+
if (v.isDynamic) {
|
|
1733
|
+
return `Direct LaunchDarkly SDK call ${v.callType}() with a dynamic flag key at ${v.file}:${v.line}. Migrate to OpenFeature using flaglint migrate --dry-run.`;
|
|
1734
|
+
}
|
|
1735
|
+
if (v.flagKey === "*") {
|
|
1736
|
+
return `Direct LaunchDarkly bulk inventory call ${v.callType}() at ${v.file}:${v.line}. Migrate to OpenFeature using flaglint migrate --dry-run.`;
|
|
1737
|
+
}
|
|
1738
|
+
return `Direct LaunchDarkly SDK call ${v.callType}("${v.flagKey}") at ${v.file}:${v.line}. Migrate to OpenFeature using flaglint migrate --dry-run.`;
|
|
1739
|
+
}
|
|
1740
|
+
function formatValidationSarif(result, scanRoot, scannedAt) {
|
|
1741
|
+
return JSON.stringify(
|
|
1742
|
+
{
|
|
1743
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
1744
|
+
version: "2.1.0",
|
|
1745
|
+
runs: [
|
|
1746
|
+
{
|
|
1747
|
+
tool: {
|
|
1748
|
+
driver: {
|
|
1749
|
+
name: "FlagLint",
|
|
1750
|
+
informationUri: "https://github.com/flaglint/flaglint",
|
|
1751
|
+
rules: [SARIF_RULE_NO_DIRECT_LD]
|
|
1752
|
+
}
|
|
1753
|
+
},
|
|
1754
|
+
invocations: [
|
|
1755
|
+
{
|
|
1756
|
+
executionSuccessful: true,
|
|
1757
|
+
startTimeUtc: scannedAt,
|
|
1758
|
+
properties: {
|
|
1759
|
+
scannedFiles: result.scannedFiles,
|
|
1760
|
+
totalUsages: result.totalUsages,
|
|
1761
|
+
violations: result.violations.length,
|
|
1762
|
+
passed: result.passed
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
],
|
|
1766
|
+
originalUriBaseIds: {
|
|
1767
|
+
"%SRCROOT%": {
|
|
1768
|
+
uri: sarifScanRootUri(scanRoot)
|
|
1769
|
+
}
|
|
1770
|
+
},
|
|
1771
|
+
results: result.violations.map((v) => ({
|
|
1772
|
+
ruleId: SARIF_RULE_NO_DIRECT_LD.id,
|
|
1773
|
+
level: "error",
|
|
1774
|
+
message: {
|
|
1775
|
+
text: violationSarifMessage(v)
|
|
1776
|
+
},
|
|
1777
|
+
locations: [
|
|
1778
|
+
{
|
|
1779
|
+
physicalLocation: {
|
|
1780
|
+
artifactLocation: {
|
|
1781
|
+
uri: sarifViolationUri(v.file),
|
|
1782
|
+
uriBaseId: "%SRCROOT%"
|
|
1783
|
+
},
|
|
1784
|
+
region: {
|
|
1785
|
+
startLine: Math.max(v.line, 1),
|
|
1786
|
+
startColumn: Math.max(v.column + 1, 1)
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
],
|
|
1791
|
+
partialFingerprints: {
|
|
1792
|
+
"flagKey/v1": v.flagKey
|
|
1793
|
+
},
|
|
1794
|
+
properties: {
|
|
1795
|
+
flagKey: v.flagKey,
|
|
1796
|
+
callType: v.callType,
|
|
1797
|
+
isDynamic: v.isDynamic
|
|
1798
|
+
}
|
|
1799
|
+
}))
|
|
1800
|
+
}
|
|
1801
|
+
]
|
|
1802
|
+
},
|
|
1803
|
+
null,
|
|
1804
|
+
2
|
|
1805
|
+
);
|
|
1806
|
+
}
|
|
1804
1807
|
|
|
1805
1808
|
// src/commands/validate.ts
|
|
1809
|
+
var VALID_VALIDATE_FORMATS = ["text", "sarif"];
|
|
1806
1810
|
function registerValidateCommand(program2) {
|
|
1807
1811
|
program2.command("validate").description("Validate that your codebase complies with feature flag usage policies").argument("[dir]", "directory to validate", process.cwd()).option(
|
|
1808
1812
|
"--no-direct-launchdarkly",
|
|
@@ -1812,7 +1816,11 @@ function registerValidateCommand(program2) {
|
|
|
1812
1816
|
"glob pattern for files allowed to use LaunchDarkly SDK directly (repeatable)",
|
|
1813
1817
|
(val, prev) => [...prev, val],
|
|
1814
1818
|
[]
|
|
1815
|
-
).option(
|
|
1819
|
+
).option(
|
|
1820
|
+
"-f, --format <format>",
|
|
1821
|
+
"output format: text | sarif",
|
|
1822
|
+
"text"
|
|
1823
|
+
).option("-o, --output <file>", "write report to file instead of stdout").option("-c, --config <path>", "path to .flaglintrc config file").addHelpText(
|
|
1816
1824
|
"after",
|
|
1817
1825
|
`
|
|
1818
1826
|
Examples:
|
|
@@ -1822,13 +1830,25 @@ Examples:
|
|
|
1822
1830
|
--bootstrap-exclude src/provider/setup.ts allow bootstrap file
|
|
1823
1831
|
$ flaglint validate --no-direct-launchdarkly \\
|
|
1824
1832
|
--bootstrap-exclude "src/provider/**" allow all provider files
|
|
1833
|
+
$ flaglint validate --no-direct-launchdarkly \\
|
|
1834
|
+
--format sarif --output flaglint.sarif emit SARIF for GitHub Code Scanning
|
|
1825
1835
|
$ flaglint validate --no-direct-launchdarkly \\
|
|
1826
1836
|
--bootstrap-exclude "src/provider/*.ts" \\
|
|
1827
1837
|
--bootstrap-exclude "src/bootstrap/**" multiple exclusion patterns`
|
|
1828
1838
|
).action(
|
|
1829
1839
|
async (dir, options) => {
|
|
1840
|
+
if (!VALID_VALIDATE_FORMATS.includes(options.format)) {
|
|
1841
|
+
process.stderr.write(
|
|
1842
|
+
chalk3.red(
|
|
1843
|
+
`Error: Invalid format '${options.format}'. Must be one of: ${VALID_VALIDATE_FORMATS.join(", ")}
|
|
1844
|
+
`
|
|
1845
|
+
)
|
|
1846
|
+
);
|
|
1847
|
+
process.exit(2);
|
|
1848
|
+
}
|
|
1849
|
+
const format = options.format;
|
|
1830
1850
|
try {
|
|
1831
|
-
const s = await stat3(
|
|
1851
|
+
const s = await stat3(resolve7(dir));
|
|
1832
1852
|
if (!s.isDirectory()) {
|
|
1833
1853
|
process.stderr.write(chalk3.red(`Error: Not a directory: ${dir}
|
|
1834
1854
|
`));
|
|
@@ -1883,8 +1903,34 @@ Examples:
|
|
|
1883
1903
|
bootstrapExclude
|
|
1884
1904
|
};
|
|
1885
1905
|
const validationResult = validateScanResult(scanResult, validateOptions);
|
|
1886
|
-
|
|
1887
|
-
|
|
1906
|
+
let report;
|
|
1907
|
+
if (format === "sarif") {
|
|
1908
|
+
report = formatValidationSarif(
|
|
1909
|
+
validationResult,
|
|
1910
|
+
scanResult.scanRoot,
|
|
1911
|
+
scanResult.scannedAt
|
|
1912
|
+
);
|
|
1913
|
+
} else {
|
|
1914
|
+
report = formatValidationReport(validationResult, validateOptions);
|
|
1915
|
+
}
|
|
1916
|
+
if (options.output) {
|
|
1917
|
+
const outPath = resolve7(options.output);
|
|
1918
|
+
try {
|
|
1919
|
+
await writeFile4(outPath, report, "utf8");
|
|
1920
|
+
process.stderr.write(chalk3.dim(` Report written to ${options.output}
|
|
1921
|
+
`));
|
|
1922
|
+
} catch (err) {
|
|
1923
|
+
process.stderr.write(
|
|
1924
|
+
chalk3.red(
|
|
1925
|
+
`Error: Failed to write report to ${options.output}: ${err instanceof Error ? err.message : String(err)}
|
|
1926
|
+
`
|
|
1927
|
+
)
|
|
1928
|
+
);
|
|
1929
|
+
process.exit(1);
|
|
1930
|
+
}
|
|
1931
|
+
} else {
|
|
1932
|
+
process.stdout.write(report);
|
|
1933
|
+
}
|
|
1888
1934
|
if (!validationResult.passed) {
|
|
1889
1935
|
process.exit(1);
|
|
1890
1936
|
}
|
|
@@ -1896,7 +1942,7 @@ Examples:
|
|
|
1896
1942
|
// src/cli.ts
|
|
1897
1943
|
function createCLI() {
|
|
1898
1944
|
const program2 = new Command();
|
|
1899
|
-
program2.name("flaglint").description("LaunchDarkly Node.js server SDK -> OpenFeature migration.").version("0.
|
|
1945
|
+
program2.name("flaglint").description("LaunchDarkly Node.js server SDK -> OpenFeature migration.").version("0.5.0", "-v, --version", "output the current version").addHelpText(
|
|
1900
1946
|
"after",
|
|
1901
1947
|
`
|
|
1902
1948
|
Examples:
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/migrator/apply.ts
|
|
4
|
+
import { execFile } from "child_process";
|
|
5
|
+
import { promisify } from "util";
|
|
6
|
+
import { parse } from "@typescript-eslint/typescript-estree";
|
|
7
|
+
import micromatch from "micromatch";
|
|
8
|
+
function moduleSpecifierMatchesGlob(specifier, pattern) {
|
|
9
|
+
const normalizeRelative = (value) => value.replace(/^(?:\.\.?\/)+/, "");
|
|
10
|
+
const normalizedSpecifier = normalizeRelative(specifier);
|
|
11
|
+
const normalizedPattern = normalizeRelative(pattern);
|
|
12
|
+
if (micromatch.isMatch(normalizedSpecifier, normalizedPattern)) return true;
|
|
13
|
+
if (!normalizedSpecifier.endsWith(".js")) return false;
|
|
14
|
+
return micromatch.isMatch(normalizedSpecifier.slice(0, -3), normalizedPattern);
|
|
15
|
+
}
|
|
16
|
+
var execFileAsync = promisify(execFile);
|
|
17
|
+
var ApplyError = class extends Error {
|
|
18
|
+
constructor(kind, message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.kind = kind;
|
|
21
|
+
this.name = "ApplyError";
|
|
22
|
+
}
|
|
23
|
+
kind;
|
|
24
|
+
};
|
|
25
|
+
var OF_SERVER_SDK = "@openfeature/server-sdk";
|
|
26
|
+
function tryParse(code) {
|
|
27
|
+
for (const jsx of [false, true]) {
|
|
28
|
+
try {
|
|
29
|
+
return parse(code, { jsx, comment: false });
|
|
30
|
+
} catch {
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
function walkNodes(root, visit) {
|
|
36
|
+
const stack = [root];
|
|
37
|
+
while (stack.length > 0) {
|
|
38
|
+
const node = stack.pop();
|
|
39
|
+
visit(node);
|
|
40
|
+
for (const val of Object.values(node)) {
|
|
41
|
+
if (Array.isArray(val)) {
|
|
42
|
+
for (const item of val) {
|
|
43
|
+
if (item !== null && typeof item === "object" && "type" in item) {
|
|
44
|
+
stack.push(item);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
} else if (val !== null && typeof val === "object" && "type" in val) {
|
|
48
|
+
stack.push(val);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function getOpenFeatureClientBindingName(code, allowedBindings = []) {
|
|
54
|
+
const ast = tryParse(code);
|
|
55
|
+
if (!ast) return null;
|
|
56
|
+
const ofApiNames = /* @__PURE__ */ new Set();
|
|
57
|
+
for (const stmt of ast.body) {
|
|
58
|
+
if (stmt.type === "ImportDeclaration" && stmt.source.value === OF_SERVER_SDK) {
|
|
59
|
+
for (const spec of stmt.specifiers) {
|
|
60
|
+
if (spec.type === "ImportSpecifier") {
|
|
61
|
+
const importedName = spec.imported.type === "Identifier" ? spec.imported.name : spec.imported.value;
|
|
62
|
+
if (importedName === "OpenFeature") {
|
|
63
|
+
ofApiNames.add(spec.local.name);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (stmt.type === "VariableDeclaration") {
|
|
70
|
+
for (const decl of stmt.declarations) {
|
|
71
|
+
const init = decl.init;
|
|
72
|
+
if (init?.type === "CallExpression" && init.callee.type === "Identifier" && init.callee.name === "require" && init.arguments.length === 1 && init.arguments[0]?.type === "Literal" && init.arguments[0].value === OF_SERVER_SDK && decl.id.type === "ObjectPattern") {
|
|
73
|
+
for (const prop of decl.id.properties) {
|
|
74
|
+
if (prop.type === "Property" && prop.key.type === "Identifier" && prop.key.name === "OpenFeature" && prop.value.type === "Identifier") {
|
|
75
|
+
ofApiNames.add(prop.value.name);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const candidates = [];
|
|
83
|
+
walkNodes(ast, (node) => {
|
|
84
|
+
if (node.type !== "VariableDeclarator") return;
|
|
85
|
+
const decl = node;
|
|
86
|
+
if (decl.id.type !== "Identifier") return;
|
|
87
|
+
if (decl.init?.type !== "CallExpression") return;
|
|
88
|
+
const call = decl.init;
|
|
89
|
+
if (call.callee.type !== "MemberExpression") return;
|
|
90
|
+
const member = call.callee;
|
|
91
|
+
if (member.computed) return;
|
|
92
|
+
if (member.object.type !== "Identifier") return;
|
|
93
|
+
if (!ofApiNames.has(member.object.name)) return;
|
|
94
|
+
if (member.property.type !== "Identifier") return;
|
|
95
|
+
if (member.property.name !== "getClient") return;
|
|
96
|
+
candidates.push(decl.id.name);
|
|
97
|
+
});
|
|
98
|
+
if (candidates.length === 1) return candidates[0];
|
|
99
|
+
if (candidates.length > 1) return null;
|
|
100
|
+
for (const stmt of ast.body) {
|
|
101
|
+
if (stmt.type === "ImportDeclaration") {
|
|
102
|
+
const source = String(stmt.source.value || "");
|
|
103
|
+
if (!source.startsWith(".")) continue;
|
|
104
|
+
for (const spec of stmt.specifiers) {
|
|
105
|
+
if (spec.type === "ImportSpecifier") {
|
|
106
|
+
const importedName = spec.imported.type === "Identifier" ? spec.imported.name : spec.imported.value;
|
|
107
|
+
const allowed = allowedBindings.find((b) => b.importName === importedName);
|
|
108
|
+
if (!allowed) continue;
|
|
109
|
+
if (!allowed.modulePatterns.some((p) => moduleSpecifierMatchesGlob(source, p))) continue;
|
|
110
|
+
candidates.push(spec.local.name);
|
|
111
|
+
}
|
|
112
|
+
if (spec.type === "ImportDefaultSpecifier") {
|
|
113
|
+
const local = spec.local.name;
|
|
114
|
+
const allowed = allowedBindings.find((b) => b.importName === local);
|
|
115
|
+
if (!allowed) continue;
|
|
116
|
+
if (!allowed.modulePatterns.some((p) => moduleSpecifierMatchesGlob(source, p))) continue;
|
|
117
|
+
candidates.push(local);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (stmt.type === "VariableDeclaration") {
|
|
122
|
+
for (const decl of stmt.declarations) {
|
|
123
|
+
const init = decl.init;
|
|
124
|
+
if (init?.type === "CallExpression" && init.callee.type === "Identifier" && init.callee.name === "require" && init.arguments.length === 1 && init.arguments[0]?.type === "Literal") {
|
|
125
|
+
const source = String(init.arguments[0].value || "");
|
|
126
|
+
if (!source.startsWith(".")) continue;
|
|
127
|
+
if (decl.id.type === "ObjectPattern") {
|
|
128
|
+
for (const prop of decl.id.properties) {
|
|
129
|
+
if (prop.type !== "Property" || prop.key.type !== "Identifier") continue;
|
|
130
|
+
const importedName = prop.key.name;
|
|
131
|
+
const local = prop.value.type === "Identifier" ? prop.value.name : null;
|
|
132
|
+
if (!local) continue;
|
|
133
|
+
const allowed = allowedBindings.find((b) => b.importName === importedName);
|
|
134
|
+
if (!allowed) continue;
|
|
135
|
+
if (!allowed.modulePatterns.some((p) => moduleSpecifierMatchesGlob(source, p))) continue;
|
|
136
|
+
candidates.push(local);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (candidates.length === 1) return candidates[0];
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
function hasOpenFeatureClientBinding(code) {
|
|
147
|
+
return getOpenFeatureClientBindingName(code) != null;
|
|
148
|
+
}
|
|
149
|
+
var DETAIL_METHODS = /* @__PURE__ */ new Set([
|
|
150
|
+
"variationDetail",
|
|
151
|
+
"boolVariationDetail",
|
|
152
|
+
"stringVariationDetail",
|
|
153
|
+
"numberVariationDetail",
|
|
154
|
+
"jsonVariationDetail"
|
|
155
|
+
]);
|
|
156
|
+
function methodForType(valueType) {
|
|
157
|
+
switch (valueType) {
|
|
158
|
+
case "boolean":
|
|
159
|
+
return "getBooleanValue";
|
|
160
|
+
case "string":
|
|
161
|
+
return "getStringValue";
|
|
162
|
+
case "number":
|
|
163
|
+
return "getNumberValue";
|
|
164
|
+
case "object":
|
|
165
|
+
return "getObjectValue";
|
|
166
|
+
case "unknown":
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function buildReplacement(item, code, clientBindingName) {
|
|
171
|
+
if (DETAIL_METHODS.has(item.launchDarklyMethod)) {
|
|
172
|
+
return {
|
|
173
|
+
item,
|
|
174
|
+
reason: "detail methods skipped: OpenFeature detail APIs exist, but LaunchDarkly/OpenFeature detail result parity requires manual review"
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
if (!item.safelyAutomatable) {
|
|
178
|
+
const reason = item.manualReviewReason === "dynamic-key" ? "dynamic key requires manual review" : item.manualReviewReason === "unknown-fallback" ? "unknown fallback type requires manual review" : item.manualReviewReason === "bulk-inventory-call" ? "bulk inventory call has no single-flag codemod" : "manual review required";
|
|
179
|
+
return { item, reason };
|
|
180
|
+
}
|
|
181
|
+
if (item.rangeStart == null || item.rangeEnd == null || !item.callExpression) {
|
|
182
|
+
return { item, reason: "missing source range for apply" };
|
|
183
|
+
}
|
|
184
|
+
const currentText = code.slice(item.rangeStart, item.rangeEnd);
|
|
185
|
+
if (currentText !== item.callExpression) {
|
|
186
|
+
return {
|
|
187
|
+
item,
|
|
188
|
+
reason: "range content does not match original call \u2014 already transformed or stale analysis; skipping"
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
if (!item.flagKeyExpression || !item.fallbackExpression || !item.evaluationContextExpression) {
|
|
192
|
+
return { item, reason: "missing flag key, fallback, or evaluation context evidence" };
|
|
193
|
+
}
|
|
194
|
+
const method = methodForType(item.valueType);
|
|
195
|
+
if (!method) return { item, reason: "unsupported or unknown value type" };
|
|
196
|
+
const call = `${clientBindingName}.${method}(${item.flagKeyExpression}, ${item.fallbackExpression}, ${item.evaluationContextExpression})`;
|
|
197
|
+
return { item, replacement: call };
|
|
198
|
+
}
|
|
199
|
+
function applyReplacements(code, replacements) {
|
|
200
|
+
let next = code;
|
|
201
|
+
for (const r of [...replacements].sort((a, b) => b.item.rangeStart - a.item.rangeStart)) {
|
|
202
|
+
next = next.slice(0, r.item.rangeStart) + r.replacement + next.slice(r.item.rangeEnd);
|
|
203
|
+
}
|
|
204
|
+
return next;
|
|
205
|
+
}
|
|
206
|
+
async function defaultIsWorkingTreeDirty() {
|
|
207
|
+
try {
|
|
208
|
+
const { stdout } = await execFileAsync("git", ["status", "--porcelain"]);
|
|
209
|
+
return stdout.trim().length > 0;
|
|
210
|
+
} catch {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
async function applyMigration(analysis, source, options = {}) {
|
|
215
|
+
if (!options.allowDirty) {
|
|
216
|
+
const checkDirty = options.isWorkingTreeDirty ?? defaultIsWorkingTreeDirty;
|
|
217
|
+
if (await checkDirty()) {
|
|
218
|
+
throw new ApplyError(
|
|
219
|
+
"dirty-tree",
|
|
220
|
+
"Working tree has uncommitted changes.\nCommit or stash your changes first, or pass --allow-dirty to override.\nReview `flaglint migrate --dry-run` for provider setup guidance before applying."
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const itemsByFile = /* @__PURE__ */ new Map();
|
|
225
|
+
for (const item of analysis.inventoryItems) {
|
|
226
|
+
if (!itemsByFile.has(item.file)) itemsByFile.set(item.file, []);
|
|
227
|
+
itemsByFile.get(item.file).push(item);
|
|
228
|
+
}
|
|
229
|
+
const transformedFiles = [];
|
|
230
|
+
const skippedFiles = [];
|
|
231
|
+
let transformed = 0;
|
|
232
|
+
for (const [file, items] of [...itemsByFile.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
233
|
+
const code = await source.readFile(file);
|
|
234
|
+
const bindingName = getOpenFeatureClientBindingName(code, options.allowedOpenFeatureClientBindings ?? []);
|
|
235
|
+
if (!bindingName) {
|
|
236
|
+
skippedFiles.push({
|
|
237
|
+
file,
|
|
238
|
+
reason: "skipped \u2014 OpenFeature client setup required. Review `flaglint migrate --dry-run` provider guidance first."
|
|
239
|
+
});
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const replacements = [];
|
|
243
|
+
for (const item of items) {
|
|
244
|
+
const result = buildReplacement(item, code, bindingName);
|
|
245
|
+
if ("reason" in result) continue;
|
|
246
|
+
replacements.push(result);
|
|
247
|
+
}
|
|
248
|
+
if (replacements.length === 0) continue;
|
|
249
|
+
const newCode = applyReplacements(code, replacements);
|
|
250
|
+
if (newCode === code) continue;
|
|
251
|
+
await source.writeFile(file, newCode);
|
|
252
|
+
transformedFiles.push(file);
|
|
253
|
+
transformed += replacements.length;
|
|
254
|
+
}
|
|
255
|
+
return { transformed, skipped: skippedFiles.length, transformedFiles, skippedFiles };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export {
|
|
259
|
+
ApplyError,
|
|
260
|
+
getOpenFeatureClientBindingName,
|
|
261
|
+
hasOpenFeatureClientBinding,
|
|
262
|
+
applyMigration
|
|
263
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flaglint",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "LaunchDarkly Node.js server SDK -> OpenFeature migration.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"LICENSE"
|
|
14
14
|
],
|
|
15
15
|
"engines": {
|
|
16
|
-
"node": ">=
|
|
16
|
+
"node": ">=20"
|
|
17
17
|
},
|
|
18
18
|
"keywords": [
|
|
19
19
|
"feature-flags",
|
|
@@ -39,8 +39,11 @@
|
|
|
39
39
|
"dev": "tsup --watch",
|
|
40
40
|
"typecheck": "tsc --noEmit",
|
|
41
41
|
"typecheck:agent": "tsc --project tsconfig.agent.json",
|
|
42
|
+
"pretest": "npm run build",
|
|
42
43
|
"test": "vitest",
|
|
44
|
+
"pretest:run": "npm run build",
|
|
43
45
|
"test:run": "vitest run",
|
|
46
|
+
"pretest:coverage": "npm run build",
|
|
44
47
|
"test:coverage": "vitest run --coverage",
|
|
45
48
|
"new-branch": "tsx scripts/new-branch.ts",
|
|
46
49
|
"release:patch": "tsx scripts/release.ts patch",
|
|
@@ -53,11 +56,13 @@
|
|
|
53
56
|
"chalk": "^5.3.0",
|
|
54
57
|
"commander": "^12.1.0",
|
|
55
58
|
"fast-glob": "^3.3.2",
|
|
59
|
+
"micromatch": "^4.0.8",
|
|
56
60
|
"ora": "^8.1.0",
|
|
57
61
|
"p-limit": "^7.3.0",
|
|
58
62
|
"zod": "^3.23.8"
|
|
59
63
|
},
|
|
60
64
|
"devDependencies": {
|
|
65
|
+
"@types/micromatch": "^4.0.10",
|
|
61
66
|
"@types/node": "^22.0.0",
|
|
62
67
|
"@vitest/coverage-v8": "^4.1.6",
|
|
63
68
|
"clipboardy": "^4.0.0",
|