@yedoma-labs/kulyn-flags 0.0.0-stage → 1.0.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.
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Why a flag exists. Each kind has its own default maximum lifetime.
3
+ */
4
+ type FlagKind = "release" | "experiment" | "ops" | "permission";
5
+ /**
6
+ * A single entry in the flag register.
7
+ *
8
+ * Dates are ISO 8601 strings. `createdAt` and `expiresAt` accept `YYYY-MM-DD` or a
9
+ * date-time with an explicit timezone (`Z` or `+HH:MM`).
10
+ */
11
+ interface FlagDefinition {
12
+ /** Flag key, e.g. `checkout.new-ui`. Must match `/^[a-z][a-z0-9-.]{1,79}$/`. */
13
+ key: string;
14
+ /** Team or person responsible for removing the flag. Must not be blank. */
15
+ owner: string;
16
+ kind: FlagKind;
17
+ createdAt: string;
18
+ /** Date after which the flag is considered expired. */
19
+ expiresAt?: string;
20
+ description?: string;
21
+ }
22
+ /**
23
+ * Per-kind maximum lifetime in days. `null` disables the limit for that kind.
24
+ */
25
+ type MaxLifetimeDays = Readonly<Record<FlagKind, number | null>>;
26
+ /**
27
+ * Overrides for the default maximum lifetimes. Keys that are omitted keep their default.
28
+ */
29
+ type LifetimeOverrides = Partial<Record<FlagKind, number | null>>;
30
+ interface DefineFlagsOptions {
31
+ maxLifetimeDays?: LifetimeOverrides;
32
+ }
33
+ type HygieneCode = "EXPIRED" | "EXCEEDS_MAX_LIFETIME" | "UNUSED" | "UNREGISTERED_USAGE";
34
+ interface HygieneFinding {
35
+ key: string;
36
+ code: HygieneCode;
37
+ message: string;
38
+ }
39
+ interface CheckFlagHygieneOptions {
40
+ flags: readonly FlagDefinition[];
41
+ /** Reference time for expiry checks. Defaults to the current time. */
42
+ now?: Date;
43
+ /**
44
+ * Flag keys found in the code base. When omitted, no usage findings are produced.
45
+ */
46
+ usedKeys?: Iterable<string>;
47
+ /** Maximum lifetimes used for `EXCEEDS_MAX_LIFETIME`. Defaults match `defineFlags`. */
48
+ maxLifetimeDays?: LifetimeOverrides;
49
+ }
50
+ interface AssertFlagHygieneOptions {
51
+ /** Finding codes that cause a throw. Defaults to all codes. */
52
+ failOn?: readonly HygieneCode[];
53
+ }
54
+ interface FindFlagKeysOptions {
55
+ /**
56
+ * Function names whose first string argument is a flag key.
57
+ * Defaults to `isEnabled`, `getFlag`, `flag` and `evaluate`.
58
+ */
59
+ functions?: readonly string[];
60
+ }
61
+ type FlagRegistryErrorCode = "INVALID_DEFINITION" | "INVALID_KEY" | "DUPLICATE_KEY" | "EMPTY_OWNER" | "INVALID_KIND" | "INVALID_DESCRIPTION" | "INVALID_DATE" | "EXPIRY_NOT_AFTER_CREATION" | "MISSING_EXPIRY" | "EXCEEDS_MAX_LIFETIME" | "INVALID_OPTIONS";
62
+ /**
63
+ * Thrown when a flag register or its options are invalid.
64
+ */
65
+ export declare class FlagRegistryError extends Error {
66
+ readonly code: FlagRegistryErrorCode;
67
+ /** The offending flag key, when it is known. */
68
+ readonly key: string | undefined;
69
+ constructor(code: FlagRegistryErrorCode, message: string, key?: string);
70
+ }
71
+ /**
72
+ * Thrown by `assertFlagHygiene` when findings match the `failOn` codes.
73
+ */
74
+ export declare class FlagHygieneError extends Error {
75
+ readonly code: HygieneCode;
76
+ readonly findings: readonly HygieneFinding[];
77
+ constructor(findings: readonly HygieneFinding[]);
78
+ }
79
+ /**
80
+ * Checks a flag register for flags that should be cleaned up.
81
+ *
82
+ * - `EXPIRED`: `now` is at or after `expiresAt`.
83
+ * - `EXCEEDS_MAX_LIFETIME`: the flag lives longer than the configured maximum for its kind.
84
+ * This catches flags registered before a limit was lowered.
85
+ * - `UNUSED`: only when `usedKeys` is given. The key is registered but not used.
86
+ * - `UNREGISTERED_USAGE`: only when `usedKeys` is given. The key is used but not registered.
87
+ *
88
+ * Findings are sorted by key, then by code.
89
+ */
90
+ export declare function checkFlagHygiene(options: CheckFlagHygieneOptions): readonly HygieneFinding[];
91
+ /**
92
+ * Throws `FlagHygieneError` if any finding has a code listed in `failOn`.
93
+ * Defaults to all codes. Use it in tests or CI.
94
+ */
95
+ export declare function assertFlagHygiene(findings: readonly HygieneFinding[], options?: AssertFlagHygieneOptions): void;
96
+ /**
97
+ * Default maximum lifetimes. `permission` flags may live without limit.
98
+ */
99
+ export declare const DEFAULT_MAX_LIFETIME_DAYS: MaxLifetimeDays;
100
+ /**
101
+ * Validates a flag register and returns a frozen, normalized copy.
102
+ *
103
+ * Throws `FlagRegistryError` with a `code` for the first invalid entry.
104
+ * A flag kind with a finite maximum lifetime requires `expiresAt`, so that a flag
105
+ * cannot escape the limit by omitting the date.
106
+ */
107
+ export declare function defineFlags(definitions: readonly FlagDefinition[], options?: DefineFlagsOptions): readonly FlagDefinition[];
108
+ /**
109
+ * Finds flag keys in source text.
110
+ *
111
+ * Recognized forms, with single or double quotes:
112
+ * - `isEnabled("key")`, `getFlag('key')` and the other configured function names
113
+ * - `flags.key` (identifier-style keys only)
114
+ * - `flags["key"]`
115
+ *
116
+ * Text in comments, and template literals (backticks), are ignored, including `${...}`
117
+ * placeholders in template literals that are not code. Calls inside `${...}` are still scanned.
118
+ * This function does not read files. The caller passes the text.
119
+ *
120
+ * Limitations: regex literals containing quotes are not understood. A quote inside a
121
+ * regex literal can hide code on the same line.
122
+ */
123
+ export declare function findFlagKeysInSource(sourceText: string, options?: FindFlagKeysOptions): Set<string>;
124
+ export type { AssertFlagHygieneOptions, CheckFlagHygieneOptions, DefineFlagsOptions, FindFlagKeysOptions, FlagDefinition, FlagKind, FlagRegistryErrorCode, HygieneCode, HygieneFinding, LifetimeOverrides, MaxLifetimeDays };
125
+ //# sourceMappingURL=index.d.cts.map
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Why a flag exists. Each kind has its own default maximum lifetime.
3
+ */
4
+ type FlagKind = "release" | "experiment" | "ops" | "permission";
5
+ /**
6
+ * A single entry in the flag register.
7
+ *
8
+ * Dates are ISO 8601 strings. `createdAt` and `expiresAt` accept `YYYY-MM-DD` or a
9
+ * date-time with an explicit timezone (`Z` or `+HH:MM`).
10
+ */
11
+ interface FlagDefinition {
12
+ /** Flag key, e.g. `checkout.new-ui`. Must match `/^[a-z][a-z0-9-.]{1,79}$/`. */
13
+ key: string;
14
+ /** Team or person responsible for removing the flag. Must not be blank. */
15
+ owner: string;
16
+ kind: FlagKind;
17
+ createdAt: string;
18
+ /** Date after which the flag is considered expired. */
19
+ expiresAt?: string;
20
+ description?: string;
21
+ }
22
+ /**
23
+ * Per-kind maximum lifetime in days. `null` disables the limit for that kind.
24
+ */
25
+ type MaxLifetimeDays = Readonly<Record<FlagKind, number | null>>;
26
+ /**
27
+ * Overrides for the default maximum lifetimes. Keys that are omitted keep their default.
28
+ */
29
+ type LifetimeOverrides = Partial<Record<FlagKind, number | null>>;
30
+ interface DefineFlagsOptions {
31
+ maxLifetimeDays?: LifetimeOverrides;
32
+ }
33
+ type HygieneCode = "EXPIRED" | "EXCEEDS_MAX_LIFETIME" | "UNUSED" | "UNREGISTERED_USAGE";
34
+ interface HygieneFinding {
35
+ key: string;
36
+ code: HygieneCode;
37
+ message: string;
38
+ }
39
+ interface CheckFlagHygieneOptions {
40
+ flags: readonly FlagDefinition[];
41
+ /** Reference time for expiry checks. Defaults to the current time. */
42
+ now?: Date;
43
+ /**
44
+ * Flag keys found in the code base. When omitted, no usage findings are produced.
45
+ */
46
+ usedKeys?: Iterable<string>;
47
+ /** Maximum lifetimes used for `EXCEEDS_MAX_LIFETIME`. Defaults match `defineFlags`. */
48
+ maxLifetimeDays?: LifetimeOverrides;
49
+ }
50
+ interface AssertFlagHygieneOptions {
51
+ /** Finding codes that cause a throw. Defaults to all codes. */
52
+ failOn?: readonly HygieneCode[];
53
+ }
54
+ interface FindFlagKeysOptions {
55
+ /**
56
+ * Function names whose first string argument is a flag key.
57
+ * Defaults to `isEnabled`, `getFlag`, `flag` and `evaluate`.
58
+ */
59
+ functions?: readonly string[];
60
+ }
61
+ type FlagRegistryErrorCode = "INVALID_DEFINITION" | "INVALID_KEY" | "DUPLICATE_KEY" | "EMPTY_OWNER" | "INVALID_KIND" | "INVALID_DESCRIPTION" | "INVALID_DATE" | "EXPIRY_NOT_AFTER_CREATION" | "MISSING_EXPIRY" | "EXCEEDS_MAX_LIFETIME" | "INVALID_OPTIONS";
62
+ /**
63
+ * Thrown when a flag register or its options are invalid.
64
+ */
65
+ export declare class FlagRegistryError extends Error {
66
+ readonly code: FlagRegistryErrorCode;
67
+ /** The offending flag key, when it is known. */
68
+ readonly key: string | undefined;
69
+ constructor(code: FlagRegistryErrorCode, message: string, key?: string);
70
+ }
71
+ /**
72
+ * Thrown by `assertFlagHygiene` when findings match the `failOn` codes.
73
+ */
74
+ export declare class FlagHygieneError extends Error {
75
+ readonly code: HygieneCode;
76
+ readonly findings: readonly HygieneFinding[];
77
+ constructor(findings: readonly HygieneFinding[]);
78
+ }
79
+ /**
80
+ * Checks a flag register for flags that should be cleaned up.
81
+ *
82
+ * - `EXPIRED`: `now` is at or after `expiresAt`.
83
+ * - `EXCEEDS_MAX_LIFETIME`: the flag lives longer than the configured maximum for its kind.
84
+ * This catches flags registered before a limit was lowered.
85
+ * - `UNUSED`: only when `usedKeys` is given. The key is registered but not used.
86
+ * - `UNREGISTERED_USAGE`: only when `usedKeys` is given. The key is used but not registered.
87
+ *
88
+ * Findings are sorted by key, then by code.
89
+ */
90
+ export declare function checkFlagHygiene(options: CheckFlagHygieneOptions): readonly HygieneFinding[];
91
+ /**
92
+ * Throws `FlagHygieneError` if any finding has a code listed in `failOn`.
93
+ * Defaults to all codes. Use it in tests or CI.
94
+ */
95
+ export declare function assertFlagHygiene(findings: readonly HygieneFinding[], options?: AssertFlagHygieneOptions): void;
96
+ /**
97
+ * Default maximum lifetimes. `permission` flags may live without limit.
98
+ */
99
+ export declare const DEFAULT_MAX_LIFETIME_DAYS: MaxLifetimeDays;
100
+ /**
101
+ * Validates a flag register and returns a frozen, normalized copy.
102
+ *
103
+ * Throws `FlagRegistryError` with a `code` for the first invalid entry.
104
+ * A flag kind with a finite maximum lifetime requires `expiresAt`, so that a flag
105
+ * cannot escape the limit by omitting the date.
106
+ */
107
+ export declare function defineFlags(definitions: readonly FlagDefinition[], options?: DefineFlagsOptions): readonly FlagDefinition[];
108
+ /**
109
+ * Finds flag keys in source text.
110
+ *
111
+ * Recognized forms, with single or double quotes:
112
+ * - `isEnabled("key")`, `getFlag('key')` and the other configured function names
113
+ * - `flags.key` (identifier-style keys only)
114
+ * - `flags["key"]`
115
+ *
116
+ * Text in comments, and template literals (backticks), are ignored, including `${...}`
117
+ * placeholders in template literals that are not code. Calls inside `${...}` are still scanned.
118
+ * This function does not read files. The caller passes the text.
119
+ *
120
+ * Limitations: regex literals containing quotes are not understood. A quote inside a
121
+ * regex literal can hide code on the same line.
122
+ */
123
+ export declare function findFlagKeysInSource(sourceText: string, options?: FindFlagKeysOptions): Set<string>;
124
+ export type { AssertFlagHygieneOptions, CheckFlagHygieneOptions, DefineFlagsOptions, FindFlagKeysOptions, FlagDefinition, FlagKind, FlagRegistryErrorCode, HygieneCode, HygieneFinding, LifetimeOverrides, MaxLifetimeDays };
125
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,388 @@
1
+ // src/errors.ts
2
+ /**
3
+ * Thrown when a flag register or its options are invalid.
4
+ */
5
+ var FlagRegistryError = class extends Error {
6
+ code;
7
+ /** The offending flag key, when it is known. */
8
+ key;
9
+ constructor(code, message, key) {
10
+ super(message);
11
+ this.name = "FlagRegistryError";
12
+ this.code = code;
13
+ this.key = key;
14
+ }
15
+ };
16
+ /**
17
+ * Thrown by `assertFlagHygiene` when findings match the `failOn` codes.
18
+ */
19
+ var FlagHygieneError = class extends Error {
20
+ code;
21
+ findings;
22
+ constructor(findings) {
23
+ const lines = findings.map((finding) => ` - [${finding.code}] ${finding.message}`);
24
+ super(`Flag hygiene check failed with ${findings.length} finding(s):\n${lines.join("\n")}`);
25
+ this.name = "FlagHygieneError";
26
+ this.code = findings[0]?.code ?? "UNUSED";
27
+ this.findings = findings;
28
+ }
29
+ };
30
+ // src/dates.ts
31
+ const DAY_MS = 864e5;
32
+ /**
33
+ * `YYYY-MM-DD`, optionally followed by `T HH:MM[:SS[.fraction]]` and a mandatory timezone.
34
+ * Only fixed quantifiers and a single optional group, so matching is linear.
35
+ */
36
+ const ISO_PATTERN = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,9}))?)?(Z|[+-]\d{2}:\d{2}))?$/;
37
+ function isLeapYear(year) {
38
+ return year % 4 === 0 && year % 100 !== 0 || year % 400 === 0;
39
+ }
40
+ function daysInMonth(year, month) {
41
+ return [
42
+ 31,
43
+ isLeapYear(year) ? 29 : 28,
44
+ 31,
45
+ 30,
46
+ 31,
47
+ 30,
48
+ 31,
49
+ 31,
50
+ 30,
51
+ 31,
52
+ 30,
53
+ 31
54
+ ][month - 1] ?? 0;
55
+ }
56
+ /**
57
+ * Parses a strict ISO 8601 date or date-time and returns epoch milliseconds.
58
+ * Returns `undefined` for anything that is not a real calendar date and time.
59
+ */
60
+ function parseIsoDate(value) {
61
+ if (typeof value !== "string") return;
62
+ const match = ISO_PATTERN.exec(value);
63
+ if (match === null) return;
64
+ const year = Number(match[1]);
65
+ const month = Number(match[2]);
66
+ const day = Number(match[3]);
67
+ if (month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) return;
68
+ if (match[4] !== void 0) {
69
+ const hours = Number(match[4]);
70
+ const minutes = Number(match[5]);
71
+ const seconds = match[6] === void 0 ? 0 : Number(match[6]);
72
+ if (hours > 23 || minutes > 59 || seconds > 59) return;
73
+ const offset = match[8];
74
+ if (offset !== void 0 && offset !== "Z") {
75
+ const offsetHours = Number(offset.slice(1, 3));
76
+ const offsetMinutes = Number(offset.slice(4, 6));
77
+ if (offsetHours > 23 || offsetMinutes > 59) return;
78
+ }
79
+ }
80
+ const time = Date.parse(value);
81
+ return Number.isNaN(time) ? void 0 : time;
82
+ }
83
+ // src/lifetimes.ts
84
+ const FLAG_KINDS = [
85
+ "release",
86
+ "experiment",
87
+ "ops",
88
+ "permission"
89
+ ];
90
+ /**
91
+ * Default maximum lifetimes. `permission` flags may live without limit.
92
+ */
93
+ const DEFAULT_MAX_LIFETIME_DAYS = Object.freeze({
94
+ release: 90,
95
+ experiment: 30,
96
+ ops: 365,
97
+ permission: null
98
+ });
99
+ function isFlagKind(value) {
100
+ return typeof value === "string" && FLAG_KINDS.includes(value);
101
+ }
102
+ /**
103
+ * Merges user overrides into the defaults and validates them.
104
+ */
105
+ function resolveMaxLifetimeDays(overrides) {
106
+ const resolved = { ...DEFAULT_MAX_LIFETIME_DAYS };
107
+ if (overrides === void 0) return resolved;
108
+ if (typeof overrides !== "object" || overrides === null) throw new FlagRegistryError("INVALID_OPTIONS", "maxLifetimeDays must be an object");
109
+ for (const kind of Object.keys(overrides)) {
110
+ if (!isFlagKind(kind)) throw new FlagRegistryError("INVALID_OPTIONS", `Unknown flag kind in maxLifetimeDays: "${kind}"`);
111
+ const value = overrides[kind];
112
+ if (value === void 0) continue;
113
+ 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)}`);
114
+ resolved[kind] = value;
115
+ }
116
+ return resolved;
117
+ }
118
+ // src/hygiene.ts
119
+ /** Order of codes in the result when two findings share a key. */
120
+ const CODE_ORDER = [
121
+ "EXPIRED",
122
+ "EXCEEDS_MAX_LIFETIME",
123
+ "UNUSED",
124
+ "UNREGISTERED_USAGE"
125
+ ];
126
+ /**
127
+ * Checks a flag register for flags that should be cleaned up.
128
+ *
129
+ * - `EXPIRED`: `now` is at or after `expiresAt`.
130
+ * - `EXCEEDS_MAX_LIFETIME`: the flag lives longer than the configured maximum for its kind.
131
+ * This catches flags registered before a limit was lowered.
132
+ * - `UNUSED`: only when `usedKeys` is given. The key is registered but not used.
133
+ * - `UNREGISTERED_USAGE`: only when `usedKeys` is given. The key is used but not registered.
134
+ *
135
+ * Findings are sorted by key, then by code.
136
+ */
137
+ function checkFlagHygiene(options) {
138
+ const nowMs = resolveNow(options.now);
139
+ const lifetimes = resolveMaxLifetimeDays(options.maxLifetimeDays);
140
+ const registered = /* @__PURE__ */ new Set();
141
+ const findings = [];
142
+ for (const flag of options.flags) {
143
+ if (registered.has(flag.key)) throw new FlagRegistryError("DUPLICATE_KEY", `Duplicate flag key "${flag.key}"`, flag.key);
144
+ registered.add(flag.key);
145
+ collectLifecycleFindings(flag, nowMs, lifetimes[flag.kind], findings);
146
+ }
147
+ if (options.usedKeys !== void 0) {
148
+ const used = new Set(options.usedKeys);
149
+ for (const key of registered) if (!used.has(key)) findings.push({
150
+ key,
151
+ code: "UNUSED",
152
+ message: `Flag "${key}" is registered but not used`
153
+ });
154
+ for (const key of used) if (!registered.has(key)) findings.push({
155
+ key,
156
+ code: "UNREGISTERED_USAGE",
157
+ message: `Flag "${key}" is used but not registered`
158
+ });
159
+ }
160
+ return findings.sort((a, b) => compareStrings(a.key, b.key) || CODE_ORDER.indexOf(a.code) - CODE_ORDER.indexOf(b.code));
161
+ }
162
+ /**
163
+ * Throws `FlagHygieneError` if any finding has a code listed in `failOn`.
164
+ * Defaults to all codes. Use it in tests or CI.
165
+ */
166
+ function assertFlagHygiene(findings, options) {
167
+ const failOn = new Set(options?.failOn ?? CODE_ORDER);
168
+ const failing = findings.filter((finding) => failOn.has(finding.code));
169
+ if (failing.length > 0) throw new FlagHygieneError(failing);
170
+ }
171
+ function collectLifecycleFindings(flag, nowMs, maxDays, findings) {
172
+ if (!isFlagKind(flag.kind)) throw new FlagRegistryError("INVALID_KIND", `Flag "${flag.key}" has invalid kind`, flag.key);
173
+ const createdMs = parseIsoDate(flag.createdAt);
174
+ if (createdMs === void 0) throw new FlagRegistryError("INVALID_DATE", `Flag "${flag.key}" has invalid createdAt`, flag.key);
175
+ if (flag.expiresAt === void 0) return;
176
+ const expiresMs = parseIsoDate(flag.expiresAt);
177
+ if (expiresMs === void 0) throw new FlagRegistryError("INVALID_DATE", `Flag "${flag.key}" has invalid expiresAt`, flag.key);
178
+ if (nowMs >= expiresMs) findings.push({
179
+ key: flag.key,
180
+ code: "EXPIRED",
181
+ message: `Flag "${flag.key}" expired at ${flag.expiresAt}`
182
+ });
183
+ if (maxDays !== null && maxDays !== void 0) {
184
+ const lifetimeMs = expiresMs - createdMs;
185
+ if (lifetimeMs > maxDays * 864e5) findings.push({
186
+ key: flag.key,
187
+ code: "EXCEEDS_MAX_LIFETIME",
188
+ message: `Flag "${flag.key}" (${flag.kind}) lives ${lifetimeMs / DAY_MS} days, the maximum is ${maxDays}`
189
+ });
190
+ }
191
+ }
192
+ function resolveNow(now) {
193
+ if (now === void 0) return Date.now();
194
+ const time = now.getTime();
195
+ if (Number.isNaN(time)) throw new FlagRegistryError("INVALID_OPTIONS", "now is an invalid Date");
196
+ return time;
197
+ }
198
+ function compareStrings(a, b) {
199
+ if (a === b) return 0;
200
+ return a < b ? -1 : 1;
201
+ }
202
+ // src/registry.ts
203
+ const KEY_PATTERN = /^[a-z][a-z0-9-.]{1,79}$/;
204
+ /**
205
+ * Validates a flag register and returns a frozen, normalized copy.
206
+ *
207
+ * Throws `FlagRegistryError` with a `code` for the first invalid entry.
208
+ * A flag kind with a finite maximum lifetime requires `expiresAt`, so that a flag
209
+ * cannot escape the limit by omitting the date.
210
+ */
211
+ function defineFlags(definitions, options) {
212
+ if (!Array.isArray(definitions)) throw new FlagRegistryError("INVALID_DEFINITION", "defineFlags expects an array of flags");
213
+ const maxLifetimeDays = resolveMaxLifetimeDays(options?.maxLifetimeDays);
214
+ const seen = /* @__PURE__ */ new Set();
215
+ const result = [];
216
+ for (const [index, raw] of definitions.entries()) {
217
+ const flag = validateDefinition(raw, index, maxLifetimeDays);
218
+ if (seen.has(flag.key)) throw new FlagRegistryError("DUPLICATE_KEY", `Duplicate flag key "${flag.key}"`, flag.key);
219
+ seen.add(flag.key);
220
+ result.push(flag);
221
+ }
222
+ return Object.freeze(result);
223
+ }
224
+ function validateDefinition(raw, index, maxLifetimeDays) {
225
+ if (typeof raw !== "object" || raw === null) throw new FlagRegistryError("INVALID_DEFINITION", `Flag at index ${index} is not an object`);
226
+ const record = raw;
227
+ const key = record.key;
228
+ 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);
229
+ const owner = record.owner;
230
+ if (typeof owner !== "string" || owner.trim() === "") throw new FlagRegistryError("EMPTY_OWNER", `Flag "${key}" has no owner`, key);
231
+ const kind = record.kind;
232
+ if (!isFlagKind(kind)) throw new FlagRegistryError("INVALID_KIND", `Flag "${key}" has invalid kind ${JSON.stringify(kind)}`, key);
233
+ const createdAt = record.createdAt;
234
+ const createdMs = parseIsoDate(createdAt);
235
+ if (createdMs === void 0) throw new FlagRegistryError("INVALID_DATE", `Flag "${key}" has invalid createdAt ${JSON.stringify(createdAt)}`, key);
236
+ const expiresAt = record.expiresAt;
237
+ let expiresMs;
238
+ if (expiresAt !== void 0) {
239
+ expiresMs = parseIsoDate(expiresAt);
240
+ if (expiresMs === void 0) throw new FlagRegistryError("INVALID_DATE", `Flag "${key}" has invalid expiresAt ${JSON.stringify(expiresAt)}`, key);
241
+ if (expiresMs <= createdMs) throw new FlagRegistryError("EXPIRY_NOT_AFTER_CREATION", `Flag "${key}" expires at ${String(expiresAt)}, which is not after createdAt`, key);
242
+ }
243
+ const maxDays = maxLifetimeDays[kind];
244
+ if (maxDays !== null) {
245
+ if (expiresMs === void 0) throw new FlagRegistryError("MISSING_EXPIRY", `Flag "${key}" (${kind}) needs an expiresAt, the maximum lifetime is ${maxDays} days`, key);
246
+ if (expiresMs - createdMs > maxDays * 864e5) throw new FlagRegistryError("EXCEEDS_MAX_LIFETIME", `Flag "${key}" (${kind}) lives longer than the maximum of ${maxDays} days`, key);
247
+ }
248
+ const description = record.description;
249
+ if (description !== void 0 && typeof description !== "string") throw new FlagRegistryError("INVALID_DESCRIPTION", `Flag "${key}" has a non-string description`, key);
250
+ const flag = {
251
+ key,
252
+ owner,
253
+ kind,
254
+ createdAt
255
+ };
256
+ if (expiresAt !== void 0) flag.expiresAt = expiresAt;
257
+ if (description !== void 0) flag.description = description;
258
+ return flag;
259
+ }
260
+ // src/scan.ts
261
+ const DEFAULT_FUNCTIONS = [
262
+ "isEnabled",
263
+ "getFlag",
264
+ "flag",
265
+ "evaluate"
266
+ ];
267
+ /** Upper bound for a key literal. Longer literals are ignored. */
268
+ const MAX_LITERAL_LENGTH = 100;
269
+ /**
270
+ * Finds flag keys in source text.
271
+ *
272
+ * Recognized forms, with single or double quotes:
273
+ * - `isEnabled("key")`, `getFlag('key')` and the other configured function names
274
+ * - `flags.key` (identifier-style keys only)
275
+ * - `flags["key"]`
276
+ *
277
+ * Text in comments, and template literals (backticks), are ignored, including `${...}`
278
+ * placeholders in template literals that are not code. Calls inside `${...}` are still scanned.
279
+ * This function does not read files. The caller passes the text.
280
+ *
281
+ * Limitations: regex literals containing quotes are not understood. A quote inside a
282
+ * regex literal can hide code on the same line.
283
+ */
284
+ function findFlagKeysInSource(sourceText, options) {
285
+ const functions = options?.functions ?? DEFAULT_FUNCTIONS;
286
+ const code = maskNonCode(sourceText);
287
+ const keys = /* @__PURE__ */ new Set();
288
+ if (functions.length > 0) {
289
+ const alternation = functions.map(escapeRegExp).join("|");
290
+ const callPattern = new RegExp(String.raw`\b(?:${alternation})\s*\(\s*(["'])([^"'\\\r\n]{1,${MAX_LITERAL_LENGTH}})\1(?=\s*[,)])`, "g");
291
+ for (const match of code.matchAll(callPattern)) {
292
+ const key = match[2];
293
+ if (key !== void 0) keys.add(key);
294
+ }
295
+ }
296
+ for (const match of code.matchAll(/\bflags\.([a-z][a-z0-9]*)(?![\w.-]|\s*\()/g)) {
297
+ const key = match[1];
298
+ if (key !== void 0) keys.add(key);
299
+ }
300
+ const bracketPattern = new RegExp(String.raw`\bflags\s*\[\s*(["'])([^"'\\\r\n]{1,${MAX_LITERAL_LENGTH}})\1\s*\]`, "g");
301
+ for (const match of code.matchAll(bracketPattern)) {
302
+ const key = match[2];
303
+ if (key !== void 0) keys.add(key);
304
+ }
305
+ return keys;
306
+ }
307
+ /**
308
+ * Returns `source` with comments and template-literal text replaced by spaces.
309
+ * Newlines are kept, so the output has the same length and line structure.
310
+ * Single- and double-quoted strings are kept, because keys are read from them.
311
+ * Runs in linear time: each character is visited a constant number of times.
312
+ */
313
+ function maskNonCode(source) {
314
+ const out = source.split("");
315
+ const n = source.length;
316
+ const frames = [{
317
+ kind: "code",
318
+ braces: 0,
319
+ interpolation: false
320
+ }];
321
+ const mask = (from, to) => {
322
+ for (let k = from; k < to; k++) if (out[k] !== "\n" && out[k] !== "\r") out[k] = " ";
323
+ };
324
+ let i = 0;
325
+ while (i < n) {
326
+ const top = frames[frames.length - 1];
327
+ if (top === void 0) break;
328
+ const ch = source[i];
329
+ const next = source[i + 1];
330
+ if (top.kind === "template") {
331
+ if (ch === "\\") {
332
+ mask(i, i + 2);
333
+ i += 2;
334
+ } else if (ch === "`") {
335
+ mask(i, i + 1);
336
+ frames.pop();
337
+ i++;
338
+ } else if (ch === "$" && next === "{") {
339
+ mask(i, i + 2);
340
+ frames.push({
341
+ kind: "code",
342
+ braces: 0,
343
+ interpolation: true
344
+ });
345
+ i += 2;
346
+ } else {
347
+ mask(i, i + 1);
348
+ i++;
349
+ }
350
+ continue;
351
+ }
352
+ if (ch === "/" && next === "/") {
353
+ const newline = source.indexOf("\n", i);
354
+ const end = newline === -1 ? n : newline;
355
+ mask(i, end);
356
+ i = end;
357
+ } else if (ch === "/" && next === "*") {
358
+ const close = source.indexOf("*/", i + 2);
359
+ const end = close === -1 ? n : close + 2;
360
+ mask(i, end);
361
+ i = end;
362
+ } else if (ch === "\"" || ch === "'") {
363
+ let j = i + 1;
364
+ while (j < n && source[j] !== ch && source[j] !== "\n") j += source[j] === "\\" ? 2 : 1;
365
+ i = source[j] === ch ? j + 1 : j;
366
+ } else if (ch === "`") {
367
+ mask(i, i + 1);
368
+ frames.push({ kind: "template" });
369
+ i++;
370
+ } else if (ch === "{") {
371
+ top.braces++;
372
+ i++;
373
+ } else if (ch === "}") {
374
+ if (top.interpolation && top.braces === 0) {
375
+ mask(i, i + 1);
376
+ frames.pop();
377
+ } else if (top.braces > 0) top.braces--;
378
+ i++;
379
+ } else i++;
380
+ }
381
+ return out.join("");
382
+ }
383
+ function escapeRegExp(value) {
384
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
385
+ }
386
+ export { DEFAULT_MAX_LIFETIME_DAYS, FlagHygieneError, FlagRegistryError, assertFlagHygiene, checkFlagHygiene, defineFlags, findFlagKeysInSource };
387
+
388
+ //# sourceMappingURL=index.js.map