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,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `flagrm list`: turn each adapter's discovery candidates into one inventory
|
|
3
|
+
* entry per flag — its definitions, configured state per environment file,
|
|
4
|
+
* type, direct reference counts and the projects involved.
|
|
5
|
+
*/
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { findMentions } from "./mentions.js";
|
|
8
|
+
import { isTestPath } from "./scan.js";
|
|
9
|
+
import { JSON_SCHEMA_VERSION, } from "./types.js";
|
|
10
|
+
/** Every adapter's discovery candidates, without the ones only tests evaluate. */
|
|
11
|
+
export async function discoverCandidates(projects) {
|
|
12
|
+
const candidates = [];
|
|
13
|
+
for (const { adapter, ctx } of projects) {
|
|
14
|
+
// A literal that only a test passes to an eval method (`isEnabled('DoesNotExist')`) is not a flag.
|
|
15
|
+
const found = await adapter.discover(ctx);
|
|
16
|
+
candidates.push(...found.filter((c) => (c.source !== "eval" && c.source !== "attribute") || !isTestPath(path.relative(ctx.root, c.file))));
|
|
17
|
+
}
|
|
18
|
+
return candidates;
|
|
19
|
+
}
|
|
20
|
+
/** Discover every flag across the workspace and build the inventory. */
|
|
21
|
+
export async function buildInventory(projects) {
|
|
22
|
+
const candidates = await discoverCandidates(projects);
|
|
23
|
+
const entries = new Map();
|
|
24
|
+
const involved = new Map();
|
|
25
|
+
for (const c of candidates) {
|
|
26
|
+
let entry = entries.get(c.flag);
|
|
27
|
+
if (!entry) {
|
|
28
|
+
entry = {
|
|
29
|
+
flag: c.flag,
|
|
30
|
+
type: "unknown",
|
|
31
|
+
state: "unconfigured",
|
|
32
|
+
projects: [],
|
|
33
|
+
definitions: [],
|
|
34
|
+
config: [],
|
|
35
|
+
references: { total: 0, files: 0, tests: 0, byProject: {} },
|
|
36
|
+
};
|
|
37
|
+
entries.set(c.flag, entry);
|
|
38
|
+
involved.set(entry, new Set());
|
|
39
|
+
}
|
|
40
|
+
involved.get(entry)?.add(c.project);
|
|
41
|
+
const location = { project: c.project, file: c.file, line: c.line };
|
|
42
|
+
if (c.source === "config") {
|
|
43
|
+
entry.config.push({
|
|
44
|
+
...location,
|
|
45
|
+
environment: c.environment ?? "default",
|
|
46
|
+
state: c.state ?? "unknown",
|
|
47
|
+
...(c.filters ? { filters: c.filters } : {}),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
else if (c.symbol && c.symbolKind) {
|
|
51
|
+
entry.definitions.push({ kind: c.symbolKind, name: c.symbol, ...location });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
countReferences(projects, [...entries.values()]);
|
|
55
|
+
const order = projects.map(({ ctx }) => ctx.name);
|
|
56
|
+
const byLocation = (a, b) => order.indexOf(a.project) - order.indexOf(b.project) || a.file.localeCompare(b.file) || a.line - b.line;
|
|
57
|
+
for (const entry of entries.values()) {
|
|
58
|
+
entry.definitions.sort(byLocation);
|
|
59
|
+
entry.config.sort(byLocation);
|
|
60
|
+
const names = involved.get(entry) ?? new Set();
|
|
61
|
+
for (const name of Object.keys(entry.references.byProject))
|
|
62
|
+
names.add(name);
|
|
63
|
+
entry.projects = order.filter((name) => names.has(name));
|
|
64
|
+
Object.assign(entry, summarizeConfig(entry));
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
schemaVersion: JSON_SCHEMA_VERSION,
|
|
68
|
+
projects: projects.map(({ ctx }) => ({ name: ctx.name, adapter: ctx.project.adapter })),
|
|
69
|
+
flags: [...entries.values()].sort((a, b) => a.flag.localeCompare(b.flag)),
|
|
70
|
+
candidates,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function summarizeConfig(entry) {
|
|
74
|
+
const states = new Set(entry.config.map((c) => c.state));
|
|
75
|
+
if (states.size === 0)
|
|
76
|
+
return { type: "unknown", state: "unconfigured" };
|
|
77
|
+
if (states.has("conditional"))
|
|
78
|
+
return { type: "conditional", state: "conditional" };
|
|
79
|
+
const type = states.has("unknown") ? "unknown" : "boolean";
|
|
80
|
+
const state = states.size === 1 && !states.has("unknown") ? entry.config[0].state : "mixed";
|
|
81
|
+
return { type, state };
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Count each flag's direct references: the flag name as a string literal, or
|
|
85
|
+
* a definition's qualified name (`FLAGS.newCheckout`). Mentions on
|
|
86
|
+
* definition and config lines are the declarations themselves, not uses.
|
|
87
|
+
*/
|
|
88
|
+
function countReferences(projects, entries) {
|
|
89
|
+
const byLiteral = new Map();
|
|
90
|
+
const byAlias = new Map();
|
|
91
|
+
const declared = new Set();
|
|
92
|
+
for (const entry of entries) {
|
|
93
|
+
byLiteral.set(entry.flag, entry);
|
|
94
|
+
for (const d of entry.definitions) {
|
|
95
|
+
byAlias.set(d.name, entry);
|
|
96
|
+
declared.add(`${d.file}:${d.line}`);
|
|
97
|
+
}
|
|
98
|
+
for (const c of entry.config)
|
|
99
|
+
declared.add(`${c.file}:${c.line}`);
|
|
100
|
+
}
|
|
101
|
+
const mentions = findMentions(projects.map(({ ctx }) => ctx), [...byLiteral.keys()], [...byAlias.keys()]);
|
|
102
|
+
const files = new Map();
|
|
103
|
+
for (const m of mentions) {
|
|
104
|
+
const entry = m.kind === "literal" ? byLiteral.get(m.token) : byAlias.get(m.token);
|
|
105
|
+
if (!entry || declared.has(`${m.file}:${m.line}`))
|
|
106
|
+
continue;
|
|
107
|
+
const refs = entry.references;
|
|
108
|
+
refs.total++;
|
|
109
|
+
if (m.isTest)
|
|
110
|
+
refs.tests++;
|
|
111
|
+
refs.byProject[m.project] = (refs.byProject[m.project] ?? 0) + 1;
|
|
112
|
+
const seen = files.get(entry) ?? new Set();
|
|
113
|
+
seen.add(m.file);
|
|
114
|
+
files.set(entry, seen);
|
|
115
|
+
}
|
|
116
|
+
for (const [entry, seen] of files)
|
|
117
|
+
entry.references.files = seen.size;
|
|
118
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `flagrm verify <flag> --md`: a compact markdown overview of a verify result
|
|
3
|
+
* for the user or a PR. Every fact comes from the report; the agent adds
|
|
4
|
+
* notes and the commit message around it.
|
|
5
|
+
*/
|
|
6
|
+
import { relativePath } from "./util.js";
|
|
7
|
+
import { shortTestNames } from "./verify/test-results.js";
|
|
8
|
+
const ICON = { pass: "✅", fail: "❌", warn: "⚠️", skipped: "➖" };
|
|
9
|
+
/** How many entries a list shows before summarizing the rest. */
|
|
10
|
+
const MAX_ITEMS = 20;
|
|
11
|
+
function codeList(items) {
|
|
12
|
+
const shown = items
|
|
13
|
+
.slice(0, MAX_ITEMS)
|
|
14
|
+
.map((i) => `\`${i}\``)
|
|
15
|
+
.join(", ");
|
|
16
|
+
return items.length > MAX_ITEMS ? `${shown} and ${items.length - MAX_ITEMS} more` : shown;
|
|
17
|
+
}
|
|
18
|
+
function findingLine(root, f) {
|
|
19
|
+
if (!f.file)
|
|
20
|
+
return `- ${f.message}`;
|
|
21
|
+
const rel = relativePath(root, f.file);
|
|
22
|
+
return `- \`${rel}${f.line ? `:${f.line}` : ""}\` ${f.message}`;
|
|
23
|
+
}
|
|
24
|
+
/** Render a verify report as markdown: status, checks table, changed files, removed and new tests, failures and warnings. */
|
|
25
|
+
export function verifyMarkdown(report, root) {
|
|
26
|
+
const out = [
|
|
27
|
+
`### flagrm verify: ${report.flag} ${report.status === "pass" ? "✅ pass" : "❌ fail"}`,
|
|
28
|
+
"",
|
|
29
|
+
"| Check | Status | Summary |",
|
|
30
|
+
"|---|---|---|",
|
|
31
|
+
...report.checks.map((c) => `| ${c.id} | ${ICON[c.status]} ${c.status} | ${c.summary.replace(/\|/g, "\\|")} |`),
|
|
32
|
+
];
|
|
33
|
+
const changed = report.changedFiles ?? [];
|
|
34
|
+
if (changed.length)
|
|
35
|
+
out.push("", `**Files changed since baseline (${changed.length}):** ${codeList(changed)}`);
|
|
36
|
+
const removed = report.tests.flatMap((t) => t.removed);
|
|
37
|
+
if (removed.length) {
|
|
38
|
+
out.push("", `**Tests that no longer run (${removed.length}):** ${codeList(shortTestNames(removed))}`);
|
|
39
|
+
}
|
|
40
|
+
const added = report.tests.flatMap((t) => t.added);
|
|
41
|
+
if (added.length)
|
|
42
|
+
out.push("", `**New tests (${added.length}):** ${codeList(shortTestNames(added))}`);
|
|
43
|
+
for (const [severity, title] of [
|
|
44
|
+
["fail", "Failures"],
|
|
45
|
+
["warn", "Warnings"],
|
|
46
|
+
]) {
|
|
47
|
+
const items = report.checks.flatMap((c) => c.findings.filter((f) => f.severity === severity));
|
|
48
|
+
if (items.length === 0)
|
|
49
|
+
continue;
|
|
50
|
+
out.push("", `**${title}:**`, ...items.slice(0, MAX_ITEMS).map((f) => findingLine(root, f)));
|
|
51
|
+
if (items.length > MAX_ITEMS)
|
|
52
|
+
out.push(`- and ${items.length - MAX_ITEMS} more (see --json)`);
|
|
53
|
+
}
|
|
54
|
+
return `${out.join("\n")}\n`;
|
|
55
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { commentMaskFor, commentSyntax, computeLexicalMask, escapeRegExp, isKeyValueConfig, isTestPath, LEX_COMMENT, LEX_STRING, } from "./scan.js";
|
|
4
|
+
import { SourceText } from "./source-text.js";
|
|
5
|
+
import { toLf } from "./text-utils.js";
|
|
6
|
+
/**
|
|
7
|
+
* Find, in one pass per file, every string literal equal to one of
|
|
8
|
+
* `literals` and every qualified name in `aliases` (`FLAGS.newCheckout`,
|
|
9
|
+
* also inside `nameof(...)` or a longer chain). An alias followed by `(` is a
|
|
10
|
+
* call of something else with the same name, never a use of a constant.
|
|
11
|
+
* Comments and config (`.json`) files are skipped; with `comments: true`,
|
|
12
|
+
* aliases inside comments (`<see cref="FeatureFlags.NewCheckout"/>`) are
|
|
13
|
+
* returned too, marked `inComment`. A file shared by two projects is reported
|
|
14
|
+
* once, for the first.
|
|
15
|
+
*/
|
|
16
|
+
export function findMentions(projects, literals, aliases, options = {}) {
|
|
17
|
+
const alternation = (keys, toPattern) => [...new Set(keys)]
|
|
18
|
+
.sort((a, b) => b.length - a.length)
|
|
19
|
+
.map(toPattern)
|
|
20
|
+
.join("|");
|
|
21
|
+
const parts = [];
|
|
22
|
+
if (literals.length)
|
|
23
|
+
parts.push(`(['"\`])(${alternation(literals, escapeRegExp)})\\1`);
|
|
24
|
+
if (aliases.length) {
|
|
25
|
+
const alias = alternation(aliases, (name) => name.split(".").map(escapeRegExp).join("\\s*\\.\\s*"));
|
|
26
|
+
parts.push(`(?<![\\w$])(${alias})(?![\\w$])(?!\\s*\\()`);
|
|
27
|
+
}
|
|
28
|
+
if (parts.length === 0)
|
|
29
|
+
return [];
|
|
30
|
+
const pattern = new RegExp(parts.join("|"), "g");
|
|
31
|
+
// Group numbers: literal quote/content are 1/2 when present; the alias group comes last.
|
|
32
|
+
const literalGroup = literals.length ? 2 : -1;
|
|
33
|
+
const aliasGroup = literals.length ? 3 : 1;
|
|
34
|
+
const mentions = [];
|
|
35
|
+
const seen = new Set();
|
|
36
|
+
for (const ctx of projects) {
|
|
37
|
+
for (const file of ctx.files) {
|
|
38
|
+
if (seen.has(file) || file.endsWith(".json"))
|
|
39
|
+
continue;
|
|
40
|
+
seen.add(file);
|
|
41
|
+
let text;
|
|
42
|
+
try {
|
|
43
|
+
text = toLf(fs.readFileSync(file, "utf8"));
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
pattern.lastIndex = 0;
|
|
49
|
+
if (!pattern.test(text))
|
|
50
|
+
continue;
|
|
51
|
+
pattern.lastIndex = 0; // matchAll starts from the regex's lastIndex
|
|
52
|
+
const source = new SourceText(file, text);
|
|
53
|
+
const mask = commentMaskFor(file, text);
|
|
54
|
+
const isTest = isTestPath(path.relative(ctx.root, file));
|
|
55
|
+
for (const m of text.matchAll(pattern)) {
|
|
56
|
+
const literal = literalGroup > 0 ? m[literalGroup] : undefined;
|
|
57
|
+
const inComment = mask !== null && mask[m.index] === 0;
|
|
58
|
+
if (inComment && (!options.comments || literal !== undefined))
|
|
59
|
+
continue;
|
|
60
|
+
mentions.push({
|
|
61
|
+
project: ctx.name,
|
|
62
|
+
file,
|
|
63
|
+
line: source.position(m.index).line,
|
|
64
|
+
token: literal ?? m[aliasGroup].replace(/\s+/g, ""),
|
|
65
|
+
kind: literal !== undefined ? "literal" : "alias",
|
|
66
|
+
isTest,
|
|
67
|
+
inComment,
|
|
68
|
+
snippet: source.snippet(m.index),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return mentions;
|
|
74
|
+
}
|
|
75
|
+
/** What may come before a key on its line: indentation, a YAML list `- `, `export `, dotted parents (`features.`). */
|
|
76
|
+
const CONFIG_KEY_HEAD = /^[ \t]*(?:-[ \t]+)?(?:export[ \t]+)?(?:[\w-]+\.)*$/;
|
|
77
|
+
/** What follows a key in a key/value file: `:` and whitespace (YAML), or `=` but not `==`. */
|
|
78
|
+
const CONFIG_KEY_TAIL = /^[ \t]*(?::(?=\s|$)|=(?!=))/;
|
|
79
|
+
/**
|
|
80
|
+
* Find every whole-word use of `names` (calls included, unlike aliases in
|
|
81
|
+
* {@link findMentions}), skipping string literals. Uses in comments are
|
|
82
|
+
* returned marked `inComment`. Config (`.json`) files are skipped. A file
|
|
83
|
+
* shared by two projects is reported once, for the first.
|
|
84
|
+
*
|
|
85
|
+
* `configPaths` are .NET configuration paths (`FeatureManagement__NewCheckout`
|
|
86
|
+
* environment variables, `FeatureManagement:NewCheckout` keys), matched in the
|
|
87
|
+
* same pass: ignoring case, as .NET configuration does, inside strings and
|
|
88
|
+
* `launchSettings.json` too, and before a deeper `__Section`. Other `.json`
|
|
89
|
+
* files are never read.
|
|
90
|
+
*/
|
|
91
|
+
export function findIdentifiers(projects, names, configPaths = []) {
|
|
92
|
+
const alternation = (list, toPattern) => [...new Set(list)]
|
|
93
|
+
.sort((a, b) => b.length - a.length)
|
|
94
|
+
.map(toPattern)
|
|
95
|
+
.join("|");
|
|
96
|
+
const branches = [
|
|
97
|
+
names.length ? `(?<name>${alternation(names, escapeRegExp)})(?![\\w$])` : "",
|
|
98
|
+
configPaths.length ? `(?<path>${alternation(configPaths, caseInsensitivePattern)})(?!(?!__)[\\w$])` : "",
|
|
99
|
+
].filter(Boolean);
|
|
100
|
+
if (branches.length === 0)
|
|
101
|
+
return [];
|
|
102
|
+
const pattern = new RegExp(`(?<![\\w$])(?:${branches.join("|")})`, "g");
|
|
103
|
+
const hits = [];
|
|
104
|
+
const seen = new Set();
|
|
105
|
+
for (const ctx of projects) {
|
|
106
|
+
for (const file of ctx.files) {
|
|
107
|
+
const json = file.endsWith(".json");
|
|
108
|
+
// Of the `.json` files, only launchSettings.json sets environment variables.
|
|
109
|
+
if (seen.has(file) || (json && (configPaths.length === 0 || path.basename(file) !== "launchSettings.json")))
|
|
110
|
+
continue;
|
|
111
|
+
seen.add(file);
|
|
112
|
+
let text;
|
|
113
|
+
try {
|
|
114
|
+
text = toLf(fs.readFileSync(file, "utf8"));
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
pattern.lastIndex = 0;
|
|
120
|
+
if (!pattern.test(text))
|
|
121
|
+
continue;
|
|
122
|
+
pattern.lastIndex = 0;
|
|
123
|
+
const keyValue = isKeyValueConfig(file);
|
|
124
|
+
const syntax = commentSyntax(file);
|
|
125
|
+
const mask = syntax ? computeLexicalMask(text, syntax) : null;
|
|
126
|
+
const source = new SourceText(file, text);
|
|
127
|
+
for (const m of text.matchAll(pattern)) {
|
|
128
|
+
const isPath = m.groups?.path !== undefined;
|
|
129
|
+
const cls = mask ? mask[m.index] : 0;
|
|
130
|
+
if (!isPath && (json || cls === LEX_STRING))
|
|
131
|
+
continue;
|
|
132
|
+
const end = m.index + m[0].length;
|
|
133
|
+
hits.push({
|
|
134
|
+
project: ctx.name,
|
|
135
|
+
file,
|
|
136
|
+
line: source.position(m.index).line,
|
|
137
|
+
token: m[0],
|
|
138
|
+
inComment: cls === LEX_COMMENT,
|
|
139
|
+
isConfigKey: keyValue &&
|
|
140
|
+
CONFIG_KEY_HEAD.test(text.slice(text.lastIndexOf("\n", m.index - 1) + 1, m.index)) &&
|
|
141
|
+
CONFIG_KEY_TAIL.test(text.slice(end, end + 80)),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return hits;
|
|
147
|
+
}
|
|
148
|
+
/** `name` as a regex source that matches it in any letter case (`[fF][eE]...`). */
|
|
149
|
+
function caseInsensitivePattern(name) {
|
|
150
|
+
return [...name]
|
|
151
|
+
.map((c) => (c.toLowerCase() === c.toUpperCase() ? escapeRegExp(c) : `[${c.toLowerCase()}${c.toUpperCase()}]`))
|
|
152
|
+
.join("");
|
|
153
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
/** Root of the installed flagrm package (holds package.json and skills/); this file is src/core or dist/core. */
|
|
5
|
+
export function packageRoot() {
|
|
6
|
+
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
7
|
+
}
|
|
8
|
+
/** The flagrm version from package.json, or `0.0.0` when it can't be read. */
|
|
9
|
+
export function packageVersion() {
|
|
10
|
+
try {
|
|
11
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot(), "package.json"), "utf8"));
|
|
12
|
+
return pkg.version ?? "0.0.0";
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return "0.0.0";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { angularAdapter } from "../adapters/angular/index.js";
|
|
2
|
+
import { dotnetAdapter } from "../adapters/dotnet/index.js";
|
|
3
|
+
import { genericAdapter } from "../adapters/generic/index.js";
|
|
4
|
+
import { listProjectFiles } from "./scan.js";
|
|
5
|
+
const ADAPTERS = {
|
|
6
|
+
angular: angularAdapter,
|
|
7
|
+
dotnet: dotnetAdapter,
|
|
8
|
+
generic: genericAdapter,
|
|
9
|
+
};
|
|
10
|
+
export function getAdapter(id) {
|
|
11
|
+
return ADAPTERS[id];
|
|
12
|
+
}
|
|
13
|
+
export function createProjectContext(project, config) {
|
|
14
|
+
const adapter = getAdapter(project.adapter);
|
|
15
|
+
return {
|
|
16
|
+
name: project.name,
|
|
17
|
+
root: project.path,
|
|
18
|
+
project,
|
|
19
|
+
config,
|
|
20
|
+
files: listProjectFiles(project.path, project.globs ?? adapter.defaultGlobs, config),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/** Every configured project paired with its adapter, in config order. Throws on an unavailable adapter. */
|
|
24
|
+
export function resolveProjects(config) {
|
|
25
|
+
return config.projects.map((project) => ({
|
|
26
|
+
adapter: getAdapter(project.adapter),
|
|
27
|
+
ctx: createProjectContext(project, config),
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
import { plural, relativePath } from "./util.js";
|
|
3
|
+
import { shortTestNames } from "./verify/test-results.js";
|
|
4
|
+
/** A path relative to the working directory, with `/` separators on every platform. */
|
|
5
|
+
function rel(file) {
|
|
6
|
+
return relativePath(process.cwd(), file) || file;
|
|
7
|
+
}
|
|
8
|
+
export function printInventory(report) {
|
|
9
|
+
const { flags, projects } = report;
|
|
10
|
+
if (flags.length === 0) {
|
|
11
|
+
console.log(pc.dim("No feature flags found."));
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
console.log(pc.bold(`${plural(flags.length, "flag")} in ${plural(projects.length, "project")}`) +
|
|
15
|
+
pc.dim(` (${projects.map((p) => p.name).join(", ")})\n`));
|
|
16
|
+
const pad = (values) => {
|
|
17
|
+
const width = Math.max(0, ...values.map((v) => v.length));
|
|
18
|
+
return (v) => v.padEnd(width);
|
|
19
|
+
};
|
|
20
|
+
for (const entry of flags) {
|
|
21
|
+
const { references: refs } = entry;
|
|
22
|
+
const byProject = Object.entries(refs.byProject)
|
|
23
|
+
.map(([name, n]) => `${name} ${n}`)
|
|
24
|
+
.join(", ");
|
|
25
|
+
const usage = refs.total === 0
|
|
26
|
+
? pc.yellow("no references")
|
|
27
|
+
: `${plural(refs.total, "ref")} in ${plural(refs.files, "file")}` +
|
|
28
|
+
(refs.tests ? ` (${refs.tests} in tests)` : "") +
|
|
29
|
+
pc.dim(` · ${byProject}`);
|
|
30
|
+
console.log(`${pc.bold(entry.flag)} ${pc.dim(`${entry.type} · ${stateLabel(entry.state)} ·`)} ${usage}`);
|
|
31
|
+
const project = pad([...entry.definitions, ...entry.config].map((x) => x.project));
|
|
32
|
+
const kind = pad(entry.definitions.map((d) => d.kind));
|
|
33
|
+
const name = pad(entry.definitions.map((d) => d.name));
|
|
34
|
+
for (const d of entry.definitions) {
|
|
35
|
+
console.log(` ${pc.dim("defined")} ${pc.dim(project(d.project))} ${pc.dim(kind(d.kind))} ${name(d.name)} ${pc.cyan(`${rel(d.file)}:${d.line}`)}`);
|
|
36
|
+
}
|
|
37
|
+
const env = pad(entry.config.map((c) => c.environment));
|
|
38
|
+
const state = pad(entry.config.map((c) => configLabel(c.state, c.filters)));
|
|
39
|
+
for (const c of entry.config) {
|
|
40
|
+
console.log(` ${pc.dim("config ")} ${pc.dim(project(c.project))} ${pc.dim(env(c.environment))} ${state(configLabel(c.state, c.filters))} ${pc.cyan(`${rel(c.file)}:${c.line}`)}`);
|
|
41
|
+
}
|
|
42
|
+
console.log("");
|
|
43
|
+
}
|
|
44
|
+
console.log(pc.dim("Reference counts are direct uses of the flag's literal and definitions, not reads through wrappers."));
|
|
45
|
+
}
|
|
46
|
+
function stateLabel(state) {
|
|
47
|
+
return state === "unconfigured" ? "not configured" : state;
|
|
48
|
+
}
|
|
49
|
+
function configLabel(state, filters) {
|
|
50
|
+
return filters?.length ? `${state} (${filters.join(", ")})` : state;
|
|
51
|
+
}
|
|
52
|
+
export function printBaseline(baseline, file) {
|
|
53
|
+
const { git, checks } = baseline;
|
|
54
|
+
const state = git.sha
|
|
55
|
+
? `git ${git.sha.slice(0, 7)}${git.dirty ? ", uncommitted changes" : ""}`
|
|
56
|
+
: "not a git repository";
|
|
57
|
+
console.log(`${pc.green(`Baseline for ${baseline.flag} written to ${rel(file)}`)} ${pc.dim(`(${state})`)}`);
|
|
58
|
+
for (const c of checks ?? []) {
|
|
59
|
+
const status = c.exitCode === 0 ? pc.green("pass") : pc.red(`fail (exit ${c.exitCode})`);
|
|
60
|
+
console.log(` ${c.project} ${c.check}: ${status} ${pc.dim(`${c.command} — ${Math.round(c.durationMs / 100) / 10}s`)}`);
|
|
61
|
+
}
|
|
62
|
+
if (baseline.names.length) {
|
|
63
|
+
const names = baseline.names.map((n) => (n.kind === "literal" ? n.name : `${n.name} (${n.kind})`));
|
|
64
|
+
console.log(` names: ${pc.dim(names.join(", "))}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const CHECK_STYLE = {
|
|
68
|
+
pass: { icon: "✓", color: pc.green },
|
|
69
|
+
fail: { icon: "✗", color: pc.red },
|
|
70
|
+
warn: { icon: "!", color: pc.yellow },
|
|
71
|
+
skipped: { icon: "–", color: pc.dim },
|
|
72
|
+
};
|
|
73
|
+
/** How many findings to print per check before summarizing the rest. */
|
|
74
|
+
const MAX_FINDINGS = 15;
|
|
75
|
+
export function printVerifyReport(report, file) {
|
|
76
|
+
const failed = report.checks.filter((c) => c.status === "fail").length;
|
|
77
|
+
const warned = report.checks.filter((c) => c.status === "warn").length;
|
|
78
|
+
const verdict = report.status === "pass" ? pc.green(pc.bold("PASS")) : pc.red(pc.bold("FAIL"));
|
|
79
|
+
const tally = [failed && `${failed} failed`, warned && `${warned} with warnings`].filter(Boolean).join(", ");
|
|
80
|
+
console.log(`\n${pc.bold(`flagrm verify ${report.flag}`)} — ${verdict}${tally ? pc.dim(` (${tally})`) : ""}\n`);
|
|
81
|
+
const width = Math.max(...report.checks.map((c) => c.id.length));
|
|
82
|
+
for (const check of report.checks) {
|
|
83
|
+
const { icon, color } = CHECK_STYLE[check.status];
|
|
84
|
+
console.log(` ${color(icon)} ${check.id.padEnd(width)} ${check.status === "pass" ? pc.dim(check.summary) : check.summary}`);
|
|
85
|
+
if (check.status === "pass" || check.status === "skipped")
|
|
86
|
+
continue;
|
|
87
|
+
const shown = check.findings.slice(0, MAX_FINDINGS);
|
|
88
|
+
for (const f of shown) {
|
|
89
|
+
const where = f.file ? `${pc.cyan(`${rel(f.file)}${f.line ? `:${f.line}` : ""}`)} ` : "";
|
|
90
|
+
const project = f.project ? pc.dim(`[${f.project}] `) : "";
|
|
91
|
+
const text = f.severity === "info" ? pc.dim(f.message) : f.message;
|
|
92
|
+
console.log(` ${project}${where}${text}`);
|
|
93
|
+
}
|
|
94
|
+
if (check.findings.length > shown.length) {
|
|
95
|
+
console.log(pc.dim(` … ${check.findings.length - shown.length} more (see --json)`));
|
|
96
|
+
}
|
|
97
|
+
if (check.id === "tests") {
|
|
98
|
+
const removed = shortTestNames(report.tests.flatMap((t) => t.removed));
|
|
99
|
+
for (const name of removed.slice(0, MAX_FINDINGS))
|
|
100
|
+
console.log(pc.dim(` no longer runs: ${name}`));
|
|
101
|
+
if (removed.length > MAX_FINDINGS)
|
|
102
|
+
console.log(pc.dim(` … ${removed.length - MAX_FINDINGS} more (see --md)`));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (report.strict && warned)
|
|
106
|
+
console.log(pc.dim("\n --strict: warnings count as failures."));
|
|
107
|
+
console.log(pc.dim(`\nResult written to ${rel(file)}.`));
|
|
108
|
+
}
|
|
109
|
+
const STEP_STYLE = {
|
|
110
|
+
created: pc.green,
|
|
111
|
+
updated: pc.green,
|
|
112
|
+
unchanged: pc.dim,
|
|
113
|
+
skipped: pc.dim,
|
|
114
|
+
};
|
|
115
|
+
/** One line per `init`/`update` step: `created .claude/skills/flagrm-remove`. */
|
|
116
|
+
export function printSteps(steps) {
|
|
117
|
+
for (const s of steps) {
|
|
118
|
+
console.log(STEP_STYLE[s.status](`${s.status.padEnd(9)} ${s.path}${s.note ? ` — ${s.note}` : ""}`));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const DOCTOR_STYLE = {
|
|
122
|
+
ok: { icon: "✓", color: pc.green },
|
|
123
|
+
warn: { icon: "!", color: pc.yellow },
|
|
124
|
+
fail: { icon: "✗", color: pc.red },
|
|
125
|
+
};
|
|
126
|
+
/** One line per doctor check, then the overall result. */
|
|
127
|
+
export function printDoctor(report) {
|
|
128
|
+
for (const c of report.checks) {
|
|
129
|
+
const { icon, color } = DOCTOR_STYLE[c.status];
|
|
130
|
+
console.log(` ${color(icon)} ${c.status === "ok" ? pc.dim(c.message) : c.message}`);
|
|
131
|
+
for (const line of c.details ?? [])
|
|
132
|
+
console.log(pc.dim(` ${line}`));
|
|
133
|
+
}
|
|
134
|
+
console.log(report.status === "ok" ? pc.green("\nflagrm doctor: ok") : pc.red("\nflagrm doctor: problems found"));
|
|
135
|
+
}
|