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 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
- ## [Unreleased]
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 Node.js server SDK -> OpenFeature migration</strong>
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
- FlagLint inventories direct LaunchDarkly Node.js server SDK calls in your TypeScript/JavaScript
34
- codebase, generates reviewable OpenFeature migration diffs, applies only guarded transformations,
35
- and enforces migration state in CI.
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 already contain a proven `openFeatureClient` binding
153
- (`openFeatureClient = OpenFeature.getClient()` from `@openfeature/server-sdk`)
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, { targetingKey: user.id });
264
- const theme = await openFeatureClient.getStringValue("color-theme", "light", { targetingKey: user.id });
265
- const timeout = await openFeatureClient.getNumberValue("timeout-ms", 5000, { targetingKey: user.id });
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: Scan for LaunchDarkly SDK usage
332
- run: npx flaglint scan --format sarif --output flaglint.sarif
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
- Code Scanning alerts show the exact file and line of each direct LD call — reviewers see them in the PR without running anything locally.
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 forms,
355
- and custom wrappers are all resolved before matching.
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
+ };
@@ -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.4.1" : "0.1.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">${scannedFiles}</div><div class="card-label">Files Scanned</div></div>
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">${totalUsages}</div><div class="card-label">Total Usages</div></div>
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
- <footer>Generated by FlagLint ${esc(version)} on ${esc(date)}</footer>
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 rows = document.querySelectorAll('#flags-table tbody tr');
829
+ const tableRows = document.querySelectorAll('#flags-table tbody tr');
752
830
  input.addEventListener('input', () => {
753
831
  const q = input.value.toLowerCase();
754
- rows.forEach(r => { r.style.display = r.textContent.toLowerCase().includes(q) ? '' : 'none'; });
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.exit(staleCount > 0 ? 1 : 0);
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.4.1" : "0.1.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 call = `openFeatureClient.${method}(${item.flagKeyExpression}, ${item.fallbackExpression}, ${item.evaluationContextExpression})`;
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: true
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
- const result = buildReplacement(item);
1310
- if ("reason" in result) {
1311
- skipped.push(result);
1312
- continue;
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 resolve6 } from "path";
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("-c, --config <path>", "path to .flaglintrc config file").addHelpText(
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(resolve6(dir));
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
- const report = formatValidationReport(validationResult, validateOptions);
1887
- process.stdout.write(report);
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.4.1", "-v, --version", "output the current version").addHelpText(
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.4.1",
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": ">=22"
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",