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,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Language-agnostic text helpers for the generic adapter: config-file
|
|
3
|
+
* detection, eval-call patterns, `NAME = "flag"` declarations and paren
|
|
4
|
+
* matching, all skipping comments with a per-extension lexical mask.
|
|
5
|
+
*/
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { commentSyntax, computeLexicalMask, LEX_CODE, methodNamePattern } from "../../core/scan.js";
|
|
8
|
+
const CONFIG_EXTENSIONS = new Set([".json", ".yaml", ".yml", ".toml", ".properties", ".ini", ".env"]);
|
|
9
|
+
/** Configuration files: every mention of the flag in one is a `config` site. */
|
|
10
|
+
export function isConfigFile(file) {
|
|
11
|
+
const base = path.basename(file).toLowerCase();
|
|
12
|
+
return CONFIG_EXTENSIONS.has(path.extname(base)) || base === ".env" || base.startsWith(".env.");
|
|
13
|
+
}
|
|
14
|
+
/** Code/comment/string classes per character, or null when the file's comment syntax is unknown (all code). */
|
|
15
|
+
export function lexicalMask(file, text) {
|
|
16
|
+
const syntax = commentSyntax(file);
|
|
17
|
+
return syntax ? computeLexicalMask(text, syntax) : null;
|
|
18
|
+
}
|
|
19
|
+
/** `(?<![\w$])(?:IsEnabled|...)\s*\(` for the configured eval methods, or undefined when there are none. */
|
|
20
|
+
export function evalCallPattern(methods) {
|
|
21
|
+
if (methods.length === 0)
|
|
22
|
+
return undefined;
|
|
23
|
+
return new RegExp(`(?<![\\w$])(?:${methods.map(methodNamePattern).join("|")})\\s*\\(`, "g");
|
|
24
|
+
}
|
|
25
|
+
/** Declaration keywords, modifiers and built-in string types that are never the declared name. */
|
|
26
|
+
const NOT_A_NAME = new Set([
|
|
27
|
+
"const",
|
|
28
|
+
"let",
|
|
29
|
+
"var",
|
|
30
|
+
"val",
|
|
31
|
+
"final",
|
|
32
|
+
"static",
|
|
33
|
+
"public",
|
|
34
|
+
"private",
|
|
35
|
+
"protected",
|
|
36
|
+
"internal",
|
|
37
|
+
"readonly",
|
|
38
|
+
"export",
|
|
39
|
+
"pub",
|
|
40
|
+
"mut",
|
|
41
|
+
"my",
|
|
42
|
+
"our",
|
|
43
|
+
"local",
|
|
44
|
+
"string",
|
|
45
|
+
"String",
|
|
46
|
+
"str",
|
|
47
|
+
]);
|
|
48
|
+
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|
|
49
|
+
/**
|
|
50
|
+
* The name a string literal starting at `quote` is assigned to, on the same
|
|
51
|
+
* line: `X = "flag"`, `X := "flag"`, `const X string = "flag"`,
|
|
52
|
+
* `static final String X = "flag"`, `X: str = "flag"`. Undefined for
|
|
53
|
+
* comparisons, keyword arguments, member assignments (`self.x = ...`) and
|
|
54
|
+
* anything else that isn't a plain declaration. Go writes the type after the
|
|
55
|
+
* name, so in `.go` files the first identifier wins, elsewhere the last.
|
|
56
|
+
*/
|
|
57
|
+
export function declaredName(text, quote, file) {
|
|
58
|
+
let eq = quote - 1;
|
|
59
|
+
while (eq >= 0 && (text[eq] === " " || text[eq] === "\t"))
|
|
60
|
+
eq--;
|
|
61
|
+
if (text[eq] !== "=" || /[=!<>+\-*/%&|^~]/.test(text[eq - 1] ?? ""))
|
|
62
|
+
return undefined;
|
|
63
|
+
const lineStart = text.lastIndexOf("\n", eq - 1) + 1;
|
|
64
|
+
let prefix = text.slice(lineStart, eq);
|
|
65
|
+
if (prefix.endsWith(":"))
|
|
66
|
+
prefix = prefix.slice(0, -1);
|
|
67
|
+
const count = (c) => prefix.split(c).length - 1;
|
|
68
|
+
if (count("(") > count(")") || count("[") > count("]"))
|
|
69
|
+
return undefined;
|
|
70
|
+
// A type annotation (`X: str`, `X: &'static str`).
|
|
71
|
+
prefix = prefix.replace(/:\s*[&\w$.<>[\], ']+$/, "");
|
|
72
|
+
const tokens = [...prefix.matchAll(/[^\s,;{}]+/g)];
|
|
73
|
+
if (tokens.length === 0 || !IDENTIFIER.test(tokens[tokens.length - 1][0]))
|
|
74
|
+
return undefined;
|
|
75
|
+
const names = tokens.filter((t) => !NOT_A_NAME.has(t[0]));
|
|
76
|
+
if (names.length === 0 || !names.every((t) => IDENTIFIER.test(t[0])))
|
|
77
|
+
return undefined;
|
|
78
|
+
const pick = file.endsWith(".go") ? names[0] : names[names.length - 1];
|
|
79
|
+
return { name: pick[0], start: lineStart + pick.index };
|
|
80
|
+
}
|
|
81
|
+
/** Index of the `)` matching the `(` at `open`, skipping strings and comments; -1 when unbalanced. */
|
|
82
|
+
export function closingParen(text, mask, open) {
|
|
83
|
+
let depth = 0;
|
|
84
|
+
for (let i = open; i < text.length; i++) {
|
|
85
|
+
if (mask && mask[i] !== LEX_CODE)
|
|
86
|
+
continue;
|
|
87
|
+
if (text[i] === "(")
|
|
88
|
+
depth++;
|
|
89
|
+
else if (text[i] === ")" && --depth === 0)
|
|
90
|
+
return i;
|
|
91
|
+
}
|
|
92
|
+
return -1;
|
|
93
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import pc from "picocolors";
|
|
6
|
+
import { addNames, baselinePath, discoverNames, flagDir, mergeNames, runChecks, validateAgentNames, writeBaseline, } from "./core/baseline.js";
|
|
7
|
+
import { loadConfig } from "./core/config.js";
|
|
8
|
+
import { runDoctor } from "./core/doctor.js";
|
|
9
|
+
import { gitState } from "./core/git.js";
|
|
10
|
+
import { hookRoot, parseHookInput, stopDecision } from "./core/hook.js";
|
|
11
|
+
import { initProject, installRoot, updateProject } from "./core/install.js";
|
|
12
|
+
import { buildInventory } from "./core/inventory.js";
|
|
13
|
+
import { verifyMarkdown } from "./core/markdown.js";
|
|
14
|
+
import { packageVersion } from "./core/package.js";
|
|
15
|
+
import { resolveProjects } from "./core/registry.js";
|
|
16
|
+
import { printBaseline, printDoctor, printInventory, printSteps, printVerifyReport } from "./core/report.js";
|
|
17
|
+
import { CHECK_IDS } from "./core/types.js";
|
|
18
|
+
import { relativePath } from "./core/util.js";
|
|
19
|
+
import { parseSkip, verifyFlag } from "./core/verify/index.js";
|
|
20
|
+
const program = new Command();
|
|
21
|
+
program
|
|
22
|
+
.name("flagrm")
|
|
23
|
+
.description("Guardrails for removing feature flags with an AI coding agent: set up the skills and hook (init, update, " +
|
|
24
|
+
"doctor), find flags (list), record the state before a removal (baseline) and gate the result (verify).")
|
|
25
|
+
.version(packageVersion());
|
|
26
|
+
/** Accumulate repeatable options (`--exclude a --exclude b`) into an array. */
|
|
27
|
+
function collect(value, previous) {
|
|
28
|
+
return previous.concat([value]);
|
|
29
|
+
}
|
|
30
|
+
function addGlobOptions(cmd) {
|
|
31
|
+
return cmd
|
|
32
|
+
.option("--exclude <glob>", "additional glob to exclude from scanning (repeatable)", collect, [])
|
|
33
|
+
.option("--include <glob>", "additional glob to include when scanning (repeatable)", collect, []);
|
|
34
|
+
}
|
|
35
|
+
function addCommonOptions(cmd) {
|
|
36
|
+
return addGlobOptions(cmd
|
|
37
|
+
.option("--frontend <path>", "path to an Angular project (adds or replaces the project named 'frontend')")
|
|
38
|
+
.option("--backend <path>", "path to a .NET project (adds or replaces the project named 'backend')")
|
|
39
|
+
.option("--config <path>", "path to a config file (default: the nearest flagrm.config.yaml up to the git repository root)")
|
|
40
|
+
.option("--json", "machine-readable JSON output"));
|
|
41
|
+
}
|
|
42
|
+
/** Print a usage or config error and end the command with exit code 2. */
|
|
43
|
+
class CliError extends Error {
|
|
44
|
+
}
|
|
45
|
+
function fail(err) {
|
|
46
|
+
throw new CliError(err instanceof Error ? err.message : String(err));
|
|
47
|
+
}
|
|
48
|
+
/** Load the config and pair every project with its adapter, exiting with code 2 on any config error. */
|
|
49
|
+
function loadWorkspace(opts) {
|
|
50
|
+
try {
|
|
51
|
+
const config = loadConfig(opts);
|
|
52
|
+
return { root: config.root, projects: resolveProjects(config) };
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
fail(err);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// --- baseline ------------------------------------------------------------------
|
|
59
|
+
addCommonOptions(program
|
|
60
|
+
.command("baseline <flag>")
|
|
61
|
+
.description("record the state before removing a flag: the git commit, each project's build and test results, and the " +
|
|
62
|
+
"flag's names for the leftovers check; writes .flagrm/<flag>/baseline.json for verify. With --name on an " +
|
|
63
|
+
"existing baseline, only adds names"))
|
|
64
|
+
.option("--no-checks", "record only the git state, without running build and test commands")
|
|
65
|
+
.option("--name <name>", "add a name the flag is read through (repeatable)", collect, [])
|
|
66
|
+
.option("--kind <kind>", "how --name entries are matched: alias (qualified constant) or wrapper (identifier, calls included)", "alias")
|
|
67
|
+
.option("--force", "overwrite an existing baseline")
|
|
68
|
+
.action(async (flag, opts) => {
|
|
69
|
+
const { root, projects } = loadWorkspace(opts);
|
|
70
|
+
if (opts.kind !== "alias" && opts.kind !== "wrapper") {
|
|
71
|
+
fail(new Error(`--kind must be alias or wrapper, not "${opts.kind}".`));
|
|
72
|
+
}
|
|
73
|
+
const added = opts.name.map((name) => ({
|
|
74
|
+
name,
|
|
75
|
+
kind: opts.kind,
|
|
76
|
+
source: "agent",
|
|
77
|
+
}));
|
|
78
|
+
let result;
|
|
79
|
+
try {
|
|
80
|
+
validateAgentNames(projects, added);
|
|
81
|
+
if (fs.existsSync(baselinePath(root, flag)) && !opts.force) {
|
|
82
|
+
if (added.length === 0) {
|
|
83
|
+
throw new Error(`Baseline for ${flag} already exists; pass --name to add names, or --force to start over.`);
|
|
84
|
+
}
|
|
85
|
+
result = addNames(root, flag, added);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
const git = gitState(root);
|
|
89
|
+
const checks = opts.checks ? await runChecks(projects, flagDir(root, flag)) : undefined;
|
|
90
|
+
result = writeBaseline(root, flag, projects, git, checks, mergeNames(await discoverNames(projects, flag), added));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
fail(err);
|
|
95
|
+
}
|
|
96
|
+
if (opts.json) {
|
|
97
|
+
console.log(JSON.stringify({ ...result.baseline, baselineFile: result.file }, null, 2));
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
printBaseline(result.baseline, result.file);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
// --- verify ------------------------------------------------------------------
|
|
104
|
+
addCommonOptions(program
|
|
105
|
+
.command("verify <flag>")
|
|
106
|
+
.description("gate a flag removal against its baseline (`flagrm baseline <flag>`): build, tests and new unused code; " +
|
|
107
|
+
"writes .flagrm/<flag>/verify.json; exit 0 pass, 1 fail, 2 usage error"))
|
|
108
|
+
.option("--skip <checks>", `comma-separated checks to skip (${CHECK_IDS.join(", ")})`)
|
|
109
|
+
.option("--strict", "treat warnings as failures")
|
|
110
|
+
.option("--md", "print a markdown overview (for the user or a PR) instead of text")
|
|
111
|
+
.action(async (flag, opts) => {
|
|
112
|
+
if (opts.md && opts.json)
|
|
113
|
+
fail(new Error("--md and --json can't be combined."));
|
|
114
|
+
const { root, projects } = loadWorkspace(opts);
|
|
115
|
+
let result;
|
|
116
|
+
try {
|
|
117
|
+
result = await verifyFlag(root, projects, flag, { skip: parseSkip(opts.skip), strict: opts.strict });
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
fail(err);
|
|
121
|
+
}
|
|
122
|
+
if (opts.json) {
|
|
123
|
+
console.log(JSON.stringify({ ...result.report, verifyFile: result.file }, null, 2));
|
|
124
|
+
}
|
|
125
|
+
else if (opts.md) {
|
|
126
|
+
process.stdout.write(verifyMarkdown(result.report, root));
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
printVerifyReport(result.report, result.file);
|
|
130
|
+
}
|
|
131
|
+
// exitCode, not exit(): exit() can cut off stdout still being flushed to a pipe.
|
|
132
|
+
process.exitCode = result.report.status === "pass" ? 0 : 1;
|
|
133
|
+
});
|
|
134
|
+
// --- list ----------------------------------------------------------------
|
|
135
|
+
addCommonOptions(program
|
|
136
|
+
.command("list")
|
|
137
|
+
.description("inventory the workspace's feature flags: definitions (constants, enums, flags objects), " +
|
|
138
|
+
"type and configured state per environment file (appsettings, environment.ts), " +
|
|
139
|
+
"direct reference counts and the projects involved")).action(async (opts) => {
|
|
140
|
+
const report = await buildInventory(loadWorkspace(opts).projects);
|
|
141
|
+
if (opts.json) {
|
|
142
|
+
console.log(JSON.stringify(report, null, 2));
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
printInventory(report);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
// --- hook --------------------------------------------------------------------
|
|
149
|
+
const hook = program.command("hook").description("entry points for agent hooks (installed by flagrm init)");
|
|
150
|
+
hook
|
|
151
|
+
.command("stop")
|
|
152
|
+
.description("Claude Code Stop hook: block stopping while a flag removal is not verified; always exits 0")
|
|
153
|
+
.action(() => {
|
|
154
|
+
// Anything unexpected allows the stop: a broken hook must never trap the user.
|
|
155
|
+
try {
|
|
156
|
+
const input = process.stdin.isTTY ? {} : parseHookInput(fs.readFileSync(0, "utf8"));
|
|
157
|
+
const decision = stopDecision(hookRoot(input.cwd ?? process.cwd()), input);
|
|
158
|
+
if (decision.block)
|
|
159
|
+
console.log(JSON.stringify({ decision: "block", reason: decision.reason }));
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
// Allow the stop.
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
// --- init --------------------------------------------------------------------
|
|
166
|
+
program
|
|
167
|
+
.command("init")
|
|
168
|
+
.description("set up flagrm in this repository: flagrm.config.yaml, the flagrm-remove and flagrm-verify skills for Claude Code, " +
|
|
169
|
+
"GitHub Copilot and Codex, the Claude Code Stop hook and the .flagrm/ gitignore entry; never overwrites")
|
|
170
|
+
.action(async () => {
|
|
171
|
+
const root = installRoot(process.cwd());
|
|
172
|
+
printInstallRoot(root);
|
|
173
|
+
printSteps(initProject(root));
|
|
174
|
+
console.log(pc.bold("\nflagrm doctor"));
|
|
175
|
+
printDoctor(await runDoctor(root));
|
|
176
|
+
console.log(pc.dim("\nNext: fill in flagrm.config.yaml, then ask your agent: /flagrm-remove <FlagName>"));
|
|
177
|
+
});
|
|
178
|
+
// --- update ------------------------------------------------------------------
|
|
179
|
+
program
|
|
180
|
+
.command("update")
|
|
181
|
+
.description("refresh the installed skills, the AGENTS.md block and the Stop hook from this flagrm version " +
|
|
182
|
+
"(only where flagrm init installed them)")
|
|
183
|
+
.action(() => {
|
|
184
|
+
const root = installRoot(process.cwd());
|
|
185
|
+
printInstallRoot(root);
|
|
186
|
+
printSteps(updateProject(root));
|
|
187
|
+
});
|
|
188
|
+
/** Say where init/update install when it isn't the working directory. */
|
|
189
|
+
function printInstallRoot(root) {
|
|
190
|
+
if (path.resolve(root) !== path.resolve(process.cwd())) {
|
|
191
|
+
console.log(pc.dim(`Installing in ${relativePath(process.cwd(), root)}`));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// --- doctor ------------------------------------------------------------------
|
|
195
|
+
program
|
|
196
|
+
.command("doctor")
|
|
197
|
+
.description("check that this repository is ready for flag removal: config, build/test commands, git, .gitignore, " +
|
|
198
|
+
"installed skills and Stop hook, abandoned baselines; exit 0 ok, 1 problems")
|
|
199
|
+
.option("--config <path>", "path to a config file (default: the nearest flagrm.config.yaml up to the git repository root)")
|
|
200
|
+
.option("--run", "also run each build and test command")
|
|
201
|
+
.option("--json", "machine-readable JSON output")
|
|
202
|
+
.action(async (opts) => {
|
|
203
|
+
const report = await runDoctor(process.cwd(), { config: opts.config, run: opts.run });
|
|
204
|
+
if (opts.json)
|
|
205
|
+
console.log(JSON.stringify(report, null, 2));
|
|
206
|
+
else
|
|
207
|
+
printDoctor(report);
|
|
208
|
+
process.exitCode = report.status === "ok" ? 0 : 1;
|
|
209
|
+
});
|
|
210
|
+
// exitCode, not exit(): exit() can cut off output still being flushed to a pipe.
|
|
211
|
+
program.parseAsync().catch((err) => {
|
|
212
|
+
if (!(err instanceof CliError))
|
|
213
|
+
throw err;
|
|
214
|
+
console.error(pc.red(err.message));
|
|
215
|
+
process.exitCode = 2;
|
|
216
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The diagnostics in `current` that `before` did not have. Line numbers move
|
|
3
|
+
* with the edits, so a diagnostic is matched by file, code and message (which
|
|
4
|
+
* names the member), counting repeats: a second identical message in a file
|
|
5
|
+
* (another unused `ex`) is new, not the baseline's.
|
|
6
|
+
*/
|
|
7
|
+
export function newDiagnostics(current, before) {
|
|
8
|
+
const key = (d) => `${d.file}\0${d.code}\0${d.message}`;
|
|
9
|
+
const remaining = new Map();
|
|
10
|
+
for (const d of before)
|
|
11
|
+
remaining.set(key(d), (remaining.get(key(d)) ?? 0) + 1);
|
|
12
|
+
return current.filter((d) => {
|
|
13
|
+
const n = remaining.get(key(d)) ?? 0;
|
|
14
|
+
if (n > 0)
|
|
15
|
+
remaining.set(key(d), n - 1);
|
|
16
|
+
return n === 0;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { buildInventory } from "./inventory.js";
|
|
6
|
+
import { JSON_SCHEMA_VERSION } from "./types.js";
|
|
7
|
+
import { collectTestResults } from "./verify/test-results.js";
|
|
8
|
+
/**
|
|
9
|
+
* `.flagrm/<flag>/` under the config root. A name that isn't safe as a directory
|
|
10
|
+
* (`team/new`, `..`) is sanitized and suffixed with a hash of the original, so
|
|
11
|
+
* it stays inside `.flagrm/` and can't collide with another flag's directory.
|
|
12
|
+
*/
|
|
13
|
+
export function flagDir(root, flag) {
|
|
14
|
+
const safe = flag.replace(/[^\w.-]/g, "_");
|
|
15
|
+
if (flag && safe === flag && !flag.startsWith("."))
|
|
16
|
+
return path.join(root, ".flagrm", flag);
|
|
17
|
+
const hash = createHash("sha256").update(flag).digest("hex").slice(0, 8);
|
|
18
|
+
return path.join(root, ".flagrm", `${safe.replace(/^\.+/, "")}-${hash}`);
|
|
19
|
+
}
|
|
20
|
+
export function baselinePath(root, flag) {
|
|
21
|
+
return path.join(flagDir(root, flag), "baseline.json");
|
|
22
|
+
}
|
|
23
|
+
/** `.flagrm/<flag>/verify.json`: the last verify result. */
|
|
24
|
+
export function verifyPath(root, flag) {
|
|
25
|
+
return path.join(flagDir(root, flag), "verify.json");
|
|
26
|
+
}
|
|
27
|
+
/** A problem with how flagrm was run (missing baseline, stale schema) rather than with the code under check. */
|
|
28
|
+
export class UsageError extends Error {
|
|
29
|
+
}
|
|
30
|
+
/** Read `.flagrm/<flag>/baseline.json`, which `flagrm baseline <flag>` writes. */
|
|
31
|
+
export function readBaseline(root, flag) {
|
|
32
|
+
const file = baselinePath(root, flag);
|
|
33
|
+
if (!fs.existsSync(file)) {
|
|
34
|
+
throw new UsageError(`No baseline for ${flag} at ${file}. Run \`flagrm baseline ${flag}\` before changing the code.`);
|
|
35
|
+
}
|
|
36
|
+
const baseline = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
37
|
+
if (baseline.schemaVersion !== JSON_SCHEMA_VERSION) {
|
|
38
|
+
throw new UsageError(`${file} was written by schema version ${baseline.schemaVersion}; this flagrm expects ${JSON_SCHEMA_VERSION}. ` +
|
|
39
|
+
`Re-run \`flagrm baseline ${flag} --force\` on the code as it was before the removal.`);
|
|
40
|
+
}
|
|
41
|
+
return { baseline, file };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Run one shell command inside a project, streaming its output to `logFile`
|
|
45
|
+
* (no buffer limit, stdout and stderr interleaved as printed). A command that
|
|
46
|
+
* can't start, is killed or outlives the project's `timeout` fails with the
|
|
47
|
+
* reason at the end of the log. A test run also reads the project's
|
|
48
|
+
* `testResults` files written during it.
|
|
49
|
+
*/
|
|
50
|
+
export async function runCheck(ctx, check, command, logFile) {
|
|
51
|
+
const started = Date.now();
|
|
52
|
+
fs.mkdirSync(path.dirname(logFile), { recursive: true });
|
|
53
|
+
fs.writeFileSync(logFile, `$ ${command}\n`, "utf8");
|
|
54
|
+
const fd = fs.openSync(logFile, "a");
|
|
55
|
+
let exit;
|
|
56
|
+
try {
|
|
57
|
+
exit = await runCommand(command, ctx.root, fd, ctx.project.timeout);
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
fs.closeSync(fd);
|
|
61
|
+
}
|
|
62
|
+
const reason = failureReason(exit, ctx.project.timeout);
|
|
63
|
+
if (reason)
|
|
64
|
+
fs.appendFileSync(logFile, `\nflagrm: error: ${reason}\n`, "utf8");
|
|
65
|
+
const run = {
|
|
66
|
+
project: ctx.name,
|
|
67
|
+
check,
|
|
68
|
+
command,
|
|
69
|
+
exitCode: exit.status ?? 1,
|
|
70
|
+
durationMs: Date.now() - started,
|
|
71
|
+
log: logFile,
|
|
72
|
+
};
|
|
73
|
+
const results = check === "test" && ctx.project.testResults ? collectTestResults(ctx.project.testResults, started) : undefined;
|
|
74
|
+
if (results)
|
|
75
|
+
run.results = results;
|
|
76
|
+
return run;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Run `command` in a shell with its output on `fd`. On timeout the whole
|
|
80
|
+
* process tree is killed: on Windows the shell is `cmd.exe`, and killing only
|
|
81
|
+
* it would leave the real command (`dotnet test`, `node`) running.
|
|
82
|
+
*/
|
|
83
|
+
function runCommand(command, cwd, fd, timeout) {
|
|
84
|
+
return new Promise((resolve) => {
|
|
85
|
+
const child = spawn(command, { cwd, shell: true, stdio: ["ignore", fd, fd], windowsHide: true });
|
|
86
|
+
let timedOut = false;
|
|
87
|
+
const timer = timeout
|
|
88
|
+
? setTimeout(() => {
|
|
89
|
+
timedOut = true;
|
|
90
|
+
killTree(child.pid, () => child.kill("SIGKILL"));
|
|
91
|
+
}, timeout * 1000)
|
|
92
|
+
: undefined;
|
|
93
|
+
child.on("error", (error) => {
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
resolve({ status: null, signal: null, error });
|
|
96
|
+
});
|
|
97
|
+
child.on("close", (status, signal) => {
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
resolve({ status, signal, timedOut });
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
function killTree(pid, fallback) {
|
|
104
|
+
if (pid !== undefined && process.platform === "win32") {
|
|
105
|
+
const r = spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
|
|
106
|
+
if (r.status === 0)
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
fallback();
|
|
110
|
+
}
|
|
111
|
+
/** Why a command ended without an exit code, or undefined when it exited normally. */
|
|
112
|
+
function failureReason(exit, timeout) {
|
|
113
|
+
if (exit.timedOut)
|
|
114
|
+
return `timed out after ${timeout}s (timeout in flagrm.config.yaml)`;
|
|
115
|
+
if (exit.error)
|
|
116
|
+
return `could not run the command: ${exit.error.message}`;
|
|
117
|
+
if (exit.status === null)
|
|
118
|
+
return `killed by ${exit.signal ?? "a signal"}`;
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
/** A project's build/test commands: the configured ones, or the adapter's defaults when it configures neither. */
|
|
122
|
+
export function projectCommands(adapter, ctx) {
|
|
123
|
+
const configured = ctx.project.build !== undefined || ctx.project.test !== undefined;
|
|
124
|
+
return configured ? { build: ctx.project.build, test: ctx.project.test } : (adapter.defaultCommands?.(ctx) ?? {});
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Why an Angular project's build can't catch template errors, if it can't:
|
|
128
|
+
* `tsc` never reads templates, so a member removed or renamed in a component
|
|
129
|
+
* but still used in its `.html` passes the build. `ngc` and `ng build` check them.
|
|
130
|
+
*/
|
|
131
|
+
export function templateCheckGap(adapter, build) {
|
|
132
|
+
if (adapter.id !== "angular" || !build || !/\btsc\b/.test(build))
|
|
133
|
+
return undefined;
|
|
134
|
+
if (/\bngc\b|\bng\s+build\b|\bnx\b/.test(build))
|
|
135
|
+
return undefined;
|
|
136
|
+
return "`tsc` doesn't type-check Angular templates: add `npx ngc -p <app>/tsconfig.json --noEmit` to `build` so a member removed from a component but still used in its template fails the build";
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Why a .NET build can't feed the dead-code check, if it can't: an incremental
|
|
140
|
+
* `dotnet build` skips compiling up-to-date projects and prints none of their
|
|
141
|
+
* warnings, so a baseline taken on a built tree records no pre-existing unused
|
|
142
|
+
* code and verify reports all of it as new.
|
|
143
|
+
*/
|
|
144
|
+
export function incrementalBuildGap(adapter, build) {
|
|
145
|
+
if (adapter.id !== "dotnet" || !build || !/\bdotnet\s+build\b/.test(build))
|
|
146
|
+
return undefined;
|
|
147
|
+
if (/--no-incremental\b|[-/]t(arget)?:\s*Rebuild\b/i.test(build))
|
|
148
|
+
return undefined;
|
|
149
|
+
return "`dotnet build` without `--no-incremental` skips up-to-date projects and prints none of their warnings, so the dead-code check may report existing unused code as new: add `--no-incremental` to `build`";
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Build then test every project. A project that configures `build` and/or
|
|
153
|
+
* `test` runs exactly those; one that configures neither gets the adapter's
|
|
154
|
+
* defaults (`dotnet build --no-incremental`, `npx ng test`, ...).
|
|
155
|
+
*/
|
|
156
|
+
export async function runChecks(projects, logDir, only = ["build", "test"]) {
|
|
157
|
+
const runs = [];
|
|
158
|
+
for (const { adapter, ctx } of projects) {
|
|
159
|
+
const commands = projectCommands(adapter, ctx);
|
|
160
|
+
for (const check of only) {
|
|
161
|
+
const command = commands[check];
|
|
162
|
+
if (command)
|
|
163
|
+
runs.push(await runCheck(ctx, check, command, path.join(logDir, `${ctx.name}.${check}.log`)));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return runs;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Write the flag's baseline: the git state and optional check runs. Take
|
|
170
|
+
* `git` before running checks, whose logs land under `.flagrm/` and would
|
|
171
|
+
* otherwise make the tree look dirty.
|
|
172
|
+
*/
|
|
173
|
+
export function writeBaseline(root, flag, projects, git, checks, names = []) {
|
|
174
|
+
const baseline = {
|
|
175
|
+
schemaVersion: JSON_SCHEMA_VERSION,
|
|
176
|
+
flag,
|
|
177
|
+
createdAt: new Date().toISOString(),
|
|
178
|
+
projects: projects.map(({ ctx }) => ({ name: ctx.name, adapter: ctx.project.adapter })),
|
|
179
|
+
git,
|
|
180
|
+
names,
|
|
181
|
+
};
|
|
182
|
+
if (checks)
|
|
183
|
+
baseline.checks = checks;
|
|
184
|
+
return saveBaseline(root, baseline);
|
|
185
|
+
}
|
|
186
|
+
function saveBaseline(root, baseline) {
|
|
187
|
+
const file = baselinePath(root, baseline.flag);
|
|
188
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
189
|
+
fs.writeFileSync(file, `${JSON.stringify(baseline, null, 2)}\n`, "utf8");
|
|
190
|
+
return { baseline, file };
|
|
191
|
+
}
|
|
192
|
+
/** The flag's names from discovery: the literal, then each definition's qualified name. */
|
|
193
|
+
export async function discoverNames(projects, flag) {
|
|
194
|
+
const entry = (await buildInventory(projects)).flags.find((f) => f.flag === flag);
|
|
195
|
+
return mergeNames([{ name: flag, kind: "literal", source: "discovery" }], (entry?.definitions ?? []).map((d) => ({ name: d.name, kind: "alias", source: "discovery" })));
|
|
196
|
+
}
|
|
197
|
+
/** `existing` followed by the names in `added` it doesn't already have. */
|
|
198
|
+
export function mergeNames(existing, added) {
|
|
199
|
+
const seen = new Set(existing.map((n) => n.name));
|
|
200
|
+
const merged = [...existing];
|
|
201
|
+
for (const n of added) {
|
|
202
|
+
if (seen.has(n.name))
|
|
203
|
+
continue;
|
|
204
|
+
seen.add(n.name);
|
|
205
|
+
merged.push(n);
|
|
206
|
+
}
|
|
207
|
+
return merged;
|
|
208
|
+
}
|
|
209
|
+
/** Append names to an existing baseline; its git state and checks stay as they are. */
|
|
210
|
+
export function addNames(root, flag, added) {
|
|
211
|
+
const { baseline } = readBaseline(root, flag);
|
|
212
|
+
return saveBaseline(root, { ...baseline, names: mergeNames(baseline.names, added) });
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Refuse names that would match far more than this flag: a configured flag
|
|
216
|
+
* evaluation method (`isEnabled`) is called for every flag in the project.
|
|
217
|
+
*/
|
|
218
|
+
export function validateAgentNames(projects, names) {
|
|
219
|
+
const methods = new Set(projects.flatMap(({ ctx }) => ctx.project.methods));
|
|
220
|
+
for (const { name } of names) {
|
|
221
|
+
if (methods.has(name)) {
|
|
222
|
+
throw new UsageError(`"${name}" is a flag evaluation method (methods in flagrm.config.yaml), so it matches every flag. ` +
|
|
223
|
+
"Record the wrapper or constant specific to this flag instead.");
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Up to `max` distinct lines of a log that look like errors, so the reader sees
|
|
229
|
+
* why a command failed without opening the log. Compiler warnings and summary
|
|
230
|
+
* lines such as `0 Error(s)` are skipped: in a large build they bury the one
|
|
231
|
+
* real error. When no line looks like an error, the last `max` lines instead.
|
|
232
|
+
*/
|
|
233
|
+
export function errorExcerpt(log, max = 10) {
|
|
234
|
+
if (!log)
|
|
235
|
+
return [];
|
|
236
|
+
let text;
|
|
237
|
+
try {
|
|
238
|
+
text = fs.readFileSync(log, "utf8");
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return [];
|
|
242
|
+
}
|
|
243
|
+
const all = text
|
|
244
|
+
.split(/\r?\n/)
|
|
245
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: strips ANSI colors (ngc colors even when piped)
|
|
246
|
+
.map((line) => line.replace(/\u001b\[[0-9;]*m/g, "").trim())
|
|
247
|
+
.filter((line) => line && !line.startsWith("$ "));
|
|
248
|
+
const lines = all
|
|
249
|
+
.filter((line) => /\berror\b|\bfailed\b|✗|×/i.test(line))
|
|
250
|
+
.filter((line) => !/:\s*warning\b/i.test(line) && !/^\d+ Error\(s\)$/i.test(line));
|
|
251
|
+
// No line says error (e.g. "A compatible .NET SDK was not found"): the end of the log usually does.
|
|
252
|
+
if (!lines.length)
|
|
253
|
+
return all.slice(-max);
|
|
254
|
+
return [...new Set(lines)].slice(0, max);
|
|
255
|
+
}
|