flagrm 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/LICENSE +21 -0
- package/README.md +173 -0
- package/dist/adapters/angular/discover.js +100 -0
- package/dist/adapters/angular/index.js +34 -0
- package/dist/adapters/angular/unused.js +74 -0
- package/dist/adapters/dotnet/index.js +29 -0
- package/dist/adapters/dotnet/unused.js +43 -0
- package/dist/adapters/generic/discover.js +84 -0
- package/dist/adapters/generic/index.js +50 -0
- package/dist/adapters/generic/text.js +93 -0
- package/dist/cli.js +216 -0
- package/dist/core/adapter.js +18 -0
- package/dist/core/baseline.js +255 -0
- package/dist/core/config.js +254 -0
- package/dist/core/detect.js +64 -0
- package/dist/core/discover.js +310 -0
- package/dist/core/doctor.js +141 -0
- package/dist/core/git.js +101 -0
- package/dist/core/hook.js +83 -0
- package/dist/core/install.js +257 -0
- package/dist/core/inventory.js +118 -0
- package/dist/core/markdown.js +55 -0
- package/dist/core/mentions.js +153 -0
- package/dist/core/package.js +17 -0
- package/dist/core/registry.js +29 -0
- package/dist/core/report.js +135 -0
- package/dist/core/scan.js +201 -0
- package/dist/core/source-text.js +36 -0
- package/dist/core/text-utils.js +27 -0
- package/dist/core/types.js +22 -0
- package/dist/core/util.js +18 -0
- package/dist/core/verify/commands.js +166 -0
- package/dist/core/verify/context.js +13 -0
- package/dist/core/verify/dead-code.js +92 -0
- package/dist/core/verify/index.js +84 -0
- package/dist/core/verify/leftovers.js +74 -0
- package/dist/core/verify/test-results.js +153 -0
- package/package.json +66 -0
- package/skills/flagrm-remove/SKILL.md +88 -0
- package/skills/flagrm-remove/patterns.md +96 -0
- package/skills/flagrm-verify/SKILL.md +48 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import YAML from "yaml";
|
|
4
|
+
export const ADAPTER_IDS = ["angular", "dotnet", "generic"];
|
|
5
|
+
export const DEFAULT_FRONTEND_METHODS = ["isEnabled", "isFeatureEnabled", "hasFeature", "getFlag", "isOn"];
|
|
6
|
+
export const DEFAULT_BACKEND_METHODS = [
|
|
7
|
+
"IsEnabledAsync",
|
|
8
|
+
"IsEnabled",
|
|
9
|
+
"IsFeatureEnabled",
|
|
10
|
+
"BoolVariation",
|
|
11
|
+
"GetFeatureFlag",
|
|
12
|
+
"GetValue<bool>",
|
|
13
|
+
];
|
|
14
|
+
export const DEFAULT_BACKEND_ATTRIBUTES = ["FeatureGate", "Feature"];
|
|
15
|
+
const CONFIG_FILENAMES = ["flagrm.config.yaml", "flagrm.config.yml", "flagrm.config.json"];
|
|
16
|
+
/** Known top-level `flagrm.config.yaml` keys — anything else is likely a typo. */
|
|
17
|
+
const KNOWN_CONFIG_KEYS = [
|
|
18
|
+
"projects",
|
|
19
|
+
"frontend",
|
|
20
|
+
"backend",
|
|
21
|
+
"frontendMethods",
|
|
22
|
+
"backendMethods",
|
|
23
|
+
"backendAttributes",
|
|
24
|
+
"exclude",
|
|
25
|
+
"include",
|
|
26
|
+
];
|
|
27
|
+
const KNOWN_PROJECT_KEYS = [
|
|
28
|
+
"name",
|
|
29
|
+
"adapter",
|
|
30
|
+
"path",
|
|
31
|
+
"build",
|
|
32
|
+
"test",
|
|
33
|
+
"testResults",
|
|
34
|
+
"timeout",
|
|
35
|
+
"globs",
|
|
36
|
+
"methods",
|
|
37
|
+
"attributes",
|
|
38
|
+
];
|
|
39
|
+
const PROJECT_NAME = /^[A-Za-z0-9][\w.-]*$/;
|
|
40
|
+
function readConfigFile(configPath) {
|
|
41
|
+
const raw = fs.readFileSync(configPath, "utf8");
|
|
42
|
+
const parsed = configPath.endsWith(".json") ? JSON.parse(raw) : YAML.parse(raw);
|
|
43
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
44
|
+
throw new Error(`Config file ${configPath} does not contain an object.`);
|
|
45
|
+
}
|
|
46
|
+
const file = parsed;
|
|
47
|
+
// Checked here so a YAML string (`exclude: "**/gen/**"`) fails instead of being spread into characters.
|
|
48
|
+
for (const key of ["frontend", "backend"])
|
|
49
|
+
optionalString(file[key], key);
|
|
50
|
+
for (const key of ["frontendMethods", "backendMethods", "backendAttributes", "exclude", "include"]) {
|
|
51
|
+
optionalStringList(file[key], key);
|
|
52
|
+
}
|
|
53
|
+
return file;
|
|
54
|
+
}
|
|
55
|
+
/** Warn (stderr) about keys that don't match any known option — likely a typo. */
|
|
56
|
+
function warnOnUnknownKeys(obj, known, where) {
|
|
57
|
+
for (const key of Object.keys(obj).filter((k) => !known.includes(k))) {
|
|
58
|
+
console.error(`Warning: unknown key "${key}" in ${where} — ignored. Known keys: ${known.join(", ")}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The nearest directory from `cwd` upward that holds a `.git` directory or
|
|
63
|
+
* file, or undefined outside a repository. Inside a submodule or worktree
|
|
64
|
+
* that is the submodule's or worktree's own root.
|
|
65
|
+
*/
|
|
66
|
+
export function repositoryRoot(cwd) {
|
|
67
|
+
for (let dir = path.resolve(cwd);; dir = path.dirname(dir)) {
|
|
68
|
+
if (fs.existsSync(path.join(dir, ".git")))
|
|
69
|
+
return dir;
|
|
70
|
+
if (path.dirname(dir) === dir)
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The nearest flagrm config file from `cwd` upward, stopping at the root of the
|
|
76
|
+
* git repository `cwd` is in; outside a repository only `cwd` itself is
|
|
77
|
+
* searched, so a config in a parent or home directory is never picked up.
|
|
78
|
+
* Known limitation: inside a git submodule the search stops at the
|
|
79
|
+
* submodule's root, so a config in the superproject is not found there.
|
|
80
|
+
*/
|
|
81
|
+
export function findConfigFile(cwd) {
|
|
82
|
+
const start = path.resolve(cwd);
|
|
83
|
+
const top = repositoryRoot(start);
|
|
84
|
+
for (let dir = start;; dir = path.dirname(dir)) {
|
|
85
|
+
const file = findDefaultConfigFile(dir);
|
|
86
|
+
if (file)
|
|
87
|
+
return file;
|
|
88
|
+
if (!top || dir === top)
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** The directory of {@link findConfigFile}, or undefined when there is none. */
|
|
93
|
+
export function findConfigRoot(cwd) {
|
|
94
|
+
const file = findConfigFile(cwd);
|
|
95
|
+
return file && path.dirname(file);
|
|
96
|
+
}
|
|
97
|
+
/** The flagrm config file in `cwd` (`flagrm.config.yaml`, `.yml` or `.json`, in that order), if any. */
|
|
98
|
+
export function findDefaultConfigFile(cwd) {
|
|
99
|
+
for (const name of CONFIG_FILENAMES) {
|
|
100
|
+
const candidate = path.join(cwd, name);
|
|
101
|
+
if (fs.existsSync(candidate))
|
|
102
|
+
return candidate;
|
|
103
|
+
}
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
function resolveProjectDir(base, raw, label) {
|
|
107
|
+
const abs = path.resolve(base, raw);
|
|
108
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) {
|
|
109
|
+
throw new Error(`${label} path does not exist or is not a directory: ${abs}`);
|
|
110
|
+
}
|
|
111
|
+
return abs;
|
|
112
|
+
}
|
|
113
|
+
function optionalString(value, where) {
|
|
114
|
+
if (value === undefined)
|
|
115
|
+
return undefined;
|
|
116
|
+
if (typeof value !== "string" || !value.trim())
|
|
117
|
+
throw new Error(`${where} must be a non-empty string.`);
|
|
118
|
+
return value;
|
|
119
|
+
}
|
|
120
|
+
function optionalPositiveNumber(value, where) {
|
|
121
|
+
if (value === undefined)
|
|
122
|
+
return undefined;
|
|
123
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
124
|
+
throw new Error(`${where} must be a positive number of seconds.`);
|
|
125
|
+
}
|
|
126
|
+
return value;
|
|
127
|
+
}
|
|
128
|
+
function optionalStringList(value, where) {
|
|
129
|
+
if (value === undefined)
|
|
130
|
+
return undefined;
|
|
131
|
+
if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
|
|
132
|
+
throw new Error(`${where} must be a list of strings.`);
|
|
133
|
+
}
|
|
134
|
+
return value;
|
|
135
|
+
}
|
|
136
|
+
/** Per-adapter defaults for `methods`/`attributes`; the legacy top-level keys override the built-ins. */
|
|
137
|
+
function adapterDefaults(adapter, file) {
|
|
138
|
+
const frontend = file.frontendMethods?.length ? file.frontendMethods : DEFAULT_FRONTEND_METHODS;
|
|
139
|
+
const backend = file.backendMethods?.length ? file.backendMethods : DEFAULT_BACKEND_METHODS;
|
|
140
|
+
const attributes = file.backendAttributes?.length ? file.backendAttributes : DEFAULT_BACKEND_ATTRIBUTES;
|
|
141
|
+
if (adapter === "angular")
|
|
142
|
+
return { methods: frontend, attributes: [] };
|
|
143
|
+
if (adapter === "dotnet")
|
|
144
|
+
return { methods: backend, attributes };
|
|
145
|
+
return { methods: [...new Set([...frontend, ...backend])], attributes };
|
|
146
|
+
}
|
|
147
|
+
function parseProject(raw, index, file, fileDir) {
|
|
148
|
+
const where = `projects[${index}]`;
|
|
149
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
150
|
+
throw new Error(`${where} must be an object with name, adapter and path.`);
|
|
151
|
+
}
|
|
152
|
+
const p = raw;
|
|
153
|
+
if (typeof p.name !== "string" || !p.name)
|
|
154
|
+
throw new Error(`${where} is missing a "name".`);
|
|
155
|
+
const name = p.name;
|
|
156
|
+
if (!PROJECT_NAME.test(name)) {
|
|
157
|
+
throw new Error(`${where}: name "${name}" must start with a letter or digit and contain only letters, digits, _ . -`);
|
|
158
|
+
}
|
|
159
|
+
warnOnUnknownKeys(p, KNOWN_PROJECT_KEYS, `project "${name}"`);
|
|
160
|
+
if (typeof p.adapter !== "string" || !ADAPTER_IDS.includes(p.adapter)) {
|
|
161
|
+
throw new Error(`${where}: adapter "${String(p.adapter)}" is not one of ${ADAPTER_IDS.join(", ")}.`);
|
|
162
|
+
}
|
|
163
|
+
const adapter = p.adapter;
|
|
164
|
+
if (typeof p.path !== "string" || !p.path)
|
|
165
|
+
throw new Error(`${where} ("${name}") is missing a "path".`);
|
|
166
|
+
const defaults = adapterDefaults(adapter, file);
|
|
167
|
+
const testResults = optionalString(p.testResults, `${where}.testResults`);
|
|
168
|
+
const project = {
|
|
169
|
+
name,
|
|
170
|
+
adapter,
|
|
171
|
+
path: resolveProjectDir(fileDir, p.path, `Project "${name}"`),
|
|
172
|
+
build: optionalString(p.build, `${where}.build`),
|
|
173
|
+
test: optionalString(p.test, `${where}.test`),
|
|
174
|
+
testResults: testResults && path.resolve(fileDir, testResults),
|
|
175
|
+
timeout: optionalPositiveNumber(p.timeout, `${where}.timeout`),
|
|
176
|
+
globs: optionalStringList(p.globs, `${where}.globs`),
|
|
177
|
+
methods: optionalStringList(p.methods, `${where}.methods`) ?? defaults.methods,
|
|
178
|
+
attributes: optionalStringList(p.attributes, `${where}.attributes`) ?? defaults.attributes,
|
|
179
|
+
};
|
|
180
|
+
// Keep the resolved shape free of absent optional keys.
|
|
181
|
+
for (const key of ["build", "test", "testResults", "timeout", "globs"]) {
|
|
182
|
+
if (project[key] === undefined)
|
|
183
|
+
delete project[key];
|
|
184
|
+
}
|
|
185
|
+
return project;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Merge CLI options with an optional config file and validate everything.
|
|
189
|
+
*
|
|
190
|
+
* Projects come from `projects:` plus the legacy `frontend:`/`backend:` keys,
|
|
191
|
+
* which become an `angular` project named `frontend` and a `dotnet` project
|
|
192
|
+
* named `backend`. `--frontend`/`--backend` on the CLI replace the project of
|
|
193
|
+
* that name (or add it).
|
|
194
|
+
*/
|
|
195
|
+
export function loadConfig(cli, cwd = process.cwd()) {
|
|
196
|
+
let file = {};
|
|
197
|
+
let fileDir = cwd;
|
|
198
|
+
// Without --config, the nearest config up to the repository root is the workspace, as it is for the Stop hook.
|
|
199
|
+
const configPath = cli.config ? path.resolve(cwd, cli.config) : findConfigFile(cwd);
|
|
200
|
+
if (cli.config && !fs.existsSync(configPath)) {
|
|
201
|
+
throw new Error(`Config file not found: ${configPath}`);
|
|
202
|
+
}
|
|
203
|
+
if (configPath && fs.existsSync(configPath)) {
|
|
204
|
+
file = readConfigFile(configPath);
|
|
205
|
+
fileDir = path.dirname(configPath);
|
|
206
|
+
warnOnUnknownKeys(file, KNOWN_CONFIG_KEYS, configPath);
|
|
207
|
+
}
|
|
208
|
+
if (file.projects !== undefined && !Array.isArray(file.projects)) {
|
|
209
|
+
throw new Error(`"projects" must be a list of { name, adapter, path } entries.`);
|
|
210
|
+
}
|
|
211
|
+
const projects = (file.projects ?? []).map((raw, i) => parseProject(raw, i, file, fileDir));
|
|
212
|
+
const legacyProject = (name, adapter, dir) => ({
|
|
213
|
+
name,
|
|
214
|
+
adapter,
|
|
215
|
+
path: dir,
|
|
216
|
+
...adapterDefaults(adapter, file),
|
|
217
|
+
});
|
|
218
|
+
if (file.frontend) {
|
|
219
|
+
projects.push(legacyProject("frontend", "angular", resolveProjectDir(fileDir, file.frontend, "Frontend")));
|
|
220
|
+
}
|
|
221
|
+
if (file.backend) {
|
|
222
|
+
projects.push(legacyProject("backend", "dotnet", resolveProjectDir(fileDir, file.backend, "Backend")));
|
|
223
|
+
}
|
|
224
|
+
const seen = new Set();
|
|
225
|
+
for (const p of projects) {
|
|
226
|
+
if (seen.has(p.name))
|
|
227
|
+
throw new Error(`Duplicate project name "${p.name}" in ${configPath ?? "config"}.`);
|
|
228
|
+
seen.add(p.name);
|
|
229
|
+
}
|
|
230
|
+
// CLI shortcuts replace a same-named project (keeping its position) or append one.
|
|
231
|
+
for (const [name, adapter, raw] of [
|
|
232
|
+
["frontend", "angular", cli.frontend],
|
|
233
|
+
["backend", "dotnet", cli.backend],
|
|
234
|
+
]) {
|
|
235
|
+
if (!raw)
|
|
236
|
+
continue;
|
|
237
|
+
const label = name === "frontend" ? "Frontend" : "Backend";
|
|
238
|
+
const project = legacyProject(name, adapter, resolveProjectDir(cwd, raw, label));
|
|
239
|
+
const existing = projects.findIndex((p) => p.name === name);
|
|
240
|
+
if (existing === -1)
|
|
241
|
+
projects.push(project);
|
|
242
|
+
else
|
|
243
|
+
projects[existing] = project;
|
|
244
|
+
}
|
|
245
|
+
if (projects.length === 0) {
|
|
246
|
+
throw new Error("No projects configured. Add a `projects:` list to flagrm.config.yaml, or pass --frontend and/or --backend.");
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
root: fileDir,
|
|
250
|
+
projects,
|
|
251
|
+
exclude: [...(file.exclude ?? []), ...(cli.exclude ?? [])],
|
|
252
|
+
include: [...(file.include ?? []), ...(cli.include ?? [])],
|
|
253
|
+
};
|
|
254
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which projects a repository has, for `flagrm init` to seed flagrm.config.yaml:
|
|
3
|
+
* Angular workspaces (`angular.json`) and .NET solutions (`.sln`, `.slnx`),
|
|
4
|
+
* or `.csproj` files when there is no solution. Shallow on purpose: it only
|
|
5
|
+
* saves the user from editing a template, and they check the result.
|
|
6
|
+
*/
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import fg from "fast-glob";
|
|
9
|
+
const IGNORE = ["**/node_modules/**", "**/.git/**", "**/bin/**", "**/obj/**", "**/dist/**"];
|
|
10
|
+
const depth = (file) => file.split("/").length;
|
|
11
|
+
/** Markers whose directory isn't inside another marker's directory, one per directory. */
|
|
12
|
+
function topmost(markers) {
|
|
13
|
+
const kept = [];
|
|
14
|
+
for (const marker of [...markers].sort((a, b) => depth(a) - depth(b) || a.localeCompare(b))) {
|
|
15
|
+
const dir = path.posix.dirname(marker);
|
|
16
|
+
const covered = kept.some((k) => {
|
|
17
|
+
const parent = path.posix.dirname(k);
|
|
18
|
+
return parent === "." || dir === parent || dir.startsWith(`${parent}/`);
|
|
19
|
+
});
|
|
20
|
+
if (!covered)
|
|
21
|
+
kept.push(marker);
|
|
22
|
+
}
|
|
23
|
+
return kept;
|
|
24
|
+
}
|
|
25
|
+
/** The deepest directory containing every file. */
|
|
26
|
+
function commonDir(files) {
|
|
27
|
+
const parts = files.map((f) => path.posix.dirname(f).split("/"));
|
|
28
|
+
const common = [];
|
|
29
|
+
for (let i = 0; parts.every((p) => i < p.length && p[i] === parts[0][i]); i++)
|
|
30
|
+
common.push(parts[0][i]);
|
|
31
|
+
return common.join("/") || ".";
|
|
32
|
+
}
|
|
33
|
+
export function detectProjects(cwd) {
|
|
34
|
+
const find = (pattern) => fg.sync(pattern, { cwd, deep: 4, ignore: IGNORE });
|
|
35
|
+
const found = [];
|
|
36
|
+
for (const marker of topmost(find("**/angular.json"))) {
|
|
37
|
+
const dir = path.posix.dirname(marker);
|
|
38
|
+
const jest = fg.sync("jest.config.{js,ts,mjs,cjs}", { cwd: path.join(cwd, dir) }).length > 0;
|
|
39
|
+
found.push({ adapter: "angular", path: dir, marker, jest });
|
|
40
|
+
}
|
|
41
|
+
const solutions = topmost(find("**/*.{sln,slnx}"));
|
|
42
|
+
if (solutions.length) {
|
|
43
|
+
for (const marker of solutions)
|
|
44
|
+
found.push({ adapter: "dotnet", path: path.posix.dirname(marker), marker });
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
const projects = find("**/*.csproj").sort();
|
|
48
|
+
if (projects.length)
|
|
49
|
+
found.push({ adapter: "dotnet", path: commonDir(projects), marker: projects[0] });
|
|
50
|
+
}
|
|
51
|
+
const names = new Set();
|
|
52
|
+
return found.map((p) => {
|
|
53
|
+
const base = projectName(path.basename(p.path === "." ? cwd : p.path)) || p.adapter;
|
|
54
|
+
let name = names.has(base) ? `${base}-${p.adapter}` : base;
|
|
55
|
+
for (let i = 2; names.has(name); i++)
|
|
56
|
+
name = `${base}-${p.adapter}-${i}`;
|
|
57
|
+
names.add(name);
|
|
58
|
+
return { name, ...p };
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/** A directory name as a valid project name (see PROJECT_NAME in config.ts): `my app` → `my-app`, `_legacy` → `legacy`. */
|
|
62
|
+
function projectName(dir) {
|
|
63
|
+
return dir.replace(/[^\w.-]+/g, "-").replace(/^[^A-Za-z0-9]+/, "");
|
|
64
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort discovery of *candidate* feature flag names, for `flagrm list`.
|
|
3
|
+
*
|
|
4
|
+
* This is a UX aid: false negatives (a flag it misses) are fine — the agent
|
|
5
|
+
* can still remove a flag by name. False positives are mildly annoying but
|
|
6
|
+
* harmless since nothing is written. So the heuristics here are intentionally
|
|
7
|
+
* simpler/looser than the scan code path.
|
|
8
|
+
*/
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { computeCodeMask, computeCommentMask, computeLexicalMask, escapeRegExp, LEX_COMMENT, LEX_STRING, methodNamePattern, } from "./scan.js";
|
|
11
|
+
import { SourceText } from "./source-text.js";
|
|
12
|
+
import { matchBracket } from "./text-utils.js";
|
|
13
|
+
/** The configuration section of Microsoft.FeatureManagement flags. */
|
|
14
|
+
export const FEATURE_MANAGEMENT_SECTION = "FeatureManagement";
|
|
15
|
+
/**
|
|
16
|
+
* Find keys declared directly inside a top-level `"FeatureManagement": { ... }`
|
|
17
|
+
* object in an appsettings*.json file (Microsoft.FeatureManagement convention).
|
|
18
|
+
* Keys nested inside an individual flag's own filter config (e.g. `EnabledFor`)
|
|
19
|
+
* are not candidates — only the flag names themselves are.
|
|
20
|
+
*/
|
|
21
|
+
export function findFeatureManagementKeys(text) {
|
|
22
|
+
const out = [];
|
|
23
|
+
const mask = computeLexicalMask(text, "slash");
|
|
24
|
+
let depth = 0;
|
|
25
|
+
const containerKeyAtDepth = [null];
|
|
26
|
+
let pendingKey = null;
|
|
27
|
+
let i = 0;
|
|
28
|
+
while (i < text.length) {
|
|
29
|
+
const c = text[i];
|
|
30
|
+
if (mask[i] === LEX_COMMENT) {
|
|
31
|
+
i++;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (mask[i] === LEX_STRING) {
|
|
35
|
+
const start = i;
|
|
36
|
+
while (i < text.length && mask[i] === LEX_STRING)
|
|
37
|
+
i++;
|
|
38
|
+
const value = text.slice(start + 1, i - 1);
|
|
39
|
+
let j = i;
|
|
40
|
+
while (j < text.length && /\s/.test(text[j]))
|
|
41
|
+
j++;
|
|
42
|
+
if (text[j] === ":") {
|
|
43
|
+
pendingKey = value;
|
|
44
|
+
if (containerKeyAtDepth[depth] === FEATURE_MANAGEMENT_SECTION)
|
|
45
|
+
out.push({ key: value, index: start });
|
|
46
|
+
}
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (c === "{" || c === "[") {
|
|
50
|
+
containerKeyAtDepth[depth + 1] = c === "{" ? pendingKey : null;
|
|
51
|
+
depth++;
|
|
52
|
+
pendingKey = null;
|
|
53
|
+
}
|
|
54
|
+
else if (c === "}" || c === "]") {
|
|
55
|
+
containerKeyAtDepth.length = depth;
|
|
56
|
+
depth = Math.max(0, depth - 1);
|
|
57
|
+
}
|
|
58
|
+
i++;
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The environment a config file configures, from its name: `default` for the
|
|
64
|
+
* base file, otherwise the suffix (`appsettings.Development.json` →
|
|
65
|
+
* `Development`, `environment.prod.ts` → `prod`).
|
|
66
|
+
*/
|
|
67
|
+
export function configEnvironment(file) {
|
|
68
|
+
const m = /^(?:appsettings|environment)\.(.+)\.(?:json|ts)$/i.exec(path.basename(file));
|
|
69
|
+
return m ? m[1] : "default";
|
|
70
|
+
}
|
|
71
|
+
/** Microsoft.FeatureManagement filter that turns a flag on unconditionally. */
|
|
72
|
+
const ALWAYS_ON = "AlwaysOn";
|
|
73
|
+
/** The configured state of each key in an appsettings file's `FeatureManagement` section. */
|
|
74
|
+
function featureManagementStates(text) {
|
|
75
|
+
const states = new Map();
|
|
76
|
+
let section;
|
|
77
|
+
try {
|
|
78
|
+
section = parseJsonc(text)?.[FEATURE_MANAGEMENT_SECTION];
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return states;
|
|
82
|
+
}
|
|
83
|
+
if (!section || typeof section !== "object")
|
|
84
|
+
return states;
|
|
85
|
+
for (const [key, value] of Object.entries(section)) {
|
|
86
|
+
// .NET configuration binds the strings "true"/"false" like the booleans.
|
|
87
|
+
if (typeof value === "boolean" || value === "true" || value === "false") {
|
|
88
|
+
states.set(key, { state: value === true || value === "true" ? "on" : "off" });
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (!value || typeof value !== "object") {
|
|
92
|
+
states.set(key, { state: "unknown" });
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
// A flag object without filters is never enabled.
|
|
96
|
+
const enabledFor = value.EnabledFor;
|
|
97
|
+
if (!Array.isArray(enabledFor)) {
|
|
98
|
+
states.set(key, { state: enabledFor === undefined ? "off" : "unknown" });
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const filters = enabledFor.map((f) => String(f?.Name ?? "")).filter(Boolean);
|
|
102
|
+
if (filters.length === 0)
|
|
103
|
+
states.set(key, { state: "off" });
|
|
104
|
+
else if (filters.includes(ALWAYS_ON))
|
|
105
|
+
states.set(key, { state: "on" });
|
|
106
|
+
else
|
|
107
|
+
states.set(key, { state: "conditional", filters });
|
|
108
|
+
}
|
|
109
|
+
return states;
|
|
110
|
+
}
|
|
111
|
+
/** `JSON.parse` for appsettings-style JSONC: comments and trailing commas are allowed. */
|
|
112
|
+
function parseJsonc(text) {
|
|
113
|
+
const mask = computeCommentMask(text);
|
|
114
|
+
let code = "";
|
|
115
|
+
for (let i = 0; i < text.length; i++)
|
|
116
|
+
if (mask[i])
|
|
117
|
+
code += text[i];
|
|
118
|
+
return JSON.parse(code.replace(/^\uFEFF/, "").replace(/,(\s*[}\]])/g, "$1"));
|
|
119
|
+
}
|
|
120
|
+
/** Find every quoted string literal inside the (possibly nested-paren) argument list of `pattern(...)` matches. */
|
|
121
|
+
function extractStringLiteralArgs(text, openPattern, mode, mask) {
|
|
122
|
+
const out = [];
|
|
123
|
+
const litPattern = /(['"])((?:\\.|(?!\1).)*)\1/g;
|
|
124
|
+
openPattern.lastIndex = 0;
|
|
125
|
+
let m;
|
|
126
|
+
while ((m = openPattern.exec(text)) !== null) {
|
|
127
|
+
if (!mask[m.index])
|
|
128
|
+
continue; // inside a comment/string
|
|
129
|
+
const open = m.index + m[0].length - 1;
|
|
130
|
+
const close = matchBracket(text, mask, open, "(", ")");
|
|
131
|
+
if (close === -1)
|
|
132
|
+
continue;
|
|
133
|
+
const argsText = text.slice(open + 1, close);
|
|
134
|
+
litPattern.lastIndex = 0;
|
|
135
|
+
let lm;
|
|
136
|
+
while ((lm = litPattern.exec(argsText)) !== null) {
|
|
137
|
+
out.push({ value: lm[2], index: open + 1 + lm.index });
|
|
138
|
+
if (mode === "first")
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
const FLAG_TYPE_NAME = /(?:Flags?|Features?|Toggles?)/i;
|
|
145
|
+
/**
|
|
146
|
+
* A type declaration header; `record class X` / `record struct X` name `X`.
|
|
147
|
+
* `class` and `struct` after `:` or `,` are generic constraints (`where T : class`), not types.
|
|
148
|
+
*/
|
|
149
|
+
const TYPE_HEADER = /(?<![:,]\s*)\b(?:class|struct|interface|record(?:\s+(?:class|struct))?)\s+(\w+)/;
|
|
150
|
+
/**
|
|
151
|
+
* For each of `indexes` (ascending), the type names between the type's
|
|
152
|
+
* opening brace at `braceIdx` and that index, outermost first (`["Checkout"]`
|
|
153
|
+
* for a const inside `static class Checkout { ... }`), or undefined when it
|
|
154
|
+
* sits in a non-type block such as a method body. One pass over the body;
|
|
155
|
+
* comments, strings and preprocessor lines (`#region Nested class ...`) never
|
|
156
|
+
* name a type.
|
|
157
|
+
*/
|
|
158
|
+
function nestedTypePaths(text, mask, braceIdx, indexes) {
|
|
159
|
+
const out = [];
|
|
160
|
+
const stack = [];
|
|
161
|
+
let header = "";
|
|
162
|
+
let next = 0;
|
|
163
|
+
for (let i = braceIdx + 1; next < indexes.length; i++) {
|
|
164
|
+
while (next < indexes.length && indexes[next] <= i) {
|
|
165
|
+
out.push(stack.every((name) => name !== undefined) ? [...stack] : undefined);
|
|
166
|
+
next++;
|
|
167
|
+
}
|
|
168
|
+
if (i >= text.length)
|
|
169
|
+
break;
|
|
170
|
+
if (!mask[i]) {
|
|
171
|
+
header += " ";
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const c = text[i];
|
|
175
|
+
if (c === "#" && /^[ \t]*$/.test(text.slice(text.lastIndexOf("\n", i - 1) + 1, i))) {
|
|
176
|
+
const eol = text.indexOf("\n", i);
|
|
177
|
+
i = (eol === -1 ? text.length : eol) - 1;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (c === "{") {
|
|
181
|
+
stack.push(TYPE_HEADER.exec(header)?.[1]);
|
|
182
|
+
header = "";
|
|
183
|
+
}
|
|
184
|
+
else if (c === "}") {
|
|
185
|
+
stack.pop();
|
|
186
|
+
header = "";
|
|
187
|
+
}
|
|
188
|
+
else if (c === ";") {
|
|
189
|
+
header = "";
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
header += c;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Members of `class`/`struct`/`enum` types named like `*Flag(s)*`,
|
|
199
|
+
* `*Feature(s)*` or `*Toggle(s)*` (common flag-registry conventions). A
|
|
200
|
+
* const's flag name is its string value; an enum member's is its name.
|
|
201
|
+
*/
|
|
202
|
+
function findFlagTypeMembers(text) {
|
|
203
|
+
const out = [];
|
|
204
|
+
const mask = computeCodeMask(text);
|
|
205
|
+
// Keywords are lowercase in C#: a type named `Record` (`Save(Record featureFlags)`) declares nothing.
|
|
206
|
+
// `record class X` names `X`, so a keyword is never taken as the name.
|
|
207
|
+
const typePattern = /\b(class|struct|interface|record|enum)\s+(?!(?:class|struct)\b)(\w*(?:[Ff]lags?|[Ff]eatures?|[Tt]oggles?|FLAGS?|FEATURES?|TOGGLES?)\w*)\b/g;
|
|
208
|
+
let tm;
|
|
209
|
+
while ((tm = typePattern.exec(text)) !== null) {
|
|
210
|
+
if (!mask[tm.index])
|
|
211
|
+
continue;
|
|
212
|
+
const isEnum = tm[1].toLowerCase() === "enum";
|
|
213
|
+
const typeName = tm[2];
|
|
214
|
+
const braceIdx = text.indexOf("{", tm.index + tm[0].length);
|
|
215
|
+
if (braceIdx === -1)
|
|
216
|
+
continue;
|
|
217
|
+
// A body-less declaration (`record CheckoutFlags(string Name);`): the next brace belongs to something else.
|
|
218
|
+
const semicolon = text.indexOf(";", tm.index + tm[0].length);
|
|
219
|
+
if (semicolon !== -1 && semicolon < braceIdx && mask[semicolon])
|
|
220
|
+
continue;
|
|
221
|
+
const closeIdx = matchBracket(text, mask, braceIdx, "{", "}");
|
|
222
|
+
if (closeIdx === -1)
|
|
223
|
+
continue;
|
|
224
|
+
const body = text.slice(braceIdx + 1, closeIdx);
|
|
225
|
+
const bodyStart = braceIdx + 1;
|
|
226
|
+
if (isEnum) {
|
|
227
|
+
let offset = 0;
|
|
228
|
+
for (const rawLine of body.split("\n")) {
|
|
229
|
+
const line = rawLine.trim();
|
|
230
|
+
const leadingWs = rawLine.length - rawLine.trimStart().length;
|
|
231
|
+
if (line &&
|
|
232
|
+
!line.startsWith("//") &&
|
|
233
|
+
!line.startsWith("[") &&
|
|
234
|
+
!line.startsWith("*") &&
|
|
235
|
+
!line.startsWith("/*")) {
|
|
236
|
+
const nm = /^([A-Za-z_]\w*)/.exec(line);
|
|
237
|
+
if (nm) {
|
|
238
|
+
const symbol = `${typeName}.${nm[1]}`;
|
|
239
|
+
out.push({ flag: nm[1], symbol, symbolKind: "enum-member", index: bodyStart + offset + leadingWs });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
offset += rawLine.length + 1;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
const constPattern = /\b(?:public|internal|private|protected)?\s*(?:const|static\s+readonly)\s+string\s+(\w+)\s*=\s*(?:@?"([^"\\]*)"\s*;)?/g;
|
|
247
|
+
const consts = [...body.matchAll(constPattern)].filter((cm) => mask[bodyStart + cm.index]);
|
|
248
|
+
const paths = nestedTypePaths(text, mask, braceIdx, consts.map((cm) => bodyStart + cm.index));
|
|
249
|
+
consts.forEach((cm, k) => {
|
|
250
|
+
const nested = paths[k];
|
|
251
|
+
// Not a member of a type (a local in a method), or of a flag-named type that is scanned on its own.
|
|
252
|
+
if (!nested || nested.some((name) => FLAG_TYPE_NAME.test(name)))
|
|
253
|
+
return;
|
|
254
|
+
out.push({
|
|
255
|
+
flag: cm[2] || cm[1],
|
|
256
|
+
symbol: [typeName, ...nested, cm[1]].join("."),
|
|
257
|
+
symbolKind: "const",
|
|
258
|
+
index: bodyStart + cm.index + cm[0].indexOf(cm[1]),
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
265
|
+
/** Discover candidate flag names in a single file's text. */
|
|
266
|
+
export function discoverInText(text, file, project, ctx) {
|
|
267
|
+
const out = [];
|
|
268
|
+
const source = new SourceText(file, text);
|
|
269
|
+
const lineAt = (index) => source.position(index).line;
|
|
270
|
+
if (file.endsWith(".json")) {
|
|
271
|
+
const environment = configEnvironment(file);
|
|
272
|
+
const states = featureManagementStates(text);
|
|
273
|
+
for (const { key, index } of findFeatureManagementKeys(text)) {
|
|
274
|
+
const { state, filters } = states.get(key) ?? { state: "unknown" };
|
|
275
|
+
out.push({
|
|
276
|
+
flag: key,
|
|
277
|
+
project,
|
|
278
|
+
source: "config",
|
|
279
|
+
file,
|
|
280
|
+
line: lineAt(index),
|
|
281
|
+
environment,
|
|
282
|
+
state,
|
|
283
|
+
...(filters ? { filters } : {}),
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
return out;
|
|
287
|
+
}
|
|
288
|
+
// `computeCodeMask` assumes `//`-style string/comment syntax; an HTML
|
|
289
|
+
// attribute's double-quoted value isn't a "string literal" to skip over,
|
|
290
|
+
// so templates get an unmasked (all-code) pass instead.
|
|
291
|
+
const mask = file.endsWith(".html") ? new Uint8Array(text.length).fill(1) : computeCodeMask(text);
|
|
292
|
+
if (ctx.evalMethods.length) {
|
|
293
|
+
const evalPattern = new RegExp(`\\b(?:${ctx.evalMethods.map(methodNamePattern).join("|")})\\s*\\(`, "g");
|
|
294
|
+
for (const { value, index } of extractStringLiteralArgs(text, evalPattern, "first", mask)) {
|
|
295
|
+
out.push({ flag: value, project, source: "eval", file, line: lineAt(index) });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (ctx.attributes.length) {
|
|
299
|
+
const attrPattern = new RegExp(`\\[\\s*(?:${ctx.attributes.map(escapeRegExp).join("|")})\\s*\\(`, "g");
|
|
300
|
+
for (const { value, index } of extractStringLiteralArgs(text, attrPattern, "all", mask)) {
|
|
301
|
+
out.push({ flag: value, project, source: "attribute", file, line: lineAt(index) });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (file.endsWith(".cs")) {
|
|
305
|
+
for (const { flag, symbol, symbolKind, index } of findFlagTypeMembers(text)) {
|
|
306
|
+
out.push({ flag, project, source: "constant", file, line: lineAt(index), symbol, symbolKind });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return out;
|
|
310
|
+
}
|