@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.
- package/LICENSE +21 -0
- package/README.md +169 -2
- package/dist/index.cjs +395 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +125 -0
- package/dist/index.d.ts +125 -0
- package/dist/index.js +388 -0
- package/dist/index.js.map +1 -0
- package/package.json +83 -5
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,170 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @yedoma-labs/kulyn-flags
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A flag register with owners and expiry dates, plus hygiene checks you run in CI.
|
|
4
|
+
It answers the question that runtime flag SDKs leave open: **who owns this flag, and is it safe to delete?**
|
|
5
|
+
|
|
6
|
+
## The problem
|
|
7
|
+
|
|
8
|
+
Teams add feature flags and never remove them. After a year nobody knows:
|
|
9
|
+
|
|
10
|
+
- who owns a flag,
|
|
11
|
+
- whether the code still reads it,
|
|
12
|
+
- whether it can be deleted without changing behavior.
|
|
13
|
+
|
|
14
|
+
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.
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
pnpm add -D @yedoma-labs/kulyn-flags
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Requires Node.js 20 or newer. It has no runtime dependencies.
|
|
23
|
+
|
|
24
|
+
## Quickstart
|
|
25
|
+
|
|
26
|
+
### 1. Register your flags
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
// flags.register.ts
|
|
30
|
+
import { defineFlags } from "@yedoma-labs/kulyn-flags";
|
|
31
|
+
|
|
32
|
+
export const flags = defineFlags([
|
|
33
|
+
{
|
|
34
|
+
key: "checkout.new-ui",
|
|
35
|
+
owner: "team-checkout",
|
|
36
|
+
kind: "release",
|
|
37
|
+
createdAt: "2026-01-12",
|
|
38
|
+
expiresAt: "2026-03-31",
|
|
39
|
+
description: "Replaces the legacy checkout page",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
key: "ops.maintenance-banner",
|
|
43
|
+
owner: "platform",
|
|
44
|
+
kind: "ops",
|
|
45
|
+
createdAt: "2026-02-01",
|
|
46
|
+
expiresAt: "2027-02-01",
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
key: "perm.beta-admin",
|
|
50
|
+
owner: "platform",
|
|
51
|
+
kind: "permission",
|
|
52
|
+
createdAt: "2025-11-03",
|
|
53
|
+
},
|
|
54
|
+
]);
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`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.
|
|
58
|
+
|
|
59
|
+
### 2. Add a CI test
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
// tests/flag-hygiene.test.ts
|
|
63
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
64
|
+
import { join } from "node:path";
|
|
65
|
+
import { describe, it } from "vitest";
|
|
66
|
+
import { assertFlagHygiene, checkFlagHygiene, findFlagKeysInSource } from "@yedoma-labs/kulyn-flags";
|
|
67
|
+
import { flags } from "../flags.register";
|
|
68
|
+
|
|
69
|
+
function readSources(dir: string): string {
|
|
70
|
+
return readdirSync(dir, { recursive: true })
|
|
71
|
+
.map(String)
|
|
72
|
+
.filter((name) => name.endsWith(".ts") || name.endsWith(".tsx"))
|
|
73
|
+
.map((name) => readFileSync(join(dir, name), "utf8"))
|
|
74
|
+
.join("\n");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe("feature flag hygiene", () => {
|
|
78
|
+
it("has no expired, overlong or unused flags, and no unregistered usage", () => {
|
|
79
|
+
const usedKeys = findFlagKeysInSource(readSources("src"));
|
|
80
|
+
const findings = checkFlagHygiene({ flags, usedKeys });
|
|
81
|
+
assertFlagHygiene(findings);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`assertFlagHygiene` throws a `FlagHygieneError` that lists every finding. Pass `failOn` to fail only on some codes:
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
assertFlagHygiene(findings, { failOn: ["EXPIRED", "UNREGISTERED_USAGE"] });
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Lifecycle rules
|
|
93
|
+
|
|
94
|
+
### Register
|
|
95
|
+
|
|
96
|
+
| Field | Rule |
|
|
97
|
+
| ------------- | ------------------------------------------------------------------------------------- |
|
|
98
|
+
| `key` | Matches `/^[a-z][a-z0-9-.]{1,79}$/`. Keys are unique. |
|
|
99
|
+
| `owner` | Must not be blank. |
|
|
100
|
+
| `kind` | `release`, `experiment`, `ops` or `permission`. |
|
|
101
|
+
| `createdAt` | ISO date (`2026-01-12`) or date-time with a timezone (`2026-01-12T10:00:00Z`). |
|
|
102
|
+
| `expiresAt` | Same format. Must be after `createdAt`. |
|
|
103
|
+
| `description` | Optional string. |
|
|
104
|
+
|
|
105
|
+
Default maximum lifetimes:
|
|
106
|
+
|
|
107
|
+
| Kind | Max lifetime | `expiresAt` required |
|
|
108
|
+
| ------------ | ------------ | -------------------- |
|
|
109
|
+
| `release` | 90 days | yes |
|
|
110
|
+
| `experiment` | 30 days | yes |
|
|
111
|
+
| `ops` | 365 days | yes |
|
|
112
|
+
| `permission` | no limit | no |
|
|
113
|
+
|
|
114
|
+
A kind with a finite limit requires `expiresAt`. Otherwise a flag could avoid the limit by omitting the date.
|
|
115
|
+
Lifetimes are measured from `createdAt` to `expiresAt`. A lifetime equal to the maximum is allowed.
|
|
116
|
+
|
|
117
|
+
Override the limits per kind. Use `null` to remove a limit:
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
defineFlags(flags, {
|
|
121
|
+
maxLifetimeDays: { release: 120, experiment: null },
|
|
122
|
+
});
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Hygiene findings
|
|
126
|
+
|
|
127
|
+
| Code | Meaning |
|
|
128
|
+
| ----------------------- | ---------------------------------------------------------------------------------------- |
|
|
129
|
+
| `EXPIRED` | `now` is at or after `expiresAt`. The expiry instant itself counts as expired. |
|
|
130
|
+
| `EXCEEDS_MAX_LIFETIME` | The lifetime is longer than the current limit for its kind. Catches flags registered before a limit was lowered. |
|
|
131
|
+
| `UNUSED` | The flag is registered but its key was not found in the code. Only with `usedKeys`. |
|
|
132
|
+
| `UNREGISTERED_USAGE` | A key is used in the code but not registered. Only with `usedKeys`. |
|
|
133
|
+
|
|
134
|
+
`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.
|
|
135
|
+
|
|
136
|
+
## API
|
|
137
|
+
|
|
138
|
+
- `defineFlags(flags, options?)` validates a register and returns a frozen copy.
|
|
139
|
+
- `checkFlagHygiene({ flags, now?, usedKeys?, maxLifetimeDays? })` returns an array of findings.
|
|
140
|
+
- `assertFlagHygiene(findings, { failOn? })` throws `FlagHygieneError` when a finding matches `failOn`. The default is all codes.
|
|
141
|
+
- `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.
|
|
142
|
+
- Errors: `FlagRegistryError` (with `code` and `key`) and `FlagHygieneError` (with `code` and `findings`).
|
|
143
|
+
|
|
144
|
+
## How the source scan works
|
|
145
|
+
|
|
146
|
+
`findFlagKeysInSource` recognizes:
|
|
147
|
+
|
|
148
|
+
- calls with a string literal as the first argument: `isEnabled("key")`, `getFlag('key')`, `client.isEnabled("key", { fallback: false })`
|
|
149
|
+
- property access: `flags.beta`, `flags["new-ui"]`
|
|
150
|
+
|
|
151
|
+
It ignores text in comments and template literals (`` `isEnabled("x")` `` is not a call), but 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.
|
|
152
|
+
|
|
153
|
+
The scan runs in linear time and does not use nested quantifiers. Long inputs stay fast.
|
|
154
|
+
|
|
155
|
+
## Limitations
|
|
156
|
+
|
|
157
|
+
- 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`.
|
|
158
|
+
- Regex literals that contain quote characters are not understood. A quote inside a regex can hide code on the same line.
|
|
159
|
+
- Dates are checked against the calendar. Date-times need an explicit timezone.
|
|
160
|
+
- The library checks the register. It does not know which flags are on or off in production.
|
|
161
|
+
|
|
162
|
+
## Not a runtime SDK
|
|
163
|
+
|
|
164
|
+
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/).
|
|
165
|
+
|
|
166
|
+
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.
|
|
167
|
+
|
|
168
|
+
## License
|
|
169
|
+
|
|
170
|
+
MIT. See [LICENSE](LICENSE).
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
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
|
+
* This function does not read files. The caller passes the text.
|
|
281
|
+
*
|
|
282
|
+
* Limitations: regex literals containing quotes are not understood. A quote inside a
|
|
283
|
+
* regex literal can hide code on the same line.
|
|
284
|
+
*/
|
|
285
|
+
function findFlagKeysInSource(sourceText, options) {
|
|
286
|
+
const functions = options?.functions ?? DEFAULT_FUNCTIONS;
|
|
287
|
+
const code = maskNonCode(sourceText);
|
|
288
|
+
const keys = /* @__PURE__ */ new Set();
|
|
289
|
+
if (functions.length > 0) {
|
|
290
|
+
const alternation = functions.map(escapeRegExp).join("|");
|
|
291
|
+
const callPattern = new RegExp(String.raw`\b(?:${alternation})\s*\(\s*(["'])([^"'\\\r\n]{1,${MAX_LITERAL_LENGTH}})\1(?=\s*[,)])`, "g");
|
|
292
|
+
for (const match of code.matchAll(callPattern)) {
|
|
293
|
+
const key = match[2];
|
|
294
|
+
if (key !== void 0) keys.add(key);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
for (const match of code.matchAll(/\bflags\.([a-z][a-z0-9]*)(?![\w.-]|\s*\()/g)) {
|
|
298
|
+
const key = match[1];
|
|
299
|
+
if (key !== void 0) keys.add(key);
|
|
300
|
+
}
|
|
301
|
+
const bracketPattern = new RegExp(String.raw`\bflags\s*\[\s*(["'])([^"'\\\r\n]{1,${MAX_LITERAL_LENGTH}})\1\s*\]`, "g");
|
|
302
|
+
for (const match of code.matchAll(bracketPattern)) {
|
|
303
|
+
const key = match[2];
|
|
304
|
+
if (key !== void 0) keys.add(key);
|
|
305
|
+
}
|
|
306
|
+
return keys;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Returns `source` with comments and template-literal text replaced by spaces.
|
|
310
|
+
* Newlines are kept, so the output has the same length and line structure.
|
|
311
|
+
* Single- and double-quoted strings are kept, because keys are read from them.
|
|
312
|
+
* Runs in linear time: each character is visited a constant number of times.
|
|
313
|
+
*/
|
|
314
|
+
function maskNonCode(source) {
|
|
315
|
+
const out = source.split("");
|
|
316
|
+
const n = source.length;
|
|
317
|
+
const frames = [{
|
|
318
|
+
kind: "code",
|
|
319
|
+
braces: 0,
|
|
320
|
+
interpolation: false
|
|
321
|
+
}];
|
|
322
|
+
const mask = (from, to) => {
|
|
323
|
+
for (let k = from; k < to; k++) if (out[k] !== "\n" && out[k] !== "\r") out[k] = " ";
|
|
324
|
+
};
|
|
325
|
+
let i = 0;
|
|
326
|
+
while (i < n) {
|
|
327
|
+
const top = frames[frames.length - 1];
|
|
328
|
+
if (top === void 0) break;
|
|
329
|
+
const ch = source[i];
|
|
330
|
+
const next = source[i + 1];
|
|
331
|
+
if (top.kind === "template") {
|
|
332
|
+
if (ch === "\\") {
|
|
333
|
+
mask(i, i + 2);
|
|
334
|
+
i += 2;
|
|
335
|
+
} else if (ch === "`") {
|
|
336
|
+
mask(i, i + 1);
|
|
337
|
+
frames.pop();
|
|
338
|
+
i++;
|
|
339
|
+
} else if (ch === "$" && next === "{") {
|
|
340
|
+
mask(i, i + 2);
|
|
341
|
+
frames.push({
|
|
342
|
+
kind: "code",
|
|
343
|
+
braces: 0,
|
|
344
|
+
interpolation: true
|
|
345
|
+
});
|
|
346
|
+
i += 2;
|
|
347
|
+
} else {
|
|
348
|
+
mask(i, i + 1);
|
|
349
|
+
i++;
|
|
350
|
+
}
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
if (ch === "/" && next === "/") {
|
|
354
|
+
const newline = source.indexOf("\n", i);
|
|
355
|
+
const end = newline === -1 ? n : newline;
|
|
356
|
+
mask(i, end);
|
|
357
|
+
i = end;
|
|
358
|
+
} else if (ch === "/" && next === "*") {
|
|
359
|
+
const close = source.indexOf("*/", i + 2);
|
|
360
|
+
const end = close === -1 ? n : close + 2;
|
|
361
|
+
mask(i, end);
|
|
362
|
+
i = end;
|
|
363
|
+
} else if (ch === "\"" || ch === "'") {
|
|
364
|
+
let j = i + 1;
|
|
365
|
+
while (j < n && source[j] !== ch && source[j] !== "\n") j += source[j] === "\\" ? 2 : 1;
|
|
366
|
+
i = source[j] === ch ? j + 1 : j;
|
|
367
|
+
} else if (ch === "`") {
|
|
368
|
+
mask(i, i + 1);
|
|
369
|
+
frames.push({ kind: "template" });
|
|
370
|
+
i++;
|
|
371
|
+
} else if (ch === "{") {
|
|
372
|
+
top.braces++;
|
|
373
|
+
i++;
|
|
374
|
+
} else if (ch === "}") {
|
|
375
|
+
if (top.interpolation && top.braces === 0) {
|
|
376
|
+
mask(i, i + 1);
|
|
377
|
+
frames.pop();
|
|
378
|
+
} else if (top.braces > 0) top.braces--;
|
|
379
|
+
i++;
|
|
380
|
+
} else i++;
|
|
381
|
+
}
|
|
382
|
+
return out.join("");
|
|
383
|
+
}
|
|
384
|
+
function escapeRegExp(value) {
|
|
385
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
386
|
+
}
|
|
387
|
+
exports.DEFAULT_MAX_LIFETIME_DAYS = DEFAULT_MAX_LIFETIME_DAYS;
|
|
388
|
+
exports.FlagHygieneError = FlagHygieneError;
|
|
389
|
+
exports.FlagRegistryError = FlagRegistryError;
|
|
390
|
+
exports.assertFlagHygiene = assertFlagHygiene;
|
|
391
|
+
exports.checkFlagHygiene = checkFlagHygiene;
|
|
392
|
+
exports.defineFlags = defineFlags;
|
|
393
|
+
exports.findFlagKeysInSource = findFlagKeysInSource;
|
|
394
|
+
|
|
395
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.cjs","names":[],"sources":["../src/errors.ts","../src/dates.ts","../src/lifetimes.ts","../src/hygiene.ts","../src/registry.ts","../src/scan.ts"],"sourcesContent":["import type { HygieneCode, HygieneFinding } from \"./types\";\n\nexport type FlagRegistryErrorCode =\n\t| \"INVALID_DEFINITION\"\n\t| \"INVALID_KEY\"\n\t| \"DUPLICATE_KEY\"\n\t| \"EMPTY_OWNER\"\n\t| \"INVALID_KIND\"\n\t| \"INVALID_DESCRIPTION\"\n\t| \"INVALID_DATE\"\n\t| \"EXPIRY_NOT_AFTER_CREATION\"\n\t| \"MISSING_EXPIRY\"\n\t| \"EXCEEDS_MAX_LIFETIME\"\n\t| \"INVALID_OPTIONS\";\n\n/**\n * Thrown when a flag register or its options are invalid.\n */\nexport class FlagRegistryError extends Error {\n\treadonly code: FlagRegistryErrorCode;\n\t/** The offending flag key, when it is known. */\n\treadonly key: string | undefined;\n\n\tconstructor(code: FlagRegistryErrorCode, message: string, key?: string) {\n\t\tsuper(message);\n\t\tthis.name = \"FlagRegistryError\";\n\t\tthis.code = code;\n\t\tthis.key = key;\n\t}\n}\n\n/**\n * Thrown by `assertFlagHygiene` when findings match the `failOn` codes.\n */\nexport class FlagHygieneError extends Error {\n\treadonly code: HygieneCode;\n\treadonly findings: readonly HygieneFinding[];\n\n\tconstructor(findings: readonly HygieneFinding[]) {\n\t\tconst lines = findings.map((finding) => ` - [${finding.code}] ${finding.message}`);\n\t\tsuper(`Flag hygiene check failed with ${findings.length} finding(s):\\n${lines.join(\"\\n\")}`);\n\t\tthis.name = \"FlagHygieneError\";\n\t\tthis.code = findings[0]?.code ?? \"UNUSED\";\n\t\tthis.findings = findings;\n\t}\n}\n","export const DAY_MS = 86_400_000;\n\n/**\n * `YYYY-MM-DD`, optionally followed by `T HH:MM[:SS[.fraction]]` and a mandatory timezone.\n * Only fixed quantifiers and a single optional group, so matching is linear.\n */\nconst ISO_PATTERN =\n\t/^(\\d{4})-(\\d{2})-(\\d{2})(?:T(\\d{2}):(\\d{2})(?::(\\d{2})(?:\\.(\\d{1,9}))?)?(Z|[+-]\\d{2}:\\d{2}))?$/;\n\nfunction isLeapYear(year: number): boolean {\n\treturn (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;\n}\n\nfunction daysInMonth(year: number, month: number): number {\n\tconst days = [31, isLeapYear(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];\n\treturn days[month - 1] ?? 0;\n}\n\n/**\n * Parses a strict ISO 8601 date or date-time and returns epoch milliseconds.\n * Returns `undefined` for anything that is not a real calendar date and time.\n */\nexport function parseIsoDate(value: unknown): number | undefined {\n\tif (typeof value !== \"string\") {\n\t\treturn undefined;\n\t}\n\tconst match = ISO_PATTERN.exec(value);\n\tif (match === null) {\n\t\treturn undefined;\n\t}\n\n\tconst year = Number(match[1]);\n\tconst month = Number(match[2]);\n\tconst day = Number(match[3]);\n\tif (month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) {\n\t\treturn undefined;\n\t}\n\n\tif (match[4] !== undefined) {\n\t\tconst hours = Number(match[4]);\n\t\tconst minutes = Number(match[5]);\n\t\tconst seconds = match[6] === undefined ? 0 : Number(match[6]);\n\t\tif (hours > 23 || minutes > 59 || seconds > 59) {\n\t\t\treturn undefined;\n\t\t}\n\t\tconst offset = match[8];\n\t\tif (offset !== undefined && offset !== \"Z\") {\n\t\t\tconst offsetHours = Number(offset.slice(1, 3));\n\t\t\tconst offsetMinutes = Number(offset.slice(4, 6));\n\t\t\tif (offsetHours > 23 || offsetMinutes > 59) {\n\t\t\t\treturn undefined;\n\t\t\t}\n\t\t}\n\t}\n\n\tconst time = Date.parse(value);\n\treturn Number.isNaN(time) ? undefined : time;\n}\n","import { FlagRegistryError } from \"./errors\";\nimport type { FlagKind, LifetimeOverrides, MaxLifetimeDays } from \"./types\";\n\nexport const FLAG_KINDS: readonly FlagKind[] = [\"release\", \"experiment\", \"ops\", \"permission\"];\n\n/**\n * Default maximum lifetimes. `permission` flags may live without limit.\n */\nexport const DEFAULT_MAX_LIFETIME_DAYS: MaxLifetimeDays = Object.freeze({\n\trelease: 90,\n\texperiment: 30,\n\tops: 365,\n\tpermission: null,\n});\n\nexport function isFlagKind(value: unknown): value is FlagKind {\n\treturn typeof value === \"string\" && (FLAG_KINDS as readonly string[]).includes(value);\n}\n\n/**\n * Merges user overrides into the defaults and validates them.\n */\nexport function resolveMaxLifetimeDays(overrides: LifetimeOverrides | undefined): MaxLifetimeDays {\n\tconst resolved: Record<FlagKind, number | null> = { ...DEFAULT_MAX_LIFETIME_DAYS };\n\tif (overrides === undefined) {\n\t\treturn resolved;\n\t}\n\tif (typeof overrides !== \"object\" || overrides === null) {\n\t\tthrow new FlagRegistryError(\"INVALID_OPTIONS\", \"maxLifetimeDays must be an object\");\n\t}\n\n\tfor (const kind of Object.keys(overrides)) {\n\t\tif (!isFlagKind(kind)) {\n\t\t\tthrow new FlagRegistryError(\n\t\t\t\t\"INVALID_OPTIONS\",\n\t\t\t\t`Unknown flag kind in maxLifetimeDays: \"${kind}\"`,\n\t\t\t);\n\t\t}\n\t\tconst value = overrides[kind];\n\t\tif (value === undefined) {\n\t\t\tcontinue;\n\t\t}\n\t\tif (value !== null && !(Number.isInteger(value) && value > 0)) {\n\t\t\tthrow new FlagRegistryError(\n\t\t\t\t\"INVALID_OPTIONS\",\n\t\t\t\t`maxLifetimeDays.${kind} must be a positive integer or null, got ${String(value)}`,\n\t\t\t);\n\t\t}\n\t\tresolved[kind] = value;\n\t}\n\treturn resolved;\n}\n","import { DAY_MS, parseIsoDate } from \"./dates\";\nimport { FlagHygieneError, FlagRegistryError } from \"./errors\";\nimport { isFlagKind, resolveMaxLifetimeDays } from \"./lifetimes\";\nimport type {\n\tAssertFlagHygieneOptions,\n\tCheckFlagHygieneOptions,\n\tFlagDefinition,\n\tHygieneCode,\n\tHygieneFinding,\n} from \"./types\";\n\n/** Order of codes in the result when two findings share a key. */\nconst CODE_ORDER: readonly HygieneCode[] = [\n\t\"EXPIRED\",\n\t\"EXCEEDS_MAX_LIFETIME\",\n\t\"UNUSED\",\n\t\"UNREGISTERED_USAGE\",\n];\n\n/**\n * Checks a flag register for flags that should be cleaned up.\n *\n * - `EXPIRED`: `now` is at or after `expiresAt`.\n * - `EXCEEDS_MAX_LIFETIME`: the flag lives longer than the configured maximum for its kind.\n * This catches flags registered before a limit was lowered.\n * - `UNUSED`: only when `usedKeys` is given. The key is registered but not used.\n * - `UNREGISTERED_USAGE`: only when `usedKeys` is given. The key is used but not registered.\n *\n * Findings are sorted by key, then by code.\n */\nexport function checkFlagHygiene(options: CheckFlagHygieneOptions): readonly HygieneFinding[] {\n\tconst nowMs = resolveNow(options.now);\n\tconst lifetimes = resolveMaxLifetimeDays(options.maxLifetimeDays);\n\n\tconst registered = new Set<string>();\n\tconst findings: HygieneFinding[] = [];\n\n\tfor (const flag of options.flags) {\n\t\tif (registered.has(flag.key)) {\n\t\t\tthrow new FlagRegistryError(\"DUPLICATE_KEY\", `Duplicate flag key \"${flag.key}\"`, flag.key);\n\t\t}\n\t\tregistered.add(flag.key);\n\t\tcollectLifecycleFindings(flag, nowMs, lifetimes[flag.kind], findings);\n\t}\n\n\tif (options.usedKeys !== undefined) {\n\t\tconst used = new Set(options.usedKeys);\n\t\tfor (const key of registered) {\n\t\t\tif (!used.has(key)) {\n\t\t\t\tfindings.push({\n\t\t\t\t\tkey,\n\t\t\t\t\tcode: \"UNUSED\",\n\t\t\t\t\tmessage: `Flag \"${key}\" is registered but not used`,\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t\tfor (const key of used) {\n\t\t\tif (!registered.has(key)) {\n\t\t\t\tfindings.push({\n\t\t\t\t\tkey,\n\t\t\t\t\tcode: \"UNREGISTERED_USAGE\",\n\t\t\t\t\tmessage: `Flag \"${key}\" is used but not registered`,\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t}\n\n\treturn findings.sort(\n\t\t(a, b) =>\n\t\t\tcompareStrings(a.key, b.key) || CODE_ORDER.indexOf(a.code) - CODE_ORDER.indexOf(b.code),\n\t);\n}\n\n/**\n * Throws `FlagHygieneError` if any finding has a code listed in `failOn`.\n * Defaults to all codes. Use it in tests or CI.\n */\nexport function assertFlagHygiene(\n\tfindings: readonly HygieneFinding[],\n\toptions?: AssertFlagHygieneOptions,\n): void {\n\tconst failOn = new Set<HygieneCode>(options?.failOn ?? CODE_ORDER);\n\tconst failing = findings.filter((finding) => failOn.has(finding.code));\n\tif (failing.length > 0) {\n\t\tthrow new FlagHygieneError(failing);\n\t}\n}\n\nfunction collectLifecycleFindings(\n\tflag: FlagDefinition,\n\tnowMs: number,\n\tmaxDays: number | null | undefined,\n\tfindings: HygieneFinding[],\n): void {\n\tif (!isFlagKind(flag.kind)) {\n\t\tthrow new FlagRegistryError(\"INVALID_KIND\", `Flag \"${flag.key}\" has invalid kind`, flag.key);\n\t}\n\tconst createdMs = parseIsoDate(flag.createdAt);\n\tif (createdMs === undefined) {\n\t\tthrow new FlagRegistryError(\n\t\t\t\"INVALID_DATE\",\n\t\t\t`Flag \"${flag.key}\" has invalid createdAt`,\n\t\t\tflag.key,\n\t\t);\n\t}\n\tif (flag.expiresAt === undefined) {\n\t\treturn;\n\t}\n\tconst expiresMs = parseIsoDate(flag.expiresAt);\n\tif (expiresMs === undefined) {\n\t\tthrow new FlagRegistryError(\n\t\t\t\"INVALID_DATE\",\n\t\t\t`Flag \"${flag.key}\" has invalid expiresAt`,\n\t\t\tflag.key,\n\t\t);\n\t}\n\n\tif (nowMs >= expiresMs) {\n\t\tfindings.push({\n\t\t\tkey: flag.key,\n\t\t\tcode: \"EXPIRED\",\n\t\t\tmessage: `Flag \"${flag.key}\" expired at ${flag.expiresAt}`,\n\t\t});\n\t}\n\n\tif (maxDays !== null && maxDays !== undefined) {\n\t\tconst lifetimeMs = expiresMs - createdMs;\n\t\tif (lifetimeMs > maxDays * DAY_MS) {\n\t\t\tfindings.push({\n\t\t\t\tkey: flag.key,\n\t\t\t\tcode: \"EXCEEDS_MAX_LIFETIME\",\n\t\t\t\tmessage: `Flag \"${flag.key}\" (${flag.kind}) lives ${lifetimeMs / DAY_MS} days, the maximum is ${maxDays}`,\n\t\t\t});\n\t\t}\n\t}\n}\n\nfunction resolveNow(now: Date | undefined): number {\n\tif (now === undefined) {\n\t\treturn Date.now();\n\t}\n\tconst time = now.getTime();\n\tif (Number.isNaN(time)) {\n\t\tthrow new FlagRegistryError(\"INVALID_OPTIONS\", \"now is an invalid Date\");\n\t}\n\treturn time;\n}\n\nfunction compareStrings(a: string, b: string): number {\n\tif (a === b) {\n\t\treturn 0;\n\t}\n\treturn a < b ? -1 : 1;\n}\n","import { DAY_MS, parseIsoDate } from \"./dates\";\nimport { FlagRegistryError } from \"./errors\";\nimport { isFlagKind, resolveMaxLifetimeDays } from \"./lifetimes\";\nimport type { DefineFlagsOptions, FlagDefinition, MaxLifetimeDays } from \"./types\";\n\nconst KEY_PATTERN = /^[a-z][a-z0-9-.]{1,79}$/;\n\n/**\n * Validates a flag register and returns a frozen, normalized copy.\n *\n * Throws `FlagRegistryError` with a `code` for the first invalid entry.\n * A flag kind with a finite maximum lifetime requires `expiresAt`, so that a flag\n * cannot escape the limit by omitting the date.\n */\nexport function defineFlags(\n\tdefinitions: readonly FlagDefinition[],\n\toptions?: DefineFlagsOptions,\n): readonly FlagDefinition[] {\n\tif (!Array.isArray(definitions)) {\n\t\tthrow new FlagRegistryError(\"INVALID_DEFINITION\", \"defineFlags expects an array of flags\");\n\t}\n\tconst maxLifetimeDays = resolveMaxLifetimeDays(options?.maxLifetimeDays);\n\n\tconst seen = new Set<string>();\n\tconst result: FlagDefinition[] = [];\n\tfor (const [index, raw] of definitions.entries()) {\n\t\tconst flag = validateDefinition(raw, index, maxLifetimeDays);\n\t\tif (seen.has(flag.key)) {\n\t\t\tthrow new FlagRegistryError(\"DUPLICATE_KEY\", `Duplicate flag key \"${flag.key}\"`, flag.key);\n\t\t}\n\t\tseen.add(flag.key);\n\t\tresult.push(flag);\n\t}\n\treturn Object.freeze(result);\n}\n\nfunction validateDefinition(\n\traw: unknown,\n\tindex: number,\n\tmaxLifetimeDays: MaxLifetimeDays,\n): FlagDefinition {\n\tif (typeof raw !== \"object\" || raw === null) {\n\t\tthrow new FlagRegistryError(\"INVALID_DEFINITION\", `Flag at index ${index} is not an object`);\n\t}\n\tconst record = raw as Record<string, unknown>;\n\n\tconst key = record.key;\n\tif (typeof key !== \"string\" || !KEY_PATTERN.test(key)) {\n\t\tthrow new FlagRegistryError(\n\t\t\t\"INVALID_KEY\",\n\t\t\t`Invalid flag key at index ${index}: ${JSON.stringify(key)} (must match ${KEY_PATTERN.source})`,\n\t\t\ttypeof key === \"string\" ? key : undefined,\n\t\t);\n\t}\n\n\tconst owner = record.owner;\n\tif (typeof owner !== \"string\" || owner.trim() === \"\") {\n\t\tthrow new FlagRegistryError(\"EMPTY_OWNER\", `Flag \"${key}\" has no owner`, key);\n\t}\n\n\tconst kind = record.kind;\n\tif (!isFlagKind(kind)) {\n\t\tthrow new FlagRegistryError(\n\t\t\t\"INVALID_KIND\",\n\t\t\t`Flag \"${key}\" has invalid kind ${JSON.stringify(kind)}`,\n\t\t\tkey,\n\t\t);\n\t}\n\n\tconst createdAt = record.createdAt;\n\tconst createdMs = parseIsoDate(createdAt);\n\tif (createdMs === undefined) {\n\t\tthrow new FlagRegistryError(\n\t\t\t\"INVALID_DATE\",\n\t\t\t`Flag \"${key}\" has invalid createdAt ${JSON.stringify(createdAt)}`,\n\t\t\tkey,\n\t\t);\n\t}\n\n\tconst expiresAt = record.expiresAt;\n\tlet expiresMs: number | undefined;\n\tif (expiresAt !== undefined) {\n\t\texpiresMs = parseIsoDate(expiresAt);\n\t\tif (expiresMs === undefined) {\n\t\t\tthrow new FlagRegistryError(\n\t\t\t\t\"INVALID_DATE\",\n\t\t\t\t`Flag \"${key}\" has invalid expiresAt ${JSON.stringify(expiresAt)}`,\n\t\t\t\tkey,\n\t\t\t);\n\t\t}\n\t\tif (expiresMs <= createdMs) {\n\t\t\tthrow new FlagRegistryError(\n\t\t\t\t\"EXPIRY_NOT_AFTER_CREATION\",\n\t\t\t\t`Flag \"${key}\" expires at ${String(expiresAt)}, which is not after createdAt`,\n\t\t\t\tkey,\n\t\t\t);\n\t\t}\n\t}\n\n\tconst maxDays = maxLifetimeDays[kind];\n\tif (maxDays !== null) {\n\t\tif (expiresMs === undefined) {\n\t\t\tthrow new FlagRegistryError(\n\t\t\t\t\"MISSING_EXPIRY\",\n\t\t\t\t`Flag \"${key}\" (${kind}) needs an expiresAt, the maximum lifetime is ${maxDays} days`,\n\t\t\t\tkey,\n\t\t\t);\n\t\t}\n\t\tif (expiresMs - createdMs > maxDays * DAY_MS) {\n\t\t\tthrow new FlagRegistryError(\n\t\t\t\t\"EXCEEDS_MAX_LIFETIME\",\n\t\t\t\t`Flag \"${key}\" (${kind}) lives longer than the maximum of ${maxDays} days`,\n\t\t\t\tkey,\n\t\t\t);\n\t\t}\n\t}\n\n\tconst description = record.description;\n\tif (description !== undefined && typeof description !== \"string\") {\n\t\tthrow new FlagRegistryError(\n\t\t\t\"INVALID_DESCRIPTION\",\n\t\t\t`Flag \"${key}\" has a non-string description`,\n\t\t\tkey,\n\t\t);\n\t}\n\n\tconst flag: FlagDefinition = { key, owner, kind, createdAt: createdAt as string };\n\tif (expiresAt !== undefined) {\n\t\tflag.expiresAt = expiresAt as string;\n\t}\n\tif (description !== undefined) {\n\t\tflag.description = description;\n\t}\n\treturn flag;\n}\n","import type { FindFlagKeysOptions } from \"./types\";\n\nconst DEFAULT_FUNCTIONS: readonly string[] = [\"isEnabled\", \"getFlag\", \"flag\", \"evaluate\"];\n\n/** Upper bound for a key literal. Longer literals are ignored. */\nconst MAX_LITERAL_LENGTH = 100;\n\n/**\n * Finds flag keys in source text.\n *\n * Recognized forms, with single or double quotes:\n * - `isEnabled(\"key\")`, `getFlag('key')` and the other configured function names\n * - `flags.key` (identifier-style keys only)\n * - `flags[\"key\"]`\n *\n * Text in comments, and template literals (backticks), are ignored, including `${...}`\n * placeholders in template literals that are not code. Calls inside `${...}` are still scanned.\n * This function does not read files. The caller passes the text.\n *\n * Limitations: regex literals containing quotes are not understood. A quote inside a\n * regex literal can hide code on the same line.\n */\nexport function findFlagKeysInSource(\n\tsourceText: string,\n\toptions?: FindFlagKeysOptions,\n): Set<string> {\n\tconst functions = options?.functions ?? DEFAULT_FUNCTIONS;\n\tconst code = maskNonCode(sourceText);\n\tconst keys = new Set<string>();\n\n\tif (functions.length > 0) {\n\t\tconst alternation = functions.map(escapeRegExp).join(\"|\");\n\t\t// The literal must be closed by the same quote and be a whole argument, so that\n\t\t// `isEnabled(\"a\" + b)` is not read as the key \"a\".\n\t\tconst callPattern = new RegExp(\n\t\t\tString.raw`\\b(?:${alternation})\\s*\\(\\s*([\"'])([^\"'\\\\\\r\\n]{1,${MAX_LITERAL_LENGTH}})\\1(?=\\s*[,)])`,\n\t\t\t\"g\",\n\t\t);\n\t\tfor (const match of code.matchAll(callPattern)) {\n\t\t\tconst key = match[2];\n\t\t\tif (key !== undefined) {\n\t\t\t\tkeys.add(key);\n\t\t\t}\n\t\t}\n\t}\n\n\t// `flags.key` is only matched for identifier-like keys, and not for method calls like\n\t// `flags.get(` or chained access like `flags.a.b`.\n\tfor (const match of code.matchAll(/\\bflags\\.([a-z][a-z0-9]*)(?![\\w.-]|\\s*\\()/g)) {\n\t\tconst key = match[1];\n\t\tif (key !== undefined) {\n\t\t\tkeys.add(key);\n\t\t}\n\t}\n\n\tconst bracketPattern = new RegExp(\n\t\tString.raw`\\bflags\\s*\\[\\s*([\"'])([^\"'\\\\\\r\\n]{1,${MAX_LITERAL_LENGTH}})\\1\\s*\\]`,\n\t\t\"g\",\n\t);\n\tfor (const match of code.matchAll(bracketPattern)) {\n\t\tconst key = match[2];\n\t\tif (key !== undefined) {\n\t\t\tkeys.add(key);\n\t\t}\n\t}\n\n\treturn keys;\n}\n\ntype Frame = { kind: \"template\" } | { kind: \"code\"; braces: number; interpolation: boolean };\n\n/**\n * Returns `source` with comments and template-literal text replaced by spaces.\n * Newlines are kept, so the output has the same length and line structure.\n * Single- and double-quoted strings are kept, because keys are read from them.\n * Runs in linear time: each character is visited a constant number of times.\n */\nexport function maskNonCode(source: string): string {\n\tconst out = source.split(\"\");\n\tconst n = source.length;\n\tconst frames: Frame[] = [{ kind: \"code\", braces: 0, interpolation: false }];\n\n\tconst mask = (from: number, to: number): void => {\n\t\tfor (let k = from; k < to; k++) {\n\t\t\tif (out[k] !== \"\\n\" && out[k] !== \"\\r\") {\n\t\t\t\tout[k] = \" \";\n\t\t\t}\n\t\t}\n\t};\n\n\tlet i = 0;\n\twhile (i < n) {\n\t\tconst top = frames[frames.length - 1];\n\t\tif (top === undefined) {\n\t\t\tbreak;\n\t\t}\n\t\tconst ch = source[i];\n\t\tconst next = source[i + 1];\n\n\t\tif (top.kind === \"template\") {\n\t\t\tif (ch === \"\\\\\") {\n\t\t\t\tmask(i, i + 2);\n\t\t\t\ti += 2;\n\t\t\t} else if (ch === \"`\") {\n\t\t\t\tmask(i, i + 1);\n\t\t\t\tframes.pop();\n\t\t\t\ti++;\n\t\t\t} else if (ch === \"$\" && next === \"{\") {\n\t\t\t\tmask(i, i + 2);\n\t\t\t\tframes.push({ kind: \"code\", braces: 0, interpolation: true });\n\t\t\t\ti += 2;\n\t\t\t} else {\n\t\t\t\tmask(i, i + 1);\n\t\t\t\ti++;\n\t\t\t}\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (ch === \"/\" && next === \"/\") {\n\t\t\tconst newline = source.indexOf(\"\\n\", i);\n\t\t\tconst end = newline === -1 ? n : newline;\n\t\t\tmask(i, end);\n\t\t\ti = end;\n\t\t} else if (ch === \"/\" && next === \"*\") {\n\t\t\tconst close = source.indexOf(\"*/\", i + 2);\n\t\t\tconst end = close === -1 ? n : close + 2;\n\t\t\tmask(i, end);\n\t\t\ti = end;\n\t\t} else if (ch === '\"' || ch === \"'\") {\n\t\t\t// Strings end at the matching quote or at a line break (unterminated string).\n\t\t\tlet j = i + 1;\n\t\t\twhile (j < n && source[j] !== ch && source[j] !== \"\\n\") {\n\t\t\t\tj += source[j] === \"\\\\\" ? 2 : 1;\n\t\t\t}\n\t\t\ti = source[j] === ch ? j + 1 : j;\n\t\t} else if (ch === \"`\") {\n\t\t\tmask(i, i + 1);\n\t\t\tframes.push({ kind: \"template\" });\n\t\t\ti++;\n\t\t} else if (ch === \"{\") {\n\t\t\ttop.braces++;\n\t\t\ti++;\n\t\t} else if (ch === \"}\") {\n\t\t\tif (top.interpolation && top.braces === 0) {\n\t\t\t\tmask(i, i + 1);\n\t\t\t\tframes.pop();\n\t\t\t} else if (top.braces > 0) {\n\t\t\t\ttop.braces--;\n\t\t\t}\n\t\t\ti++;\n\t\t} else {\n\t\t\ti++;\n\t\t}\n\t}\n\n\treturn out.join(\"\");\n}\n\nfunction escapeRegExp(value: string): string {\n\treturn value.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n}\n"],"mappings":"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"}
|