flagrm 0.1.1 → 0.2.1
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 +102 -0
- package/README.md +62 -21
- package/dist/adapters/dotnet/flow.js +429 -0
- package/dist/adapters/dotnet/index.js +2 -0
- package/dist/adapters/dotnet/setup.js +163 -0
- package/dist/cli.js +97 -19
- package/dist/core/baseline.js +140 -20
- package/dist/core/config-snapshot.js +183 -0
- package/dist/core/config.js +22 -1
- package/dist/core/doctor.js +37 -17
- package/dist/core/environment.js +79 -0
- package/dist/core/flag-words.js +36 -0
- package/dist/core/git.js +8 -0
- package/dist/core/hook.js +18 -8
- package/dist/core/install.js +128 -22
- package/dist/core/inventory.js +39 -0
- package/dist/core/markdown.js +16 -5
- package/dist/core/report.js +54 -11
- package/dist/core/scope.js +207 -0
- package/dist/core/tools.js +108 -0
- package/dist/core/types.js +7 -1
- package/dist/core/util.js +10 -0
- package/dist/core/verify/commands.js +127 -19
- package/dist/core/verify/context.js +9 -0
- package/dist/core/verify/deleted-text.js +180 -0
- package/dist/core/verify/index.js +3 -1
- package/dist/core/verify/leftovers.js +19 -2
- package/dist/core/verify/removed-tests.js +141 -0
- package/dist/core/verify/test-results.js +31 -6
- package/package.json +1 -1
- package/skills/flagrm-remove/SKILL.md +14 -14
package/dist/core/doctor.js
CHANGED
|
@@ -5,13 +5,17 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import fs from "node:fs";
|
|
7
7
|
import path from "node:path";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
8
|
+
import { realProbes } from "../adapters/dotnet/setup.js";
|
|
9
|
+
import { errorExcerpt, incrementalBuildGap, runCheck, templateCheckGap } from "./baseline.js";
|
|
10
|
+
import { findConfigFile, loadConfig, TOOL_IDS } from "./config.js";
|
|
11
|
+
import { projectCommands } from "./config-snapshot.js";
|
|
10
12
|
import { gitState, isAncestor } from "./git.js";
|
|
11
|
-
import {
|
|
13
|
+
import { installedSkillVersion, SKILLS, stopHookState, TOOL_SKILL_DIRS } from "./install.js";
|
|
12
14
|
import { packageVersion } from "./package.js";
|
|
13
15
|
import { resolveProjects } from "./registry.js";
|
|
14
|
-
import {
|
|
16
|
+
import { setupReport } from "./scope.js";
|
|
17
|
+
import { savedTools } from "./tools.js";
|
|
18
|
+
import { onPath, readJson, relativePath } from "./util.js";
|
|
15
19
|
/** Check the setup in `cwd`: config and commands, git, .gitignore, skills, hook and abandoned baselines. */
|
|
16
20
|
export async function runDoctor(cwd, options = {}) {
|
|
17
21
|
const checks = [];
|
|
@@ -30,8 +34,16 @@ export async function runDoctor(cwd, options = {}) {
|
|
|
30
34
|
const gitignore = path.join(root, ".gitignore");
|
|
31
35
|
const ignored = fs.existsSync(gitignore) && /^\/?\.flagrm\/?\s*$/m.test(fs.readFileSync(gitignore, "utf8"));
|
|
32
36
|
add(ignored ? "ok" : "warn", ignored ? ".flagrm/ is gitignored" : "add .flagrm/ to .gitignore (flagrm init does this)");
|
|
37
|
+
// Only the tools the config lists, when it lists them.
|
|
38
|
+
let tools = TOOL_IDS;
|
|
39
|
+
try {
|
|
40
|
+
tools = savedTools(root) ?? TOOL_IDS;
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
add("fail", `config: ${err instanceof Error ? err.message : String(err)}`);
|
|
44
|
+
}
|
|
33
45
|
const version = packageVersion();
|
|
34
|
-
for (const dir of
|
|
46
|
+
for (const dir of tools.map((t) => TOOL_SKILL_DIRS[t])) {
|
|
35
47
|
const versions = SKILLS.map((s) => installedSkillVersion(path.join(root, dir, s)));
|
|
36
48
|
if (versions.every((v) => v === undefined))
|
|
37
49
|
add("warn", `${dir}: skills not installed (run flagrm init)`);
|
|
@@ -42,12 +54,12 @@ export async function runDoctor(cwd, options = {}) {
|
|
|
42
54
|
else
|
|
43
55
|
add("ok", `${dir}: skills ${version}`);
|
|
44
56
|
}
|
|
45
|
-
const hook = stopHookState(root);
|
|
57
|
+
const hook = tools.includes("claude-code") ? stopHookState(root) : undefined;
|
|
46
58
|
if (hook === "current")
|
|
47
59
|
add("ok", "Claude Code Stop hook installed");
|
|
48
60
|
else if (hook === "outdated")
|
|
49
61
|
add("warn", "Claude Code Stop hook runs an outdated command (run flagrm update)");
|
|
50
|
-
else
|
|
62
|
+
else if (hook === "missing")
|
|
51
63
|
add("warn", "Claude Code Stop hook missing from .claude/settings.json (run flagrm init)");
|
|
52
64
|
if (git.sha !== null)
|
|
53
65
|
abandonedBaselines(root, add);
|
|
@@ -70,6 +82,7 @@ async function configChecks(cwd, configFile, options, add) {
|
|
|
70
82
|
add("fail", `config: ${err instanceof Error ? err.message : String(err)}`);
|
|
71
83
|
return;
|
|
72
84
|
}
|
|
85
|
+
setupChecks(projects, root, options.probes ?? realProbes((exe) => onPath(exe, root)), add);
|
|
73
86
|
for (const { adapter, ctx } of projects) {
|
|
74
87
|
const commands = projectCommands(adapter, ctx);
|
|
75
88
|
for (const check of ["build", "test"]) {
|
|
@@ -98,7 +111,23 @@ async function configChecks(cwd, configFile, options, add) {
|
|
|
98
111
|
add("ok", `${ctx.name}: \`${command}\` passed`);
|
|
99
112
|
continue;
|
|
100
113
|
}
|
|
101
|
-
add("fail", `${ctx.name}: \`${command}\` failed (exit ${run.exitCode}, log ${relativePath(cwd, run.log ?? "")})`, errorExcerpt(run.log, 3));
|
|
114
|
+
add("fail", `${ctx.name}: \`${command}\` failed (exit ${run.exitCode}, log ${relativePath(cwd, run.log ?? "")})`, run.failure ? [`setup problem: ${run.failure.message}`] : errorExcerpt(run.log, 3));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Whether the machine can build and test each .NET project: no SDK matching
|
|
120
|
+
* global.json fails (nothing builds), a missing `cargo` or Docker warns (a
|
|
121
|
+
* solution filter can leave out the projects that need them).
|
|
122
|
+
*/
|
|
123
|
+
function setupChecks(projects, root, probes, add) {
|
|
124
|
+
for (const s of setupReport(projects, root, probes)) {
|
|
125
|
+
if (s.sdk)
|
|
126
|
+
add("fail", `${s.project}: ${s.sdk}`);
|
|
127
|
+
const problems = s.setup?.problems ?? [];
|
|
128
|
+
if (problems.length) {
|
|
129
|
+
const out = s.setup?.leaveOut.length ?? 0;
|
|
130
|
+
add("warn", `${s.project}: ${problems.join("; ")} — \`flagrm scope\` shows the ${out} projects a solution filter would leave out`);
|
|
102
131
|
}
|
|
103
132
|
}
|
|
104
133
|
}
|
|
@@ -116,15 +145,6 @@ export function simpleCommandProgram(command) {
|
|
|
116
145
|
.split(/\s+/)
|
|
117
146
|
.find((word) => !/^\w+=/.test(word));
|
|
118
147
|
}
|
|
119
|
-
function onPath(exe, cwd) {
|
|
120
|
-
if (exe.includes("/") || exe.includes("\\"))
|
|
121
|
-
return fs.existsSync(path.resolve(cwd, exe));
|
|
122
|
-
const exts = process.platform === "win32" ? ["", ...(process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")] : [""];
|
|
123
|
-
return (process.env.PATH ?? "")
|
|
124
|
-
.split(path.delimiter)
|
|
125
|
-
.filter(Boolean)
|
|
126
|
-
.some((dir) => exts.some((ext) => fs.existsSync(path.join(dir, exe + ext))));
|
|
127
|
-
}
|
|
128
148
|
/** `.flagrm/<flag>/` entries whose baseline commit is not in this branch's history. */
|
|
129
149
|
function abandonedBaselines(root, add) {
|
|
130
150
|
const dir = path.join(root, ".flagrm");
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tell a machine setup problem apart from broken code in a failed build or
|
|
3
|
+
* test log: a missing .NET SDK, a tool a build step calls that isn't
|
|
4
|
+
* installed, Docker not running for Testcontainers. Each becomes one line
|
|
5
|
+
* that says what to fix.
|
|
6
|
+
*/
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { relativePath } from "./util.js";
|
|
10
|
+
/**
|
|
11
|
+
* The setup problems a log shows, as one actionable line, or undefined when
|
|
12
|
+
* it shows none. `root` is the project root: paths are reported relative to it.
|
|
13
|
+
*/
|
|
14
|
+
export function environmentProblem(log, root) {
|
|
15
|
+
const problems = [missingSdk(log, root), ...missingCommands(log, root), dockerUnavailable(log)].filter(Boolean);
|
|
16
|
+
return problems.length ? problems.join("; ") : undefined;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* `dotnet` exits 155 with "A compatible .NET SDK was not found" when no
|
|
20
|
+
* installed SDK satisfies global.json. It prints the requested version and the
|
|
21
|
+
* global.json path; the installed SDKs (`10.0.101 [/usr/local/share/dotnet/sdk]`)
|
|
22
|
+
* go to stdout, so in a combined log they may come after the message.
|
|
23
|
+
*/
|
|
24
|
+
function missingSdk(log, root) {
|
|
25
|
+
if (!/A compatible \.NET SDK was not found/.test(log))
|
|
26
|
+
return undefined;
|
|
27
|
+
const requested = /^Requested SDK version:\s*(\S+)/m.exec(log)?.[1];
|
|
28
|
+
const globalJson = /^global\.json file:\s*(.+?)\s*$/m.exec(log)?.[1];
|
|
29
|
+
const installed = [...new Set([...log.matchAll(/^(\d+\.\d+\.\d+\S*) \[[^\]]*sdk\]\s*$/gm)].map((m) => m[1]))];
|
|
30
|
+
const rollForward = globalJson ? readRollForward(globalJson) : undefined;
|
|
31
|
+
const wanted = [requested ?? "the version", rollForward && `rollForward: ${rollForward}`].filter(Boolean).join(", ");
|
|
32
|
+
const where = globalJson ? relativePath(root, globalJson) : "global.json";
|
|
33
|
+
return (`.NET SDK not found: ${where} requests ${wanted}; installed: ${installed.join(", ") || "none"}. ` +
|
|
34
|
+
`Install that SDK or change ${where}`);
|
|
35
|
+
}
|
|
36
|
+
function readRollForward(file) {
|
|
37
|
+
try {
|
|
38
|
+
const value = JSON.parse(fs.readFileSync(file, "utf8"))?.sdk?.rollForward;
|
|
39
|
+
return typeof value === "string" ? value : undefined;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Commands the shell couldn't find (exit 127; `9009` and "is not recognized"
|
|
47
|
+
* on Windows), with the MSBuild project whose `Exec` ran them when the log
|
|
48
|
+
* names it (`RustSdk.csproj(12,5): error MSB3073: The command "cargo build" exited with code 127`).
|
|
49
|
+
*/
|
|
50
|
+
function missingCommands(log, root) {
|
|
51
|
+
const commands = new Map();
|
|
52
|
+
const add = (command, project) => {
|
|
53
|
+
const name = path.basename(command);
|
|
54
|
+
const projects = commands.get(name) ?? new Set();
|
|
55
|
+
if (project)
|
|
56
|
+
projects.add(relativePath(root, path.resolve(root, project.trim())));
|
|
57
|
+
commands.set(name, projects);
|
|
58
|
+
};
|
|
59
|
+
for (const m of log.matchAll(/\b(?:ba|z|da)?sh: (?:line \d+: |\d+: )?([^\s:]+): (?:command )?not found/g))
|
|
60
|
+
add(m[1]);
|
|
61
|
+
for (const m of log.matchAll(/\bcommand not found: (\S+)/g))
|
|
62
|
+
add(m[1]);
|
|
63
|
+
for (const m of log.matchAll(/'([^']+)' is not recognized as an internal or external command/g))
|
|
64
|
+
add(m[1]);
|
|
65
|
+
for (const m of log.matchAll(/^\s*(.+?\.\w*proj)\(\d+,\d+\): error MSB3073: The command "([^\s"]+)[^"]*" exited with code (?:127|9009)\b/gm)) {
|
|
66
|
+
add(m[2].replace(/^["']|["']$/g, ""), m[1]);
|
|
67
|
+
}
|
|
68
|
+
return [...commands].map(([command, projects]) => {
|
|
69
|
+
const neededBy = projects.size ? ` (needed by ${[...projects].sort().join(", ")})` : "";
|
|
70
|
+
return `\`${command}\` is not installed or not on PATH${neededBy}`;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/** Testcontainers throws `DockerUnavailableException`; the docker CLI says it can't reach the daemon. */
|
|
74
|
+
function dockerUnavailable(log) {
|
|
75
|
+
const docker = /DockerUnavailableException|Docker is either not running or misconfigured|Cannot connect to the Docker daemon/i;
|
|
76
|
+
if (!docker.test(log))
|
|
77
|
+
return undefined;
|
|
78
|
+
return "Docker is not running (Testcontainers tests need it): start Docker or leave those tests out of `test`";
|
|
79
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The words a flag's names are made of, to recognize a name that is about the
|
|
3
|
+
* flag (`useVfo1Terminology`, `Save_Vfo1Enabled`) among ones that aren't.
|
|
4
|
+
*/
|
|
5
|
+
/** Words that say nothing about which flag a name is about. */
|
|
6
|
+
const GENERIC = new Set("feature features flag flags toggle key keys enabled disabled enable disable is has use get async on off the when with if and".split(" "));
|
|
7
|
+
/** Lowercase words of a name: `Vfo1FoundationEnabled_Saves` → vfo1, foundation, enabled, saves. */
|
|
8
|
+
export function words(name) {
|
|
9
|
+
return (name.match(/[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+[0-9]*|[A-Z]+[0-9]*|[0-9]+/g) ?? []).map((w) => w.toLowerCase());
|
|
10
|
+
}
|
|
11
|
+
/** Words of the flag's recorded names */
|
|
12
|
+
export function flagWords(names) {
|
|
13
|
+
return new Set(names.flatMap((n) => words(n.name)).filter((w) => !GENERIC.has(w)));
|
|
14
|
+
}
|
|
15
|
+
/** Whether `name` contains one of the flag's words. */
|
|
16
|
+
export function mentionsFlag(name, flag) {
|
|
17
|
+
return words(name).some((w) => flag.has(w));
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Whether `name` names this flag clearly enough to look for it in every file:
|
|
21
|
+
* it contains all words of the flag's name in order (`IsNewCheckoutEnabledAsync`
|
|
22
|
+
* for `NewCheckout`), or a flag word with a digit (`vfo1`), which ordinary
|
|
23
|
+
* names don't have. One plain word (`express`, `checkout`) is not enough.
|
|
24
|
+
* `flagNames` are the literal and the definitions' last segments.
|
|
25
|
+
*/
|
|
26
|
+
export function namesFlag(name, flagNames, flag) {
|
|
27
|
+
const own = words(name);
|
|
28
|
+
if (own.some((w) => flag.has(w) && /\d/.test(w) && /[a-z]/.test(w) && w.length >= 3))
|
|
29
|
+
return true;
|
|
30
|
+
return flagNames.some((flagName) => {
|
|
31
|
+
const seq = words(flagName).filter((w) => !GENERIC.has(w));
|
|
32
|
+
if (seq.length < 2)
|
|
33
|
+
return false;
|
|
34
|
+
return own.some((_, i) => seq.every((w, j) => own[i + j] === w));
|
|
35
|
+
});
|
|
36
|
+
}
|
package/dist/core/git.js
CHANGED
|
@@ -33,6 +33,14 @@ export function changedFilesSince(cwd, sha) {
|
|
|
33
33
|
return undefined;
|
|
34
34
|
return [...new Set([...paths(diff.stdout), ...untracked(cwd)])].sort();
|
|
35
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Tracked files modified or deleted in the working tree since `sha` (staged
|
|
38
|
+
* or not); untracked files don't count. Undefined when git can't tell.
|
|
39
|
+
*/
|
|
40
|
+
export function trackedChangesSince(cwd, sha) {
|
|
41
|
+
const diff = git(cwd, "diff", "-z", "--name-only", "--no-renames", "--relative", sha, ...PATHSPEC);
|
|
42
|
+
return diff.ok ? paths(diff.stdout).sort() : undefined;
|
|
43
|
+
}
|
|
36
44
|
/** A file's content at `sha`, or undefined when it did not exist there. */
|
|
37
45
|
export function fileAt(cwd, sha, relativePath) {
|
|
38
46
|
const result = git(cwd, "show", `${sha}:./${relativePath.split(path.sep).join("/")}`);
|
package/dist/core/hook.js
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import fs from "node:fs";
|
|
8
8
|
import path from "node:path";
|
|
9
|
-
import { findConfigRoot } from "./config.js";
|
|
10
|
-
import { headSha,
|
|
9
|
+
import { CONFIG_FILENAMES, findConfigRoot } from "./config.js";
|
|
10
|
+
import { headSha, trackedChangesSince, treeFingerprint } from "./git.js";
|
|
11
11
|
import { readJson } from "./util.js";
|
|
12
12
|
/** The hook's stdin: only a JSON object counts, and only fields of the expected type are kept. */
|
|
13
13
|
export function parseHookInput(text) {
|
|
@@ -33,10 +33,12 @@ export function hookRoot(cwd) {
|
|
|
33
33
|
return findConfigRoot(cwd) ?? cwd;
|
|
34
34
|
}
|
|
35
35
|
/**
|
|
36
|
-
* Whether to block the stop: a removal is in progress
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* removal
|
|
36
|
+
* Whether to block the stop: a removal is in progress and no passing verify
|
|
37
|
+
* matches the current tree. In progress means HEAD is still the baseline
|
|
38
|
+
* commit and a file git tracks changed, other than the flagrm config (a
|
|
39
|
+
* removal paused on a question to the user, or a stray test result file,
|
|
40
|
+
* doesn't count), or the last verify skipped checks. Once HEAD moves (a
|
|
41
|
+
* commit, a branch switch, a merge), that removal is no longer guarded.
|
|
40
42
|
*/
|
|
41
43
|
export function stopDecision(root, input) {
|
|
42
44
|
if (input.stop_hook_active)
|
|
@@ -55,16 +57,24 @@ export function stopDecision(root, input) {
|
|
|
55
57
|
.filter((flagDir) => readJson(path.join(flagDir, "baseline.json"))?.git?.sha === head);
|
|
56
58
|
if (active.length === 0)
|
|
57
59
|
return { block: false };
|
|
60
|
+
const edited = (trackedChangesSince(root, head) ?? []).some((rel) => !CONFIG_FILENAMES.includes(rel));
|
|
61
|
+
const started = active.filter((flagDir) => edited || lastVerifySkipped(flagDir));
|
|
62
|
+
if (started.length === 0)
|
|
63
|
+
return { block: false };
|
|
58
64
|
const current = treeFingerprint(root);
|
|
59
|
-
if (!current
|
|
65
|
+
if (!current)
|
|
60
66
|
return { block: false };
|
|
61
|
-
const reasons =
|
|
67
|
+
const reasons = started.map((flagDir) => unverified(flagDir, current)).filter((r) => r !== undefined);
|
|
62
68
|
return reasons.length ? { block: true, reason: reasons.join("\n") } : { block: false };
|
|
63
69
|
}
|
|
64
70
|
catch {
|
|
65
71
|
return { block: false };
|
|
66
72
|
}
|
|
67
73
|
}
|
|
74
|
+
function lastVerifySkipped(flagDir) {
|
|
75
|
+
const skipped = readJson(path.join(flagDir, "verify.json"))?.skipped;
|
|
76
|
+
return Array.isArray(skipped) && skipped.length > 0;
|
|
77
|
+
}
|
|
68
78
|
/** Why the in-progress removal in `flagDir` is not verified, or undefined when a full verify passed on `current`. */
|
|
69
79
|
function unverified(flagDir, current) {
|
|
70
80
|
const baseline = readJson(path.join(flagDir, "baseline.json"));
|
package/dist/core/install.js
CHANGED
|
@@ -1,18 +1,50 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* What `flagrm init` and `flagrm update` write into a repository: the config
|
|
3
|
-
* template
|
|
4
|
-
* Code
|
|
5
|
-
*
|
|
3
|
+
* template and the `.flagrm/` gitignore entry, then per AI coding tool its
|
|
4
|
+
* files: for Claude Code the skills and the Stop hook, for GitHub Copilot the
|
|
5
|
+
* skills and `/flagrm-*` prompt files, for Codex the skills and the AGENTS.md
|
|
6
|
+
* pointer. Every step reports what it did; nothing the user wrote is
|
|
7
|
+
* overwritten, and nothing is deleted.
|
|
6
8
|
*/
|
|
7
9
|
import fs from "node:fs";
|
|
8
10
|
import path from "node:path";
|
|
9
|
-
import { DEFAULT_BACKEND_ATTRIBUTES, DEFAULT_BACKEND_METHODS, DEFAULT_FRONTEND_METHODS, findConfigRoot, findDefaultConfigFile, repositoryRoot, } from "./config.js";
|
|
11
|
+
import { DEFAULT_BACKEND_ATTRIBUTES, DEFAULT_BACKEND_METHODS, DEFAULT_FRONTEND_METHODS, findConfigRoot, findDefaultConfigFile, repositoryRoot, TOOL_IDS, } from "./config.js";
|
|
10
12
|
import { detectProjects } from "./detect.js";
|
|
11
13
|
import { packageRoot, packageVersion } from "./package.js";
|
|
14
|
+
import { saveTools } from "./tools.js";
|
|
12
15
|
import { relativePath } from "./util.js";
|
|
13
16
|
export const SKILLS = ["flagrm-remove", "flagrm-verify"];
|
|
14
|
-
/** Where each supported
|
|
15
|
-
export const
|
|
17
|
+
/** Where each supported tool discovers repository skills. */
|
|
18
|
+
export const TOOL_SKILL_DIRS = {
|
|
19
|
+
"claude-code": ".claude/skills",
|
|
20
|
+
copilot: ".github/skills",
|
|
21
|
+
codex: ".agents/skills",
|
|
22
|
+
};
|
|
23
|
+
/** Every tool's skills directory: Claude Code, GitHub Copilot, Codex. */
|
|
24
|
+
export const AGENT_SKILL_DIRS = TOOL_IDS.map((t) => TOOL_SKILL_DIRS[t]);
|
|
25
|
+
/** GitHub Copilot prompt files: `/flagrm-remove` and `/flagrm-verify` in Copilot Chat, each pointing at its skill. */
|
|
26
|
+
export const PROMPT_FILES = {
|
|
27
|
+
"flagrm-remove": [
|
|
28
|
+
"---",
|
|
29
|
+
"description: Remove a feature flag with flagrm's guardrails (keep the ON path, verify until it passes)",
|
|
30
|
+
"argument-hint: <flag>",
|
|
31
|
+
"agent: agent",
|
|
32
|
+
"---",
|
|
33
|
+
"",
|
|
34
|
+
"Follow `.github/skills/flagrm-remove/SKILL.md` to remove the feature flag named after this command.",
|
|
35
|
+
"",
|
|
36
|
+
].join("\n"),
|
|
37
|
+
"flagrm-verify": [
|
|
38
|
+
"---",
|
|
39
|
+
"description: Check a feature flag removal with flagrm and propose its commit message",
|
|
40
|
+
"argument-hint: <flag>",
|
|
41
|
+
"agent: agent",
|
|
42
|
+
"---",
|
|
43
|
+
"",
|
|
44
|
+
"Follow `.github/skills/flagrm-verify/SKILL.md` for the feature flag named after this command.",
|
|
45
|
+
"",
|
|
46
|
+
].join("\n"),
|
|
47
|
+
};
|
|
16
48
|
/**
|
|
17
49
|
* The Stop hook command. npx runs the project's own flagrm (a dev dependency)
|
|
18
50
|
* and falls back to a global install. `--no-install` matters: the `flagrm` name
|
|
@@ -72,27 +104,31 @@ function projectLines(p) {
|
|
|
72
104
|
}
|
|
73
105
|
return lines;
|
|
74
106
|
}
|
|
75
|
-
export function configTemplate(projects) {
|
|
107
|
+
export function configTemplate(projects, tools) {
|
|
76
108
|
const listed = projects.length ? projects : EXAMPLE_PROJECTS;
|
|
77
|
-
|
|
109
|
+
const [comment1, comment2, projectsKey] = CONFIG_HEADER;
|
|
110
|
+
const toolLines = tools
|
|
111
|
+
? ["# The AI coding tools `flagrm init` and `flagrm update` set up.", `tools: [${tools.join(", ")}]`]
|
|
112
|
+
: [];
|
|
113
|
+
return [comment1, comment2, ...toolLines, projectsKey, ...listed.flatMap(projectLines), ...CONFIG_FOOTER].join("\n");
|
|
78
114
|
}
|
|
79
115
|
/** Write `flagrm.config.yaml` for the projects found in `cwd` (an example when none are) unless a config file exists. */
|
|
80
|
-
export function writeConfigTemplate(cwd) {
|
|
116
|
+
export function writeConfigTemplate(cwd, tools) {
|
|
81
117
|
const existing = findDefaultConfigFile(cwd);
|
|
82
118
|
if (existing)
|
|
83
119
|
return { path: relativePath(cwd, existing), status: "skipped" };
|
|
84
120
|
const file = path.join(cwd, "flagrm.config.yaml");
|
|
85
121
|
const projects = detectProjects(cwd);
|
|
86
|
-
fs.writeFileSync(file, configTemplate(projects), "utf8");
|
|
122
|
+
fs.writeFileSync(file, configTemplate(projects, tools), "utf8");
|
|
87
123
|
const note = projects.length
|
|
88
124
|
? `detected ${projects.map((p) => `${p.adapter} (${p.marker})`).join(", ")}`
|
|
89
125
|
: "no Angular or .NET project found: edit the example projects";
|
|
90
126
|
return { path: relativePath(cwd, file), status: "created", note };
|
|
91
127
|
}
|
|
92
|
-
/** Copy each skill into each
|
|
93
|
-
export function installSkills(cwd, mode, version = packageVersion(), source = path.join(packageRoot(), "skills")) {
|
|
128
|
+
/** Copy each skill into each tool's skills directory and stamp it with the flagrm version. */
|
|
129
|
+
export function installSkills(cwd, mode, version = packageVersion(), source = path.join(packageRoot(), "skills"), tools = TOOL_IDS) {
|
|
94
130
|
const results = [];
|
|
95
|
-
for (const dir of
|
|
131
|
+
for (const dir of tools.map((t) => TOOL_SKILL_DIRS[t])) {
|
|
96
132
|
for (const skill of SKILLS) {
|
|
97
133
|
const target = path.join(cwd, dir, skill);
|
|
98
134
|
const exists = fs.existsSync(target);
|
|
@@ -142,6 +178,30 @@ export function installedSkillVersion(skillDir) {
|
|
|
142
178
|
return undefined;
|
|
143
179
|
}
|
|
144
180
|
}
|
|
181
|
+
/** GitHub Copilot's `/flagrm-remove` and `/flagrm-verify` prompt files in `.github/prompts/`. */
|
|
182
|
+
export function installPrompts(cwd, mode) {
|
|
183
|
+
const results = [];
|
|
184
|
+
for (const skill of SKILLS) {
|
|
185
|
+
const file = path.join(cwd, ".github", "prompts", `${skill}.prompt.md`);
|
|
186
|
+
const step = relativePath(cwd, file);
|
|
187
|
+
const exists = fs.existsSync(file);
|
|
188
|
+
if (mode === "init" && exists) {
|
|
189
|
+
results.push({ path: step, status: "skipped" });
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (mode === "update" && !exists)
|
|
193
|
+
continue;
|
|
194
|
+
const before = exists ? fs.readFileSync(file, "utf8") : undefined;
|
|
195
|
+
if (before === PROMPT_FILES[skill]) {
|
|
196
|
+
results.push({ path: step, status: "unchanged" });
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
200
|
+
fs.writeFileSync(file, PROMPT_FILES[skill], "utf8");
|
|
201
|
+
results.push({ path: step, status: exists ? "updated" : "created" });
|
|
202
|
+
}
|
|
203
|
+
return results;
|
|
204
|
+
}
|
|
145
205
|
/** The marked flagrm block in AGENTS.md, which Codex reads. */
|
|
146
206
|
export function installAgentsBlock(cwd, mode) {
|
|
147
207
|
const file = path.join(cwd, "AGENTS.md");
|
|
@@ -241,17 +301,63 @@ export function ensureGitignore(cwd) {
|
|
|
241
301
|
export function installRoot(cwd) {
|
|
242
302
|
return findConfigRoot(cwd) ?? repositoryRoot(cwd) ?? cwd;
|
|
243
303
|
}
|
|
244
|
-
/**
|
|
245
|
-
|
|
304
|
+
/** The files `init` installs for one tool. */
|
|
305
|
+
function toolSteps(root, tool, mode) {
|
|
306
|
+
const skills = installSkills(root, mode, undefined, undefined, [tool]);
|
|
307
|
+
if (tool === "claude-code")
|
|
308
|
+
return [...skills, installStopHook(root, mode)];
|
|
309
|
+
if (tool === "copilot")
|
|
310
|
+
return [...skills, ...installPrompts(root, mode)];
|
|
311
|
+
return [...skills, installAgentsBlock(root, mode)];
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* flagrm's files for tools `tools` leaves out, when they are installed: never
|
|
315
|
+
* deleted, no longer updated, and named so the user can delete them.
|
|
316
|
+
*/
|
|
317
|
+
function deselectedSteps(root, tools) {
|
|
318
|
+
const files = {
|
|
319
|
+
"claude-code": [
|
|
320
|
+
...SKILLS.map((s) => `${TOOL_SKILL_DIRS["claude-code"]}/${s}`),
|
|
321
|
+
stopHookState(root) === "missing" ? "" : ".claude/settings.json",
|
|
322
|
+
],
|
|
323
|
+
copilot: [
|
|
324
|
+
...SKILLS.map((s) => `${TOOL_SKILL_DIRS.copilot}/${s}`),
|
|
325
|
+
...SKILLS.map((s) => `.github/prompts/${s}.prompt.md`),
|
|
326
|
+
],
|
|
327
|
+
codex: [...SKILLS.map((s) => `${TOOL_SKILL_DIRS.codex}/${s}`), hasAgentsBlock(root) ? "AGENTS.md" : ""],
|
|
328
|
+
};
|
|
329
|
+
return TOOL_IDS.filter((t) => !tools.includes(t)).flatMap((tool) => files[tool]
|
|
330
|
+
.filter((rel) => rel && fs.existsSync(path.join(root, rel)))
|
|
331
|
+
.map((rel) => ({
|
|
332
|
+
path: rel,
|
|
333
|
+
status: "skipped",
|
|
334
|
+
note: `${tool} isn't in tools: no longer updated${rel.endsWith(".json") || rel === "AGENTS.md" ? " (flagrm's entry)" : ""}; delete it if you don't use it`,
|
|
335
|
+
})));
|
|
336
|
+
}
|
|
337
|
+
function hasAgentsBlock(root) {
|
|
338
|
+
try {
|
|
339
|
+
return fs.readFileSync(path.join(root, "AGENTS.md"), "utf8").includes(AGENTS_START);
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* `flagrm init`: everything the agent needs in `root` (see {@link installRoot})
|
|
347
|
+
* for `tools`, added where missing, and the choice saved as the config's `tools:`.
|
|
348
|
+
*/
|
|
349
|
+
export function initProject(root, tools = TOOL_IDS) {
|
|
350
|
+
const config = writeConfigTemplate(root, tools);
|
|
351
|
+
const saved = config.status === "skipped" ? saveTools(root, [...tools]) : undefined;
|
|
246
352
|
return [
|
|
247
|
-
|
|
248
|
-
...
|
|
249
|
-
|
|
250
|
-
installStopHook(root, "init"),
|
|
353
|
+
config,
|
|
354
|
+
...(saved && saved.status !== "unchanged" ? [saved] : []),
|
|
355
|
+
...tools.flatMap((t) => toolSteps(root, t, "init")),
|
|
251
356
|
ensureGitignore(root),
|
|
357
|
+
...deselectedSteps(root, tools),
|
|
252
358
|
];
|
|
253
359
|
}
|
|
254
|
-
/** `flagrm update`: refresh what `init` installed in `root` from this flagrm version; adds nothing new. */
|
|
255
|
-
export function updateProject(root) {
|
|
256
|
-
return [...
|
|
360
|
+
/** `flagrm update`: refresh what `init` installed in `root` for `tools` from this flagrm version; adds nothing new. */
|
|
361
|
+
export function updateProject(root, tools = TOOL_IDS) {
|
|
362
|
+
return [...tools.flatMap((t) => toolSteps(root, t, "update")), ...deselectedSteps(root, tools)];
|
|
257
363
|
}
|
package/dist/core/inventory.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* type, direct reference counts and the projects involved.
|
|
5
5
|
*/
|
|
6
6
|
import path from "node:path";
|
|
7
|
+
import { flagWords, mentionsFlag, namesFlag } from "./flag-words.js";
|
|
7
8
|
import { findMentions } from "./mentions.js";
|
|
8
9
|
import { isTestPath } from "./scan.js";
|
|
9
10
|
import { JSON_SCHEMA_VERSION, } from "./types.js";
|
|
@@ -33,6 +34,8 @@ export async function buildInventory(projects) {
|
|
|
33
34
|
definitions: [],
|
|
34
35
|
config: [],
|
|
35
36
|
references: { total: 0, files: 0, tests: 0, byProject: {} },
|
|
37
|
+
flow: [],
|
|
38
|
+
parameterizedTests: [],
|
|
36
39
|
};
|
|
37
40
|
entries.set(c.flag, entry);
|
|
38
41
|
involved.set(entry, new Set());
|
|
@@ -52,6 +55,7 @@ export async function buildInventory(projects) {
|
|
|
52
55
|
}
|
|
53
56
|
}
|
|
54
57
|
countReferences(projects, [...entries.values()]);
|
|
58
|
+
followFlow(projects, [...entries.values()]);
|
|
55
59
|
const order = projects.map(({ ctx }) => ctx.name);
|
|
56
60
|
const byLocation = (a, b) => order.indexOf(a.project) - order.indexOf(b.project) || a.file.localeCompare(b.file) || a.line - b.line;
|
|
57
61
|
for (const entry of entries.values()) {
|
|
@@ -116,3 +120,38 @@ function countReferences(projects, entries) {
|
|
|
116
120
|
for (const [entry, seen] of files)
|
|
117
121
|
entry.references.files = seen.size;
|
|
118
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* Where each flag's value goes, from the adapters that can follow it. A name
|
|
125
|
+
* is marked `record` when it says it is about the flag; a generic one
|
|
126
|
+
* (`enabled`, `Plural`) recorded as a wrapper would match unrelated code.
|
|
127
|
+
*/
|
|
128
|
+
function followFlow(projects, entries) {
|
|
129
|
+
const byFlag = new Map(entries.map((e) => [e.flag, e]));
|
|
130
|
+
const refs = entries.map((e) => ({ flag: e.flag, aliases: e.definitions.map((d) => d.name) }));
|
|
131
|
+
for (const { adapter, ctx } of projects) {
|
|
132
|
+
if (!adapter.flow)
|
|
133
|
+
continue;
|
|
134
|
+
const { names, tests } = adapter.flow(ctx, refs);
|
|
135
|
+
for (const name of names) {
|
|
136
|
+
const entry = byFlag.get(name.flag);
|
|
137
|
+
if (!entry)
|
|
138
|
+
continue;
|
|
139
|
+
const words = flagWords([{ name: entry.flag }, ...entry.definitions]);
|
|
140
|
+
const flagNames = [entry.flag, ...entry.definitions.map((d) => d.name.split(".").pop() ?? d.name)];
|
|
141
|
+
entry.flow.push({ ...name, record: !name.ambiguous && recordable(name, flagNames, words) });
|
|
142
|
+
}
|
|
143
|
+
for (const test of tests)
|
|
144
|
+
byFlag.get(test.flag)?.parameterizedTests.push(test);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* A local, parameter or field is only looked for in its own file, so one of
|
|
149
|
+
* the flag's words in its name is enough (`useVfo1Terminology`). A method or
|
|
150
|
+
* property is looked for everywhere, so its name has to name the flag
|
|
151
|
+
* (`IsNewCheckoutEnabledAsync`); `express` or `GetCheckout` would match
|
|
152
|
+
* unrelated code.
|
|
153
|
+
*/
|
|
154
|
+
function recordable(name, flagNames, words) {
|
|
155
|
+
const scoped = name.kind === "local" || name.kind === "parameter" || name.kind === "field";
|
|
156
|
+
return scoped ? mentionsFlag(name.name, words) : namesFlag(name.name, flagNames, words);
|
|
157
|
+
}
|
package/dist/core/markdown.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* notes and the commit message around it.
|
|
5
5
|
*/
|
|
6
6
|
import { relativePath } from "./util.js";
|
|
7
|
-
import { shortTestNames } from "./verify/test-results.js";
|
|
7
|
+
import { shortTestName, shortTestNames } from "./verify/test-results.js";
|
|
8
8
|
const ICON = { pass: "✅", fail: "❌", warn: "⚠️", skipped: "➖" };
|
|
9
9
|
/** How many entries a list shows before summarizing the rest. */
|
|
10
10
|
const MAX_ITEMS = 20;
|
|
@@ -33,11 +33,22 @@ export function verifyMarkdown(report, root) {
|
|
|
33
33
|
const changed = report.changedFiles ?? [];
|
|
34
34
|
if (changed.length)
|
|
35
35
|
out.push("", `**Files changed since baseline (${changed.length}):** ${codeList(changed)}`);
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
const all = (key) => report.tests.flatMap((t) => t[key]);
|
|
37
|
+
const renamed = report.tests.flatMap((t) => t.renamed);
|
|
38
|
+
for (const [names, title] of [
|
|
39
|
+
[all("deleted"), "Tests deleted"],
|
|
40
|
+
[all("excludedByConfig"), "Tests excluded by a config change"],
|
|
41
|
+
[all("unexplained"), "Tests that no longer run, unexplained"],
|
|
42
|
+
]) {
|
|
43
|
+
if (names.length)
|
|
44
|
+
out.push("", `**${title} (${names.length}):** ${codeList(shortTestNames(names))}`);
|
|
45
|
+
}
|
|
46
|
+
if (renamed.length) {
|
|
47
|
+
const pairs = renamed.map((r) => `\`${shortTestName(r.from)}\` → \`${shortTestName(r.to)}\``);
|
|
48
|
+
out.push("", `**Tests renamed (${renamed.length}):** ${pairs.slice(0, MAX_ITEMS).join(", ")}`);
|
|
39
49
|
}
|
|
40
|
-
const
|
|
50
|
+
const renamedTo = new Set(renamed.map((r) => r.to));
|
|
51
|
+
const added = report.tests.flatMap((t) => t.added).filter((name) => !renamedTo.has(name));
|
|
41
52
|
if (added.length)
|
|
42
53
|
out.push("", `**New tests (${added.length}):** ${codeList(shortTestNames(added))}`);
|
|
43
54
|
for (const [severity, title] of [
|