@yedoma-labs/kulyn-flags 0.0.0-stage → 1.0.1
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/LICENSE +21 -0
- package/README.md +181 -2
- package/dist/index.cjs +419 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +127 -0
- package/dist/index.d.ts +127 -0
- package/dist/index.js +412 -0
- package/dist/index.js.map +1 -0
- package/package.json +79 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 yedoma-labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,182 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @yedoma-labs/kulyn-flags
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<picture>
|
|
4
|
+
<source media="(max-width: 640px)" srcset="https://raw.githubusercontent.com/yedoma-labs/assets/main/resized/banner-resized-mobile.png">
|
|
5
|
+
<img src="https://raw.githubusercontent.com/yedoma-labs/assets/main/resized/banner-resized.png" alt="Project Header">
|
|
6
|
+
</picture>
|
|
7
|
+
|
|
8
|
+
[](https://github.com/yedoma-labs/kulyn-flags/actions/workflows/ci.yml)
|
|
9
|
+
[](https://www.npmjs.com/package/@yedoma-labs/kulyn-flags)
|
|
10
|
+
[](https://www.typescriptlang.org/)
|
|
11
|
+
[](https://opensource.org/licenses/MIT)
|
|
12
|
+
|
|
13
|
+
> **kulyn** (*kulyn*) — Yakutian (Sakha) for (noch zu bestätigen)
|
|
14
|
+
|
|
15
|
+
A flag register with owners and expiry dates, plus hygiene checks you run in CI.
|
|
16
|
+
It answers the question that runtime flag SDKs leave open: **who owns this flag, and is it safe to delete?**
|
|
17
|
+
|
|
18
|
+
## The problem
|
|
19
|
+
|
|
20
|
+
Teams add feature flags and never remove them. After a year nobody knows:
|
|
21
|
+
|
|
22
|
+
- who owns a flag,
|
|
23
|
+
- whether the code still reads it,
|
|
24
|
+
- whether it can be deleted without changing behavior.
|
|
25
|
+
|
|
26
|
+
Runtime SDKs evaluate flags well. They do not track the lifecycle of a flag. This library fills that gap with a small data format and checks that you run in tests or CI.
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
pnpm add -D @yedoma-labs/kulyn-flags
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Requires Node.js 20 or newer. It has no runtime dependencies.
|
|
35
|
+
|
|
36
|
+
## Quickstart
|
|
37
|
+
|
|
38
|
+
### 1. Register your flags
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
// flags.register.ts
|
|
42
|
+
import { defineFlags } from "@yedoma-labs/kulyn-flags";
|
|
43
|
+
|
|
44
|
+
export const flags = defineFlags([
|
|
45
|
+
{
|
|
46
|
+
key: "checkout.new-ui",
|
|
47
|
+
owner: "team-checkout",
|
|
48
|
+
kind: "release",
|
|
49
|
+
createdAt: "2026-01-12",
|
|
50
|
+
expiresAt: "2026-03-31",
|
|
51
|
+
description: "Replaces the legacy checkout page",
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
key: "ops.maintenance-banner",
|
|
55
|
+
owner: "platform",
|
|
56
|
+
kind: "ops",
|
|
57
|
+
createdAt: "2026-02-01",
|
|
58
|
+
expiresAt: "2027-02-01",
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
key: "perm.beta-admin",
|
|
62
|
+
owner: "platform",
|
|
63
|
+
kind: "permission",
|
|
64
|
+
createdAt: "2025-11-03",
|
|
65
|
+
},
|
|
66
|
+
]);
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`defineFlags` throws a `FlagRegistryError` with a `code` for the first invalid entry, for example a duplicate key, a missing owner, an invalid date or a lifetime that is too long.
|
|
70
|
+
|
|
71
|
+
### 2. Add a CI test
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
// tests/flag-hygiene.test.ts
|
|
75
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
76
|
+
import { join } from "node:path";
|
|
77
|
+
import { describe, it } from "vitest";
|
|
78
|
+
import { assertFlagHygiene, checkFlagHygiene, findFlagKeysInSource } from "@yedoma-labs/kulyn-flags";
|
|
79
|
+
import { flags } from "../flags.register";
|
|
80
|
+
|
|
81
|
+
function readSources(dir: string): string {
|
|
82
|
+
return readdirSync(dir, { recursive: true })
|
|
83
|
+
.map(String)
|
|
84
|
+
.filter((name) => name.endsWith(".ts") || name.endsWith(".tsx"))
|
|
85
|
+
.map((name) => readFileSync(join(dir, name), "utf8"))
|
|
86
|
+
.join("\n");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe("feature flag hygiene", () => {
|
|
90
|
+
it("has no expired, overlong or unused flags, and no unregistered usage", () => {
|
|
91
|
+
const usedKeys = findFlagKeysInSource(readSources("src"));
|
|
92
|
+
const findings = checkFlagHygiene({ flags, usedKeys });
|
|
93
|
+
assertFlagHygiene(findings);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`assertFlagHygiene` throws a `FlagHygieneError` that lists every finding. Pass `failOn` to fail only on some codes:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
assertFlagHygiene(findings, { failOn: ["EXPIRED", "UNREGISTERED_USAGE"] });
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Lifecycle rules
|
|
105
|
+
|
|
106
|
+
### Register
|
|
107
|
+
|
|
108
|
+
| Field | Rule |
|
|
109
|
+
| ------------- | ------------------------------------------------------------------------------------- |
|
|
110
|
+
| `key` | Matches `/^[a-z][a-z0-9-.]{1,79}$/`. Keys are unique. |
|
|
111
|
+
| `owner` | Must not be blank. |
|
|
112
|
+
| `kind` | `release`, `experiment`, `ops` or `permission`. |
|
|
113
|
+
| `createdAt` | ISO date (`2026-01-12`) or date-time with a timezone (`2026-01-12T10:00:00Z`). |
|
|
114
|
+
| `expiresAt` | Same format. Must be after `createdAt`. |
|
|
115
|
+
| `description` | Optional string. |
|
|
116
|
+
|
|
117
|
+
Default maximum lifetimes:
|
|
118
|
+
|
|
119
|
+
| Kind | Max lifetime | `expiresAt` required |
|
|
120
|
+
| ------------ | ------------ | -------------------- |
|
|
121
|
+
| `release` | 90 days | yes |
|
|
122
|
+
| `experiment` | 30 days | yes |
|
|
123
|
+
| `ops` | 365 days | yes |
|
|
124
|
+
| `permission` | no limit | no |
|
|
125
|
+
|
|
126
|
+
A kind with a finite limit requires `expiresAt`. Otherwise a flag could avoid the limit by omitting the date.
|
|
127
|
+
Lifetimes are measured from `createdAt` to `expiresAt`. A lifetime equal to the maximum is allowed.
|
|
128
|
+
|
|
129
|
+
Override the limits per kind. Use `null` to remove a limit:
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
defineFlags(flags, {
|
|
133
|
+
maxLifetimeDays: { release: 120, experiment: null },
|
|
134
|
+
});
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Hygiene findings
|
|
138
|
+
|
|
139
|
+
| Code | Meaning |
|
|
140
|
+
| ----------------------- | ---------------------------------------------------------------------------------------- |
|
|
141
|
+
| `EXPIRED` | `now` is at or after `expiresAt`. The expiry instant itself counts as expired. |
|
|
142
|
+
| `EXCEEDS_MAX_LIFETIME` | The lifetime is longer than the current limit for its kind. Catches flags registered before a limit was lowered. |
|
|
143
|
+
| `UNUSED` | The flag is registered but its key was not found in the code. Only with `usedKeys`. |
|
|
144
|
+
| `UNREGISTERED_USAGE` | A key is used in the code but not registered. Only with `usedKeys`. |
|
|
145
|
+
|
|
146
|
+
`UNUSED` and `UNREGISTERED_USAGE` are only produced when you pass `usedKeys`. Findings are sorted by key, then by code, so the output is stable and diff-friendly.
|
|
147
|
+
|
|
148
|
+
## API
|
|
149
|
+
|
|
150
|
+
- `defineFlags(flags, options?)` validates a register and returns a frozen copy.
|
|
151
|
+
- `checkFlagHygiene({ flags, now?, usedKeys?, maxLifetimeDays? })` returns an array of findings.
|
|
152
|
+
- `assertFlagHygiene(findings, { failOn? })` throws `FlagHygieneError` when a finding matches `failOn`. The default is all codes.
|
|
153
|
+
- `findFlagKeysInSource(sourceText, { functions? })` returns a `Set` of keys. The default function names are `isEnabled`, `getFlag`, `flag` and `evaluate`. The library does not read files. Pass in the text.
|
|
154
|
+
- Errors: `FlagRegistryError` (with `code` and `key`) and `FlagHygieneError` (with `code` and `findings`).
|
|
155
|
+
|
|
156
|
+
## How the source scan works
|
|
157
|
+
|
|
158
|
+
`findFlagKeysInSource` recognizes:
|
|
159
|
+
|
|
160
|
+
- calls with a string literal as the first argument: `isEnabled("key")`, `getFlag('key')`, `client.isEnabled("key", { fallback: false })`
|
|
161
|
+
- property access: `flags.beta`, `flags["new-ui"]`
|
|
162
|
+
|
|
163
|
+
It ignores text in comments, in template literals (`` `isEnabled("x")` `` is not a call) and in quoted strings (`"flags.old"` is text, not a use). It does scan calls inside `${...}` placeholders. Template literals used as call arguments, such as ``getFlag(`key`)``, are not matched. Keys must be written as literals to be found.
|
|
164
|
+
|
|
165
|
+
The scan runs in linear time and does not use nested quantifiers. Long inputs stay fast.
|
|
166
|
+
|
|
167
|
+
## Limitations
|
|
168
|
+
|
|
169
|
+
- The scan is static. Keys computed at runtime, such as `isEnabled(prefix + name)`, are not found. Flags that are only reached through such calls appear as `UNUSED`. Pass the relevant keys in `usedKeys` or narrow `failOn`.
|
|
170
|
+
- Regex literals that contain quote characters are not understood. A quote inside a regex can hide code on the same line.
|
|
171
|
+
- Dates are checked against the calendar. Date-times need an explicit timezone.
|
|
172
|
+
- The library checks the register. It does not know which flags are on or off in production.
|
|
173
|
+
|
|
174
|
+
## Not a runtime SDK
|
|
175
|
+
|
|
176
|
+
This library does not evaluate flags. It does not decide whether a user sees a feature, and it has no network access. For that, use [OpenFeature](https://openfeature.dev/) with its server SDK, [Unleash](https://www.getunleash.io/) or [Flagsmith](https://www.flagsmith.com/).
|
|
177
|
+
|
|
178
|
+
Use `kulyn-flags` alongside them. Keep the register in your repository, run the hygiene check in CI, and delete flags once the findings say they are safe to remove.
|
|
179
|
+
|
|
180
|
+
## License
|
|
181
|
+
|
|
182
|
+
MIT. See [LICENSE](LICENSE).
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
// src/errors.ts
|
|
3
|
+
/**
|
|
4
|
+
* Thrown when a flag register or its options are invalid.
|
|
5
|
+
*/
|
|
6
|
+
var FlagRegistryError = class extends Error {
|
|
7
|
+
code;
|
|
8
|
+
/** The offending flag key, when it is known. */
|
|
9
|
+
key;
|
|
10
|
+
constructor(code, message, key) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "FlagRegistryError";
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.key = key;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Thrown by `assertFlagHygiene` when findings match the `failOn` codes.
|
|
19
|
+
*/
|
|
20
|
+
var FlagHygieneError = class extends Error {
|
|
21
|
+
code;
|
|
22
|
+
findings;
|
|
23
|
+
constructor(findings) {
|
|
24
|
+
const lines = findings.map((finding) => ` - [${finding.code}] ${finding.message}`);
|
|
25
|
+
super(`Flag hygiene check failed with ${findings.length} finding(s):\n${lines.join("\n")}`);
|
|
26
|
+
this.name = "FlagHygieneError";
|
|
27
|
+
this.code = findings[0]?.code ?? "UNUSED";
|
|
28
|
+
this.findings = findings;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
// src/dates.ts
|
|
32
|
+
const DAY_MS = 864e5;
|
|
33
|
+
/**
|
|
34
|
+
* `YYYY-MM-DD`, optionally followed by `T HH:MM[:SS[.fraction]]` and a mandatory timezone.
|
|
35
|
+
* Only fixed quantifiers and a single optional group, so matching is linear.
|
|
36
|
+
*/
|
|
37
|
+
const ISO_PATTERN = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,9}))?)?(Z|[+-]\d{2}:\d{2}))?$/;
|
|
38
|
+
function isLeapYear(year) {
|
|
39
|
+
return year % 4 === 0 && year % 100 !== 0 || year % 400 === 0;
|
|
40
|
+
}
|
|
41
|
+
function daysInMonth(year, month) {
|
|
42
|
+
return [
|
|
43
|
+
31,
|
|
44
|
+
isLeapYear(year) ? 29 : 28,
|
|
45
|
+
31,
|
|
46
|
+
30,
|
|
47
|
+
31,
|
|
48
|
+
30,
|
|
49
|
+
31,
|
|
50
|
+
31,
|
|
51
|
+
30,
|
|
52
|
+
31,
|
|
53
|
+
30,
|
|
54
|
+
31
|
|
55
|
+
][month - 1] ?? 0;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Parses a strict ISO 8601 date or date-time and returns epoch milliseconds.
|
|
59
|
+
* Returns `undefined` for anything that is not a real calendar date and time.
|
|
60
|
+
*/
|
|
61
|
+
function parseIsoDate(value) {
|
|
62
|
+
if (typeof value !== "string") return;
|
|
63
|
+
const match = ISO_PATTERN.exec(value);
|
|
64
|
+
if (match === null) return;
|
|
65
|
+
const year = Number(match[1]);
|
|
66
|
+
const month = Number(match[2]);
|
|
67
|
+
const day = Number(match[3]);
|
|
68
|
+
if (month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) return;
|
|
69
|
+
if (match[4] !== void 0) {
|
|
70
|
+
const hours = Number(match[4]);
|
|
71
|
+
const minutes = Number(match[5]);
|
|
72
|
+
const seconds = match[6] === void 0 ? 0 : Number(match[6]);
|
|
73
|
+
if (hours > 23 || minutes > 59 || seconds > 59) return;
|
|
74
|
+
const offset = match[8];
|
|
75
|
+
if (offset !== void 0 && offset !== "Z") {
|
|
76
|
+
const offsetHours = Number(offset.slice(1, 3));
|
|
77
|
+
const offsetMinutes = Number(offset.slice(4, 6));
|
|
78
|
+
if (offsetHours > 23 || offsetMinutes > 59) return;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const time = Date.parse(value);
|
|
82
|
+
return Number.isNaN(time) ? void 0 : time;
|
|
83
|
+
}
|
|
84
|
+
// src/lifetimes.ts
|
|
85
|
+
const FLAG_KINDS = [
|
|
86
|
+
"release",
|
|
87
|
+
"experiment",
|
|
88
|
+
"ops",
|
|
89
|
+
"permission"
|
|
90
|
+
];
|
|
91
|
+
/**
|
|
92
|
+
* Default maximum lifetimes. `permission` flags may live without limit.
|
|
93
|
+
*/
|
|
94
|
+
const DEFAULT_MAX_LIFETIME_DAYS = Object.freeze({
|
|
95
|
+
release: 90,
|
|
96
|
+
experiment: 30,
|
|
97
|
+
ops: 365,
|
|
98
|
+
permission: null
|
|
99
|
+
});
|
|
100
|
+
function isFlagKind(value) {
|
|
101
|
+
return typeof value === "string" && FLAG_KINDS.includes(value);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Merges user overrides into the defaults and validates them.
|
|
105
|
+
*/
|
|
106
|
+
function resolveMaxLifetimeDays(overrides) {
|
|
107
|
+
const resolved = { ...DEFAULT_MAX_LIFETIME_DAYS };
|
|
108
|
+
if (overrides === void 0) return resolved;
|
|
109
|
+
if (typeof overrides !== "object" || overrides === null) throw new FlagRegistryError("INVALID_OPTIONS", "maxLifetimeDays must be an object");
|
|
110
|
+
for (const kind of Object.keys(overrides)) {
|
|
111
|
+
if (!isFlagKind(kind)) throw new FlagRegistryError("INVALID_OPTIONS", `Unknown flag kind in maxLifetimeDays: "${kind}"`);
|
|
112
|
+
const value = overrides[kind];
|
|
113
|
+
if (value === void 0) continue;
|
|
114
|
+
if (value !== null && !(Number.isInteger(value) && value > 0)) throw new FlagRegistryError("INVALID_OPTIONS", `maxLifetimeDays.${kind} must be a positive integer or null, got ${String(value)}`);
|
|
115
|
+
resolved[kind] = value;
|
|
116
|
+
}
|
|
117
|
+
return resolved;
|
|
118
|
+
}
|
|
119
|
+
// src/hygiene.ts
|
|
120
|
+
/** Order of codes in the result when two findings share a key. */
|
|
121
|
+
const CODE_ORDER = [
|
|
122
|
+
"EXPIRED",
|
|
123
|
+
"EXCEEDS_MAX_LIFETIME",
|
|
124
|
+
"UNUSED",
|
|
125
|
+
"UNREGISTERED_USAGE"
|
|
126
|
+
];
|
|
127
|
+
/**
|
|
128
|
+
* Checks a flag register for flags that should be cleaned up.
|
|
129
|
+
*
|
|
130
|
+
* - `EXPIRED`: `now` is at or after `expiresAt`.
|
|
131
|
+
* - `EXCEEDS_MAX_LIFETIME`: the flag lives longer than the configured maximum for its kind.
|
|
132
|
+
* This catches flags registered before a limit was lowered.
|
|
133
|
+
* - `UNUSED`: only when `usedKeys` is given. The key is registered but not used.
|
|
134
|
+
* - `UNREGISTERED_USAGE`: only when `usedKeys` is given. The key is used but not registered.
|
|
135
|
+
*
|
|
136
|
+
* Findings are sorted by key, then by code.
|
|
137
|
+
*/
|
|
138
|
+
function checkFlagHygiene(options) {
|
|
139
|
+
const nowMs = resolveNow(options.now);
|
|
140
|
+
const lifetimes = resolveMaxLifetimeDays(options.maxLifetimeDays);
|
|
141
|
+
const registered = /* @__PURE__ */ new Set();
|
|
142
|
+
const findings = [];
|
|
143
|
+
for (const flag of options.flags) {
|
|
144
|
+
if (registered.has(flag.key)) throw new FlagRegistryError("DUPLICATE_KEY", `Duplicate flag key "${flag.key}"`, flag.key);
|
|
145
|
+
registered.add(flag.key);
|
|
146
|
+
collectLifecycleFindings(flag, nowMs, lifetimes[flag.kind], findings);
|
|
147
|
+
}
|
|
148
|
+
if (options.usedKeys !== void 0) {
|
|
149
|
+
const used = new Set(options.usedKeys);
|
|
150
|
+
for (const key of registered) if (!used.has(key)) findings.push({
|
|
151
|
+
key,
|
|
152
|
+
code: "UNUSED",
|
|
153
|
+
message: `Flag "${key}" is registered but not used`
|
|
154
|
+
});
|
|
155
|
+
for (const key of used) if (!registered.has(key)) findings.push({
|
|
156
|
+
key,
|
|
157
|
+
code: "UNREGISTERED_USAGE",
|
|
158
|
+
message: `Flag "${key}" is used but not registered`
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return findings.sort((a, b) => compareStrings(a.key, b.key) || CODE_ORDER.indexOf(a.code) - CODE_ORDER.indexOf(b.code));
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Throws `FlagHygieneError` if any finding has a code listed in `failOn`.
|
|
165
|
+
* Defaults to all codes. Use it in tests or CI.
|
|
166
|
+
*/
|
|
167
|
+
function assertFlagHygiene(findings, options) {
|
|
168
|
+
const failOn = new Set(options?.failOn ?? CODE_ORDER);
|
|
169
|
+
const failing = findings.filter((finding) => failOn.has(finding.code));
|
|
170
|
+
if (failing.length > 0) throw new FlagHygieneError(failing);
|
|
171
|
+
}
|
|
172
|
+
function collectLifecycleFindings(flag, nowMs, maxDays, findings) {
|
|
173
|
+
if (!isFlagKind(flag.kind)) throw new FlagRegistryError("INVALID_KIND", `Flag "${flag.key}" has invalid kind`, flag.key);
|
|
174
|
+
const createdMs = parseIsoDate(flag.createdAt);
|
|
175
|
+
if (createdMs === void 0) throw new FlagRegistryError("INVALID_DATE", `Flag "${flag.key}" has invalid createdAt`, flag.key);
|
|
176
|
+
if (flag.expiresAt === void 0) return;
|
|
177
|
+
const expiresMs = parseIsoDate(flag.expiresAt);
|
|
178
|
+
if (expiresMs === void 0) throw new FlagRegistryError("INVALID_DATE", `Flag "${flag.key}" has invalid expiresAt`, flag.key);
|
|
179
|
+
if (nowMs >= expiresMs) findings.push({
|
|
180
|
+
key: flag.key,
|
|
181
|
+
code: "EXPIRED",
|
|
182
|
+
message: `Flag "${flag.key}" expired at ${flag.expiresAt}`
|
|
183
|
+
});
|
|
184
|
+
if (maxDays !== null && maxDays !== void 0) {
|
|
185
|
+
const lifetimeMs = expiresMs - createdMs;
|
|
186
|
+
if (lifetimeMs > maxDays * 864e5) findings.push({
|
|
187
|
+
key: flag.key,
|
|
188
|
+
code: "EXCEEDS_MAX_LIFETIME",
|
|
189
|
+
message: `Flag "${flag.key}" (${flag.kind}) lives ${lifetimeMs / DAY_MS} days, the maximum is ${maxDays}`
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
function resolveNow(now) {
|
|
194
|
+
if (now === void 0) return Date.now();
|
|
195
|
+
const time = now.getTime();
|
|
196
|
+
if (Number.isNaN(time)) throw new FlagRegistryError("INVALID_OPTIONS", "now is an invalid Date");
|
|
197
|
+
return time;
|
|
198
|
+
}
|
|
199
|
+
function compareStrings(a, b) {
|
|
200
|
+
if (a === b) return 0;
|
|
201
|
+
return a < b ? -1 : 1;
|
|
202
|
+
}
|
|
203
|
+
// src/registry.ts
|
|
204
|
+
const KEY_PATTERN = /^[a-z][a-z0-9-.]{1,79}$/;
|
|
205
|
+
/**
|
|
206
|
+
* Validates a flag register and returns a frozen, normalized copy.
|
|
207
|
+
*
|
|
208
|
+
* Throws `FlagRegistryError` with a `code` for the first invalid entry.
|
|
209
|
+
* A flag kind with a finite maximum lifetime requires `expiresAt`, so that a flag
|
|
210
|
+
* cannot escape the limit by omitting the date.
|
|
211
|
+
*/
|
|
212
|
+
function defineFlags(definitions, options) {
|
|
213
|
+
if (!Array.isArray(definitions)) throw new FlagRegistryError("INVALID_DEFINITION", "defineFlags expects an array of flags");
|
|
214
|
+
const maxLifetimeDays = resolveMaxLifetimeDays(options?.maxLifetimeDays);
|
|
215
|
+
const seen = /* @__PURE__ */ new Set();
|
|
216
|
+
const result = [];
|
|
217
|
+
for (const [index, raw] of definitions.entries()) {
|
|
218
|
+
const flag = validateDefinition(raw, index, maxLifetimeDays);
|
|
219
|
+
if (seen.has(flag.key)) throw new FlagRegistryError("DUPLICATE_KEY", `Duplicate flag key "${flag.key}"`, flag.key);
|
|
220
|
+
seen.add(flag.key);
|
|
221
|
+
result.push(flag);
|
|
222
|
+
}
|
|
223
|
+
return Object.freeze(result);
|
|
224
|
+
}
|
|
225
|
+
function validateDefinition(raw, index, maxLifetimeDays) {
|
|
226
|
+
if (typeof raw !== "object" || raw === null) throw new FlagRegistryError("INVALID_DEFINITION", `Flag at index ${index} is not an object`);
|
|
227
|
+
const record = raw;
|
|
228
|
+
const key = record.key;
|
|
229
|
+
if (typeof key !== "string" || !KEY_PATTERN.test(key)) throw new FlagRegistryError("INVALID_KEY", `Invalid flag key at index ${index}: ${JSON.stringify(key)} (must match ${KEY_PATTERN.source})`, typeof key === "string" ? key : void 0);
|
|
230
|
+
const owner = record.owner;
|
|
231
|
+
if (typeof owner !== "string" || owner.trim() === "") throw new FlagRegistryError("EMPTY_OWNER", `Flag "${key}" has no owner`, key);
|
|
232
|
+
const kind = record.kind;
|
|
233
|
+
if (!isFlagKind(kind)) throw new FlagRegistryError("INVALID_KIND", `Flag "${key}" has invalid kind ${JSON.stringify(kind)}`, key);
|
|
234
|
+
const createdAt = record.createdAt;
|
|
235
|
+
const createdMs = parseIsoDate(createdAt);
|
|
236
|
+
if (createdMs === void 0) throw new FlagRegistryError("INVALID_DATE", `Flag "${key}" has invalid createdAt ${JSON.stringify(createdAt)}`, key);
|
|
237
|
+
const expiresAt = record.expiresAt;
|
|
238
|
+
let expiresMs;
|
|
239
|
+
if (expiresAt !== void 0) {
|
|
240
|
+
expiresMs = parseIsoDate(expiresAt);
|
|
241
|
+
if (expiresMs === void 0) throw new FlagRegistryError("INVALID_DATE", `Flag "${key}" has invalid expiresAt ${JSON.stringify(expiresAt)}`, key);
|
|
242
|
+
if (expiresMs <= createdMs) throw new FlagRegistryError("EXPIRY_NOT_AFTER_CREATION", `Flag "${key}" expires at ${String(expiresAt)}, which is not after createdAt`, key);
|
|
243
|
+
}
|
|
244
|
+
const maxDays = maxLifetimeDays[kind];
|
|
245
|
+
if (maxDays !== null) {
|
|
246
|
+
if (expiresMs === void 0) throw new FlagRegistryError("MISSING_EXPIRY", `Flag "${key}" (${kind}) needs an expiresAt, the maximum lifetime is ${maxDays} days`, key);
|
|
247
|
+
if (expiresMs - createdMs > maxDays * 864e5) throw new FlagRegistryError("EXCEEDS_MAX_LIFETIME", `Flag "${key}" (${kind}) lives longer than the maximum of ${maxDays} days`, key);
|
|
248
|
+
}
|
|
249
|
+
const description = record.description;
|
|
250
|
+
if (description !== void 0 && typeof description !== "string") throw new FlagRegistryError("INVALID_DESCRIPTION", `Flag "${key}" has a non-string description`, key);
|
|
251
|
+
const flag = {
|
|
252
|
+
key,
|
|
253
|
+
owner,
|
|
254
|
+
kind,
|
|
255
|
+
createdAt
|
|
256
|
+
};
|
|
257
|
+
if (expiresAt !== void 0) flag.expiresAt = expiresAt;
|
|
258
|
+
if (description !== void 0) flag.description = description;
|
|
259
|
+
return flag;
|
|
260
|
+
}
|
|
261
|
+
// src/scan.ts
|
|
262
|
+
const DEFAULT_FUNCTIONS = [
|
|
263
|
+
"isEnabled",
|
|
264
|
+
"getFlag",
|
|
265
|
+
"flag",
|
|
266
|
+
"evaluate"
|
|
267
|
+
];
|
|
268
|
+
/** Upper bound for a key literal. Longer literals are ignored. */
|
|
269
|
+
const MAX_LITERAL_LENGTH = 100;
|
|
270
|
+
/**
|
|
271
|
+
* Finds flag keys in source text.
|
|
272
|
+
*
|
|
273
|
+
* Recognized forms, with single or double quotes:
|
|
274
|
+
* - `isEnabled("key")`, `getFlag('key')` and the other configured function names
|
|
275
|
+
* - `flags.key` (identifier-style keys only)
|
|
276
|
+
* - `flags["key"]`
|
|
277
|
+
*
|
|
278
|
+
* Text in comments, and template literals (backticks), are ignored, including `${...}`
|
|
279
|
+
* placeholders in template literals that are not code. Calls inside `${...}` are still scanned.
|
|
280
|
+
* Calls and property access written inside a quoted string (`"isEnabled('x')"`) are ignored,
|
|
281
|
+
* because they are text, not code.
|
|
282
|
+
* This function does not read files. The caller passes the text.
|
|
283
|
+
*
|
|
284
|
+
* Limitations: regex literals containing quotes are not understood. A quote inside a
|
|
285
|
+
* regex literal can hide code on the same line.
|
|
286
|
+
*/
|
|
287
|
+
function findFlagKeysInSource(sourceText, options) {
|
|
288
|
+
const functions = options?.functions ?? DEFAULT_FUNCTIONS;
|
|
289
|
+
const { code, stringRanges } = maskWithStrings(sourceText);
|
|
290
|
+
const keys = /* @__PURE__ */ new Set();
|
|
291
|
+
if (functions.length > 0) {
|
|
292
|
+
const alternation = functions.map(escapeRegExp).join("|");
|
|
293
|
+
const callPattern = new RegExp(String.raw`\b(?:${alternation})\s*\(\s*(["'])([^"'\\\r\n]{1,${MAX_LITERAL_LENGTH}})\1(?=\s*[,)])`, "g");
|
|
294
|
+
for (const match of code.matchAll(callPattern)) {
|
|
295
|
+
if (isInRanges(stringRanges, match.index)) continue;
|
|
296
|
+
const key = match[2];
|
|
297
|
+
if (key !== void 0) keys.add(key);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
for (const match of code.matchAll(/\bflags\.([a-z][a-z0-9]*)(?![\w.-]|\s*\()/g)) {
|
|
301
|
+
if (isInRanges(stringRanges, match.index)) continue;
|
|
302
|
+
const key = match[1];
|
|
303
|
+
if (key !== void 0) keys.add(key);
|
|
304
|
+
}
|
|
305
|
+
const bracketPattern = new RegExp(String.raw`\bflags\s*\[\s*(["'])([^"'\\\r\n]{1,${MAX_LITERAL_LENGTH}})\1\s*\]`, "g");
|
|
306
|
+
for (const match of code.matchAll(bracketPattern)) {
|
|
307
|
+
if (isInRanges(stringRanges, match.index)) continue;
|
|
308
|
+
const key = match[2];
|
|
309
|
+
if (key !== void 0) keys.add(key);
|
|
310
|
+
}
|
|
311
|
+
return keys;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Like `maskNonCode`, and also returns the ranges of quoted string literals as flat
|
|
315
|
+
* `[start, end, start, end, ...]` pairs (end is exclusive), in source order.
|
|
316
|
+
*/
|
|
317
|
+
function maskWithStrings(source) {
|
|
318
|
+
const out = source.split("");
|
|
319
|
+
const n = source.length;
|
|
320
|
+
const stringRanges = [];
|
|
321
|
+
const frames = [{
|
|
322
|
+
kind: "code",
|
|
323
|
+
braces: 0,
|
|
324
|
+
interpolation: false
|
|
325
|
+
}];
|
|
326
|
+
const mask = (from, to) => {
|
|
327
|
+
for (let k = from; k < to; k++) if (out[k] !== "\n" && out[k] !== "\r") out[k] = " ";
|
|
328
|
+
};
|
|
329
|
+
let i = 0;
|
|
330
|
+
while (i < n) {
|
|
331
|
+
const top = frames[frames.length - 1];
|
|
332
|
+
if (top === void 0) break;
|
|
333
|
+
const ch = source[i];
|
|
334
|
+
const next = source[i + 1];
|
|
335
|
+
if (top.kind === "template") {
|
|
336
|
+
if (ch === "\\") {
|
|
337
|
+
mask(i, i + 2);
|
|
338
|
+
i += 2;
|
|
339
|
+
} else if (ch === "`") {
|
|
340
|
+
mask(i, i + 1);
|
|
341
|
+
frames.pop();
|
|
342
|
+
i++;
|
|
343
|
+
} else if (ch === "$" && next === "{") {
|
|
344
|
+
mask(i, i + 2);
|
|
345
|
+
frames.push({
|
|
346
|
+
kind: "code",
|
|
347
|
+
braces: 0,
|
|
348
|
+
interpolation: true
|
|
349
|
+
});
|
|
350
|
+
i += 2;
|
|
351
|
+
} else {
|
|
352
|
+
mask(i, i + 1);
|
|
353
|
+
i++;
|
|
354
|
+
}
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (ch === "/" && next === "/") {
|
|
358
|
+
const newline = source.indexOf("\n", i);
|
|
359
|
+
const end = newline === -1 ? n : newline;
|
|
360
|
+
mask(i, end);
|
|
361
|
+
i = end;
|
|
362
|
+
} else if (ch === "/" && next === "*") {
|
|
363
|
+
const close = source.indexOf("*/", i + 2);
|
|
364
|
+
const end = close === -1 ? n : close + 2;
|
|
365
|
+
mask(i, end);
|
|
366
|
+
i = end;
|
|
367
|
+
} else if (ch === "\"" || ch === "'") {
|
|
368
|
+
let j = i + 1;
|
|
369
|
+
while (j < n && source[j] !== ch && source[j] !== "\n") j += source[j] === "\\" ? 2 : 1;
|
|
370
|
+
const end = source[j] === ch ? j + 1 : j;
|
|
371
|
+
stringRanges.push(i, end);
|
|
372
|
+
i = end;
|
|
373
|
+
} else if (ch === "`") {
|
|
374
|
+
mask(i, i + 1);
|
|
375
|
+
frames.push({ kind: "template" });
|
|
376
|
+
i++;
|
|
377
|
+
} else if (ch === "{") {
|
|
378
|
+
top.braces++;
|
|
379
|
+
i++;
|
|
380
|
+
} else if (ch === "}") {
|
|
381
|
+
if (top.interpolation && top.braces === 0) {
|
|
382
|
+
mask(i, i + 1);
|
|
383
|
+
frames.pop();
|
|
384
|
+
} else if (top.braces > 0) top.braces--;
|
|
385
|
+
i++;
|
|
386
|
+
} else i++;
|
|
387
|
+
}
|
|
388
|
+
return {
|
|
389
|
+
code: out.join(""),
|
|
390
|
+
stringRanges
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
/** True when `index` lies in one of the sorted `[start, end)` pairs of `ranges`. */
|
|
394
|
+
function isInRanges(ranges, index) {
|
|
395
|
+
if (index === void 0) return false;
|
|
396
|
+
let lo = 0;
|
|
397
|
+
let hi = ranges.length / 2 - 1;
|
|
398
|
+
while (lo <= hi) {
|
|
399
|
+
const mid = lo + hi >> 1;
|
|
400
|
+
const start = ranges[mid * 2] ?? 0;
|
|
401
|
+
const end = ranges[mid * 2 + 1] ?? 0;
|
|
402
|
+
if (index < start) hi = mid - 1;
|
|
403
|
+
else if (index >= end) lo = mid + 1;
|
|
404
|
+
else return true;
|
|
405
|
+
}
|
|
406
|
+
return false;
|
|
407
|
+
}
|
|
408
|
+
function escapeRegExp(value) {
|
|
409
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
410
|
+
}
|
|
411
|
+
exports.DEFAULT_MAX_LIFETIME_DAYS = DEFAULT_MAX_LIFETIME_DAYS;
|
|
412
|
+
exports.FlagHygieneError = FlagHygieneError;
|
|
413
|
+
exports.FlagRegistryError = FlagRegistryError;
|
|
414
|
+
exports.assertFlagHygiene = assertFlagHygiene;
|
|
415
|
+
exports.checkFlagHygiene = checkFlagHygiene;
|
|
416
|
+
exports.defineFlags = defineFlags;
|
|
417
|
+
exports.findFlagKeysInSource = findFlagKeysInSource;
|
|
418
|
+
|
|
419
|
+
//# sourceMappingURL=index.cjs.map
|