flagrm 0.1.1 → 0.2.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 +92 -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 +192 -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/report.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import pc from "picocolors";
|
|
2
2
|
import { plural, relativePath } from "./util.js";
|
|
3
|
-
import { shortTestNames } from "./verify/test-results.js";
|
|
4
3
|
/** A path relative to the working directory, with `/` separators on every platform. */
|
|
5
4
|
function rel(file) {
|
|
6
5
|
return relativePath(process.cwd(), file) || file;
|
|
@@ -28,12 +27,21 @@ export function printInventory(report) {
|
|
|
28
27
|
(refs.tests ? ` (${refs.tests} in tests)` : "") +
|
|
29
28
|
pc.dim(` · ${byProject}`);
|
|
30
29
|
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));
|
|
30
|
+
const project = pad([...entry.definitions, ...entry.config, ...entry.flow].map((x) => x.project));
|
|
32
31
|
const kind = pad(entry.definitions.map((d) => d.kind));
|
|
33
32
|
const name = pad(entry.definitions.map((d) => d.name));
|
|
34
33
|
for (const d of entry.definitions) {
|
|
35
34
|
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
35
|
}
|
|
36
|
+
const flowKind = pad(entry.flow.map((n) => n.kind));
|
|
37
|
+
const flowName = pad(entry.flow.map((n) => n.name));
|
|
38
|
+
for (const n of entry.flow) {
|
|
39
|
+
console.log(` ${pc.dim("flows ")} ${pc.dim(project(n.project))} ${pc.dim(flowKind(n.kind))} ${flowName(n.name)} ${pc.cyan(`${rel(n.file)}:${n.line}`)}` +
|
|
40
|
+
(n.record ? "" : pc.dim(" (suggested)")));
|
|
41
|
+
}
|
|
42
|
+
if (entry.parameterizedTests.length) {
|
|
43
|
+
console.log(` ${pc.dim("tests ")} ${plural(entry.parameterizedTests.length, "test call")} pass the flag's value as a literal`);
|
|
44
|
+
}
|
|
37
45
|
const env = pad(entry.config.map((c) => c.environment));
|
|
38
46
|
const state = pad(entry.config.map((c) => configLabel(c.state, c.filters)));
|
|
39
47
|
for (const c of entry.config) {
|
|
@@ -41,7 +49,8 @@ export function printInventory(report) {
|
|
|
41
49
|
}
|
|
42
50
|
console.log("");
|
|
43
51
|
}
|
|
44
|
-
console.log(pc.dim("Reference counts are direct uses of the flag's literal and definitions, not reads through wrappers."
|
|
52
|
+
console.log(pc.dim("Reference counts are direct uses of the flag's literal and definitions, not reads through wrappers. " +
|
|
53
|
+
"`flows` are the names its value travels through; `baseline` records the ones not marked suggested."));
|
|
45
54
|
}
|
|
46
55
|
function stateLabel(state) {
|
|
47
56
|
return state === "unconfigured" ? "not configured" : state;
|
|
@@ -58,11 +67,21 @@ export function printBaseline(baseline, file) {
|
|
|
58
67
|
for (const c of checks ?? []) {
|
|
59
68
|
const status = c.exitCode === 0 ? pc.green("pass") : pc.red(`fail (exit ${c.exitCode})`);
|
|
60
69
|
console.log(` ${c.project} ${c.check}: ${status} ${pc.dim(`${c.command} — ${Math.round(c.durationMs / 100) / 10}s`)}`);
|
|
70
|
+
if (c.failure)
|
|
71
|
+
console.log(` ${pc.yellow("setup problem:")} ${c.failure.message}`);
|
|
61
72
|
}
|
|
62
73
|
if (baseline.names.length) {
|
|
63
|
-
const names = baseline.names.map((n) =>
|
|
74
|
+
const names = baseline.names.map((n) => n.kind === "literal" ? n.name : `${n.name} (${n.kind}${n.file ? ` in ${n.file}` : ""})`);
|
|
64
75
|
console.log(` names: ${pc.dim(names.join(", "))}`);
|
|
65
76
|
}
|
|
77
|
+
for (const n of baseline.suggestedNames ?? []) {
|
|
78
|
+
console.log(` ${pc.yellow("suggested")} ${n.name} ${pc.dim(`(${n.kind})`)} ${pc.cyan(`${rel(n.file)}:${n.line}`)} ` +
|
|
79
|
+
pc.dim(`if it only carries this flag: --name ${n.name} --kind wrapper` +
|
|
80
|
+
(n.kind === "method" || n.kind === "property" ? "" : ` --file ${rel(n.file)}`)));
|
|
81
|
+
}
|
|
82
|
+
for (const t of baseline.parameterizedTests ?? []) {
|
|
83
|
+
console.log(` ${pc.dim("test passes")} ${t.parameter}: ${t.value} ${pc.dim(`to ${t.method}`)} ${pc.cyan(`${rel(t.file)}:${t.line}`)}`);
|
|
84
|
+
}
|
|
66
85
|
}
|
|
67
86
|
const CHECK_STYLE = {
|
|
68
87
|
pass: { icon: "✓", color: pc.green },
|
|
@@ -94,13 +113,6 @@ export function printVerifyReport(report, file) {
|
|
|
94
113
|
if (check.findings.length > shown.length) {
|
|
95
114
|
console.log(pc.dim(` … ${check.findings.length - shown.length} more (see --json)`));
|
|
96
115
|
}
|
|
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
116
|
}
|
|
105
117
|
if (report.strict && warned)
|
|
106
118
|
console.log(pc.dim("\n --strict: warnings count as failures."));
|
|
@@ -133,3 +145,34 @@ export function printDoctor(report) {
|
|
|
133
145
|
}
|
|
134
146
|
console.log(report.status === "ok" ? pc.green("\nflagrm doctor: ok") : pc.red("\nflagrm doctor: problems found"));
|
|
135
147
|
}
|
|
148
|
+
/** `flagrm scope`: each .NET project's setup problems and what flagrm.slnf leaves out. */
|
|
149
|
+
export function printScope(setups, root, written) {
|
|
150
|
+
if (setups.length === 0) {
|
|
151
|
+
console.log(pc.dim("No .NET projects in the config."));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
for (const s of setups) {
|
|
155
|
+
const solution = s.setup ? relativePath(root, s.setup.solution) : s.note;
|
|
156
|
+
console.log(`${pc.bold(s.project)} ${pc.dim(solution ?? "")}`);
|
|
157
|
+
if (s.sdk)
|
|
158
|
+
console.log(` ${pc.red("✗")} ${s.sdk}`);
|
|
159
|
+
for (const p of s.setup?.problems ?? [])
|
|
160
|
+
console.log(` ${pc.yellow("!")} ${p}`);
|
|
161
|
+
const out = s.setup?.leaveOut ?? [];
|
|
162
|
+
if (!s.sdk && !s.setup?.problems.length)
|
|
163
|
+
console.log(` ${pc.green("✓")} this machine has what it needs`);
|
|
164
|
+
if (out.length) {
|
|
165
|
+
const total = s.setup?.projects.length ?? 0;
|
|
166
|
+
console.log(` ${written ? "flagrm.slnf leaves out" : "flagrm.slnf would leave out"} ${out.length} of ${total} projects:`);
|
|
167
|
+
for (const l of out.slice(0, 30))
|
|
168
|
+
console.log(` ${l.entry} ${pc.dim(`— ${l.reason}`)}`);
|
|
169
|
+
if (out.length > 30)
|
|
170
|
+
console.log(pc.dim(` … ${out.length - 30} more`));
|
|
171
|
+
console.log(pc.dim(written
|
|
172
|
+
? " Their builds and tests won't run: verify can't check edits to them, so say so in the PR."
|
|
173
|
+
: " Run `flagrm scope --write` to create it and point the build and test at it."));
|
|
174
|
+
}
|
|
175
|
+
if (s.sdk)
|
|
176
|
+
console.log(pc.dim(" A solution filter can't help here: install the SDK or change global.json."));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `flagrm scope`: check that the machine can build and test each .NET
|
|
3
|
+
* project, and when it lacks something only some projects need (`cargo`,
|
|
4
|
+
* Docker), narrow the build and test to a solution filter, `flagrm.slnf`,
|
|
5
|
+
* that leaves those projects out. A smaller scope that runs beats a full one
|
|
6
|
+
* that can't: the baseline's results are then real.
|
|
7
|
+
*/
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import fg from "fast-glob";
|
|
12
|
+
import YAML from "yaml";
|
|
13
|
+
import { sdkProblem, solutionSetup } from "../adapters/dotnet/setup.js";
|
|
14
|
+
import { projectCommands } from "./config-snapshot.js";
|
|
15
|
+
import { relativePath } from "./util.js";
|
|
16
|
+
export const SLNF = "flagrm.slnf";
|
|
17
|
+
/** Check each .NET project of the workspace. */
|
|
18
|
+
export function setupReport(projects, root, probes) {
|
|
19
|
+
const out = [];
|
|
20
|
+
for (const { adapter, ctx } of projects) {
|
|
21
|
+
if (adapter.id !== "dotnet")
|
|
22
|
+
continue;
|
|
23
|
+
const entry = { project: ctx.name };
|
|
24
|
+
const sdk = sdkProblem(ctx.root, probes, root);
|
|
25
|
+
if (sdk)
|
|
26
|
+
entry.sdk = sdk;
|
|
27
|
+
const solution = solutionFor(ctx, projectCommands(adapter, ctx));
|
|
28
|
+
if ("note" in solution)
|
|
29
|
+
entry.note = solution.note;
|
|
30
|
+
else {
|
|
31
|
+
try {
|
|
32
|
+
entry.setup = solutionSetup(solution.solution, probes, root, solution.only);
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
entry.note = `can't read ${relativePath(root, solution.solution)}: ${err instanceof Error ? err.message : String(err)}`;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
out.push(entry);
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
/** A `.sln`, `.slnx` or `.slnf` named by a command. */
|
|
43
|
+
const SOLUTION_ARG = /(?<=^|\s)(?:(["'])([^"']+\.sln[xf]?)\1|([^\s"']+\.sln[xf]?))(?=\s|$)/;
|
|
44
|
+
/** The solution path in a SOLUTION_ARG match, quoted or not. */
|
|
45
|
+
const solutionArg = (m) => m?.[2] ?? m?.[3];
|
|
46
|
+
/** A project file (`.csproj`, `.fsproj`, ...) named by a command. */
|
|
47
|
+
const PROJECT_ARG = /(?<=^|\s)["']?[^\s"']+\.\w*proj["']?(?=\s|$)/;
|
|
48
|
+
/**
|
|
49
|
+
* The solution a project builds: the one its build or test command names, or
|
|
50
|
+
* the only one in its directory. When the command names a solution filter
|
|
51
|
+
* (a user's own or flagrm.slnf), the solution it filters, with `only` its
|
|
52
|
+
* projects: that is the scope, which flagrm only narrows further.
|
|
53
|
+
*/
|
|
54
|
+
function solutionFor(ctx, commands) {
|
|
55
|
+
for (const command of [commands.build, commands.test]) {
|
|
56
|
+
if (command && PROJECT_ARG.test(command) && !SOLUTION_ARG.test(command)) {
|
|
57
|
+
return { note: `\`${command}\` builds a project, not a solution: nothing to narrow` };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
for (const command of [commands.build, commands.test]) {
|
|
61
|
+
const arg = command && solutionArg(SOLUTION_ARG.exec(command));
|
|
62
|
+
if (!arg)
|
|
63
|
+
continue;
|
|
64
|
+
const file = path.resolve(ctx.root, arg);
|
|
65
|
+
if (!fs.existsSync(file))
|
|
66
|
+
return { note: `${arg} (from \`${command}\`) not found` };
|
|
67
|
+
if (!file.endsWith(".slnf"))
|
|
68
|
+
return { solution: file };
|
|
69
|
+
try {
|
|
70
|
+
const { path: inner, projects } = JSON.parse(fs.readFileSync(file, "utf8"))?.solution ?? {};
|
|
71
|
+
if (typeof inner === "string") {
|
|
72
|
+
const solution = path.resolve(path.dirname(file), inner.replace(/\\/g, "/"));
|
|
73
|
+
const dir = path.dirname(solution);
|
|
74
|
+
const only = Array.isArray(projects)
|
|
75
|
+
? projects.filter((p) => typeof p === "string").map((p) => path.resolve(dir, p.replace(/\\/g, "/")))
|
|
76
|
+
: undefined;
|
|
77
|
+
return { solution, only };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return { note: `can't read ${arg}` };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const found = fg.sync("*.{sln,slnx}", { cwd: ctx.root, absolute: true });
|
|
85
|
+
if (found.length === 1)
|
|
86
|
+
return { solution: found[0] };
|
|
87
|
+
return { note: found.length ? "several solutions: name one in build/test" : "no solution file found" };
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Create each solution's flagrm.slnf without the projects to leave out, keep
|
|
91
|
+
* it out of git through `.git/info/exclude` (it describes this machine, not
|
|
92
|
+
* the repository), and point the project's build and test at it in the config.
|
|
93
|
+
*/
|
|
94
|
+
export function writeScope(setups, projects, root, configFile) {
|
|
95
|
+
const steps = [];
|
|
96
|
+
const commandUpdates = [];
|
|
97
|
+
for (const s of setups) {
|
|
98
|
+
if (!s.setup?.leaveOut.length)
|
|
99
|
+
continue;
|
|
100
|
+
const { solution, leaveOut, projects: all } = s.setup;
|
|
101
|
+
const slnf = path.join(path.dirname(solution), SLNF);
|
|
102
|
+
const out = new Set(leaveOut.map((l) => l.entry));
|
|
103
|
+
const content = `${JSON.stringify({ solution: { path: path.basename(solution), projects: all.map((p) => p.entry).filter((e) => !out.has(e)) } }, null, 2)}\n`;
|
|
104
|
+
const before = fs.existsSync(slnf) ? fs.readFileSync(slnf, "utf8") : undefined;
|
|
105
|
+
if (before !== content)
|
|
106
|
+
fs.writeFileSync(slnf, content, "utf8");
|
|
107
|
+
steps.push({
|
|
108
|
+
path: relativePath(root, slnf),
|
|
109
|
+
status: before === undefined ? "created" : before === content ? "unchanged" : "updated",
|
|
110
|
+
note: `${all.length - out.size} of ${all.length} projects`,
|
|
111
|
+
});
|
|
112
|
+
steps.push(excludeFromGit(root, slnf));
|
|
113
|
+
const target = projects.find((p) => p.ctx.name === s.project);
|
|
114
|
+
if (target) {
|
|
115
|
+
const commands = projectCommands(target.adapter, target.ctx);
|
|
116
|
+
const relative = relativePath(target.ctx.root, slnf) || SLNF;
|
|
117
|
+
const rel = /\s/.test(relative) ? `"${relative}"` : relative;
|
|
118
|
+
commandUpdates.push({
|
|
119
|
+
project: s.project,
|
|
120
|
+
// The resolved commands: the configured ones, or the adapter's defaults when none are.
|
|
121
|
+
build: commands.build && pointAt(commands.build, rel),
|
|
122
|
+
test: commands.test && pointAt(commands.test, rel),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (commandUpdates.length)
|
|
127
|
+
steps.push(updateConfig(configFile, root, commandUpdates));
|
|
128
|
+
return steps;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* `command` building `slnf` instead of the solution it names, or the solution
|
|
132
|
+
* `dotnet` would find. A command naming a project file is left as it is:
|
|
133
|
+
* `dotnet` takes one project or solution, not both.
|
|
134
|
+
*/
|
|
135
|
+
export function pointAt(command, slnf) {
|
|
136
|
+
if (SOLUTION_ARG.test(command))
|
|
137
|
+
return command.replace(SOLUTION_ARG, slnf);
|
|
138
|
+
if (PROJECT_ARG.test(command))
|
|
139
|
+
return command;
|
|
140
|
+
return command.replace(/\bdotnet\s+(build|test)\b/, `dotnet $1 ${slnf}`);
|
|
141
|
+
}
|
|
142
|
+
function excludeFromGit(root, file) {
|
|
143
|
+
const git = (...args) => spawnSync("git", args, { cwd: root, encoding: "utf8" });
|
|
144
|
+
const top = git("rev-parse", "--show-toplevel");
|
|
145
|
+
const exclude = git("rev-parse", "--path-format=absolute", "--git-path", "info/exclude");
|
|
146
|
+
if (top.status !== 0 || exclude.status !== 0)
|
|
147
|
+
return { path: ".git/info/exclude", status: "skipped", note: "not a git repository" };
|
|
148
|
+
const excludeFile = exclude.stdout.trim();
|
|
149
|
+
const line = `/${relativePath(fs.realpathSync(top.stdout.trim()), fs.realpathSync(file))}`;
|
|
150
|
+
const text = fs.existsSync(excludeFile) ? fs.readFileSync(excludeFile, "utf8") : "";
|
|
151
|
+
const shown = relativePath(root, excludeFile);
|
|
152
|
+
if (text.split(/\r?\n/).includes(line))
|
|
153
|
+
return { path: shown, status: "unchanged" };
|
|
154
|
+
fs.mkdirSync(path.dirname(excludeFile), { recursive: true });
|
|
155
|
+
fs.appendFileSync(excludeFile, `${text && !text.endsWith("\n") ? "\n" : ""}${line}\n`, "utf8");
|
|
156
|
+
return { path: shown, status: "updated", note: line };
|
|
157
|
+
}
|
|
158
|
+
/** Set the build and test commands of `projects:` entries in place, keeping the file's comments and layout. */
|
|
159
|
+
function updateConfig(configFile, root, updates) {
|
|
160
|
+
const paste = updates.map((u) => `${u.project}: build: ${u.build}; test: ${u.test}`).join("; ");
|
|
161
|
+
if (!configFile || configFile.endsWith(".json")) {
|
|
162
|
+
return {
|
|
163
|
+
path: configFile ? relativePath(root, configFile) : "flagrm.config.yaml",
|
|
164
|
+
status: "skipped",
|
|
165
|
+
note: `set ${paste}`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
const text = fs.readFileSync(configFile, "utf8");
|
|
169
|
+
const doc = YAML.parseDocument(text);
|
|
170
|
+
const list = doc.get("projects");
|
|
171
|
+
const missing = [];
|
|
172
|
+
for (const u of updates) {
|
|
173
|
+
const index = YAML.isSeq(list)
|
|
174
|
+
? list.items.findIndex((item) => YAML.isMap(item) && item.get("name") === u.project)
|
|
175
|
+
: -1;
|
|
176
|
+
if (index === -1) {
|
|
177
|
+
missing.push(u.project);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (u.build)
|
|
181
|
+
doc.setIn(["projects", index, "build"], u.build);
|
|
182
|
+
if (u.test)
|
|
183
|
+
doc.setIn(["projects", index, "test"], u.test);
|
|
184
|
+
}
|
|
185
|
+
const next = doc.toString();
|
|
186
|
+
if (next !== text)
|
|
187
|
+
fs.writeFileSync(configFile, next, "utf8");
|
|
188
|
+
const note = missing.length
|
|
189
|
+
? `not in projects: (${missing.join(", ")}): set ${paste}`
|
|
190
|
+
: updates.map((u) => `${u.project}: \`${u.build}\`, \`${u.test}\``).join("; ");
|
|
191
|
+
return { path: relativePath(root, configFile), status: next === text ? "unchanged" : "updated", note };
|
|
192
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which AI coding tools `flagrm init` sets up, and how the choice is made:
|
|
3
|
+
* `--tool`, the `tools:` saved in flagrm.config.yaml, or a question with the
|
|
4
|
+
* tools the repository already uses pre-selected.
|
|
5
|
+
*/
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import readline from "node:readline/promises";
|
|
9
|
+
import YAML from "yaml";
|
|
10
|
+
import { findDefaultConfigFile, parseTools, TOOL_IDS } from "./config.js";
|
|
11
|
+
import { relativePath } from "./util.js";
|
|
12
|
+
export const TOOL_LABELS = {
|
|
13
|
+
"claude-code": "Claude Code",
|
|
14
|
+
copilot: "GitHub Copilot",
|
|
15
|
+
codex: "Codex",
|
|
16
|
+
};
|
|
17
|
+
/** The tools a repository already uses: `.claude/`, Copilot instructions or skills, AGENTS.md or `.agents/`. */
|
|
18
|
+
export function detectTools(root) {
|
|
19
|
+
const has = (rel) => fs.existsSync(path.join(root, rel));
|
|
20
|
+
const found = {
|
|
21
|
+
"claude-code": has(".claude"),
|
|
22
|
+
copilot: has(".github/copilot-instructions.md") || has(".github/skills") || has(".github/prompts"),
|
|
23
|
+
codex: has("AGENTS.md") || has(".agents"),
|
|
24
|
+
};
|
|
25
|
+
return TOOL_IDS.filter((t) => found[t]);
|
|
26
|
+
}
|
|
27
|
+
/** The `tools:` saved in the config file in `root`, if any. Throws on an invalid value. */
|
|
28
|
+
export function savedTools(root) {
|
|
29
|
+
const file = findDefaultConfigFile(root);
|
|
30
|
+
if (!file)
|
|
31
|
+
return undefined;
|
|
32
|
+
const text = fs.readFileSync(file, "utf8");
|
|
33
|
+
const raw = file.endsWith(".json") ? JSON.parse(text) : YAML.parse(text);
|
|
34
|
+
return parseTools(raw?.tools, `${path.basename(file)}: tools`);
|
|
35
|
+
}
|
|
36
|
+
/** Save `tools` in the config file in `root`, in place (comments and layout kept). */
|
|
37
|
+
export function saveTools(root, tools) {
|
|
38
|
+
const file = findDefaultConfigFile(root);
|
|
39
|
+
if (!file)
|
|
40
|
+
return { path: "flagrm.config.yaml", status: "skipped" };
|
|
41
|
+
const rel = relativePath(root, file);
|
|
42
|
+
const text = fs.readFileSync(file, "utf8");
|
|
43
|
+
let current;
|
|
44
|
+
try {
|
|
45
|
+
current = savedTools(root)?.join();
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// An invalid `tools:` (say `[cursor]`) is what `init --tool` replaces.
|
|
49
|
+
}
|
|
50
|
+
if (current === tools.join())
|
|
51
|
+
return { path: rel, status: "unchanged" };
|
|
52
|
+
let next;
|
|
53
|
+
if (file.endsWith(".json")) {
|
|
54
|
+
next = `${JSON.stringify({ ...JSON.parse(text), tools }, null, 2)}\n`;
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
const doc = YAML.parseDocument(text);
|
|
58
|
+
const seq = doc.createNode(tools);
|
|
59
|
+
seq.flow = true;
|
|
60
|
+
if (doc.has("tools"))
|
|
61
|
+
doc.set("tools", seq);
|
|
62
|
+
// First, above `projects:`, as the template has it.
|
|
63
|
+
else if (YAML.isMap(doc.contents))
|
|
64
|
+
doc.contents.items.unshift(doc.createPair("tools", seq));
|
|
65
|
+
else
|
|
66
|
+
return { path: rel, status: "skipped", note: `add \`tools: [${tools.join(", ")}]\`` };
|
|
67
|
+
next = doc.toString();
|
|
68
|
+
}
|
|
69
|
+
if (next === text)
|
|
70
|
+
return { path: rel, status: "unchanged" };
|
|
71
|
+
fs.writeFileSync(file, next, "utf8");
|
|
72
|
+
return { path: rel, status: "updated", note: `tools: [${tools.join(", ")}]` };
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Ask which tools to set up, with `preselected` checked: the user types the
|
|
76
|
+
* numbers to set up (`1,3`), or presses Enter to keep the pre-selection.
|
|
77
|
+
*/
|
|
78
|
+
export async function askTools(preselected, input = process.stdin, output = process.stdout) {
|
|
79
|
+
const rl = readline.createInterface({ input, output });
|
|
80
|
+
try {
|
|
81
|
+
output.write("Which AI coding tools should flagrm set up?\n");
|
|
82
|
+
TOOL_IDS.forEach((t, i) => {
|
|
83
|
+
output.write(` ${i + 1}. [${preselected.includes(t) ? "x" : " "}] ${TOOL_LABELS[t]}\n`);
|
|
84
|
+
});
|
|
85
|
+
const fallback = preselected.length ? preselected : [...TOOL_IDS];
|
|
86
|
+
const defaults = fallback.map((t) => TOOL_IDS.indexOf(t) + 1).join(",");
|
|
87
|
+
for (;;) {
|
|
88
|
+
let answer;
|
|
89
|
+
try {
|
|
90
|
+
answer = (await rl.question(`Numbers, comma-separated [${defaults}]: `)).trim();
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// Ctrl+D or Ctrl+C: nothing was chosen.
|
|
94
|
+
throw new Error(`Cancelled: no tools chosen. Run flagrm init again, or pass --tool ${TOOL_IDS.join(",")}.`);
|
|
95
|
+
}
|
|
96
|
+
if (!answer)
|
|
97
|
+
return fallback;
|
|
98
|
+
const picks = answer.split(/[\s,]+/).map(Number);
|
|
99
|
+
if (picks.every((n) => Number.isInteger(n) && n >= 1 && n <= TOOL_IDS.length)) {
|
|
100
|
+
return TOOL_IDS.filter((_, i) => picks.includes(i + 1));
|
|
101
|
+
}
|
|
102
|
+
output.write(`Enter numbers from 1 to ${TOOL_IDS.length}, e.g. 1,3.\n`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
rl.close();
|
|
107
|
+
}
|
|
108
|
+
}
|
package/dist/core/types.js
CHANGED
|
@@ -14,8 +14,14 @@
|
|
|
14
14
|
* `failedNames`: the comparison is in `tests`, failed tests in the findings),
|
|
15
15
|
* and one `tests` warning for all tests that no longer run.
|
|
16
16
|
* - 6: `verify` reports `skipped`, the checks `--skip` left out.
|
|
17
|
+
* - 7: `baseline --json` prints a summary of `baseline.json`: test results as
|
|
18
|
+
* counts and at most 20 short failed test names (`failedTests`), no
|
|
19
|
+
* `config`. `baseline.json` itself is unchanged, so version 6 files are
|
|
20
|
+
* still read.
|
|
17
21
|
*/
|
|
18
|
-
export const JSON_SCHEMA_VERSION =
|
|
22
|
+
export const JSON_SCHEMA_VERSION = 7;
|
|
23
|
+
/** `baseline.json` versions this flagrm reads: 7 only added optional fields to 6. */
|
|
24
|
+
export const READABLE_BASELINE_VERSIONS = [6, 7];
|
|
19
25
|
// ---------------------------------------------------------------------------
|
|
20
26
|
// verify
|
|
21
27
|
// ---------------------------------------------------------------------------
|
package/dist/core/util.js
CHANGED
|
@@ -16,3 +16,13 @@ export function readJson(file) {
|
|
|
16
16
|
return undefined;
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
|
+
/** Whether `exe` can be run from `cwd`: a path that exists, or a program on PATH (with PATHEXT on Windows). */
|
|
20
|
+
export function onPath(exe, cwd) {
|
|
21
|
+
if (exe.includes("/") || exe.includes("\\"))
|
|
22
|
+
return fs.existsSync(path.resolve(cwd, exe));
|
|
23
|
+
const exts = process.platform === "win32" ? ["", ...(process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")] : [""];
|
|
24
|
+
return (process.env.PATH ?? "")
|
|
25
|
+
.split(path.delimiter)
|
|
26
|
+
.filter(Boolean)
|
|
27
|
+
.some((dir) => exts.some((ext) => fs.existsSync(path.join(dir, exe + ext))));
|
|
28
|
+
}
|
|
@@ -10,8 +10,9 @@ import path from "node:path";
|
|
|
10
10
|
import { errorExcerpt, templateCheckGap } from "../baseline.js";
|
|
11
11
|
import { escapeRegExp, isTestPath } from "../scan.js";
|
|
12
12
|
import { relativePath } from "../util.js";
|
|
13
|
-
import { checkResult, plural, skippedResult } from "./context.js";
|
|
14
|
-
import {
|
|
13
|
+
import { checkResult, configFindings, plural, skippedResult } from "./context.js";
|
|
14
|
+
import { classifyRemovedTests } from "./removed-tests.js";
|
|
15
|
+
import { diffTestNames, shortTestNames, splitFailures } from "./test-results.js";
|
|
15
16
|
function failedRunFindings(run) {
|
|
16
17
|
const findings = [
|
|
17
18
|
{
|
|
@@ -21,15 +22,29 @@ function failedRunFindings(run) {
|
|
|
21
22
|
file: run.log,
|
|
22
23
|
},
|
|
23
24
|
];
|
|
24
|
-
|
|
25
|
-
findings.push(
|
|
25
|
+
if (run.failure)
|
|
26
|
+
findings.push(setupFinding(run));
|
|
27
|
+
else
|
|
28
|
+
for (const line of errorExcerpt(run.log))
|
|
29
|
+
findings.push({ severity: "info", message: line, project: run.project });
|
|
26
30
|
return findings;
|
|
27
31
|
}
|
|
32
|
+
/** A failed run's setup problem: the machine, not the edit, is what needs fixing. */
|
|
33
|
+
function setupFinding(run) {
|
|
34
|
+
return { severity: "info", message: `setup problem: ${run.failure?.message}`, project: run.project };
|
|
35
|
+
}
|
|
36
|
+
/** No command to run: skipped, unless the baseline had one, which is worth a warning. */
|
|
37
|
+
function noCommandResult(v, id) {
|
|
38
|
+
const summary = `no ${id === "build" ? "build" : "test"} command configured`;
|
|
39
|
+
const findings = configFindings(v, id);
|
|
40
|
+
return findings.length ? checkResult(id, summary, findings) : skippedResult(id, summary);
|
|
41
|
+
}
|
|
28
42
|
export function buildCheck(v) {
|
|
29
43
|
const runs = v.runs.filter((r) => r.check === "build");
|
|
30
44
|
if (runs.length === 0)
|
|
31
|
-
return
|
|
45
|
+
return noCommandResult(v, "build");
|
|
32
46
|
const findings = runs.flatMap((r) => (r.exitCode === 0 ? [] : failedRunFindings(r)));
|
|
47
|
+
findings.push(...configFindings(v, "build"));
|
|
33
48
|
for (const run of runs) {
|
|
34
49
|
const project = v.projects.find(({ ctx }) => ctx.name === run.project);
|
|
35
50
|
const gap = project && templateCheckGap(project.adapter, run.command);
|
|
@@ -58,24 +73,104 @@ export function compareTests(v) {
|
|
|
58
73
|
after: after?.total,
|
|
59
74
|
removed: [],
|
|
60
75
|
added: [],
|
|
76
|
+
deleted: [],
|
|
77
|
+
renamed: [],
|
|
78
|
+
excludedByConfig: [],
|
|
79
|
+
unexplained: [],
|
|
80
|
+
newFailures: [],
|
|
81
|
+
knownFailures: [],
|
|
61
82
|
};
|
|
62
|
-
if (
|
|
63
|
-
|
|
83
|
+
if (after)
|
|
84
|
+
comparison.failed = after.failed;
|
|
85
|
+
const failures = splitBaselineFailures(v, run);
|
|
86
|
+
if (failures) {
|
|
87
|
+
comparison.newFailures = shortTestNames(failures.new);
|
|
88
|
+
comparison.knownFailures = shortTestNames(failures.known);
|
|
89
|
+
}
|
|
90
|
+
if (before && after) {
|
|
91
|
+
const { removed, added } = diffTestNames(before.names, after.names);
|
|
92
|
+
Object.assign(comparison, { removed, added });
|
|
93
|
+
Object.assign(comparison, classifyRemovedTests(removed, added, after.names, {
|
|
94
|
+
root: v.root,
|
|
95
|
+
sha: v.baseline.git.sha,
|
|
96
|
+
changedFiles: v.changedFiles,
|
|
97
|
+
names: v.baseline.names,
|
|
98
|
+
testConfigChanged: v.configChanges.some((c) => c.check === "tests" && c.project === run.project),
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
64
101
|
out.push(comparison);
|
|
65
102
|
}
|
|
66
103
|
return out;
|
|
67
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* For a test run that exited non-zero: its failed tests split into the ones
|
|
107
|
+
* that already failed at the baseline and new ones. Undefined when that can't
|
|
108
|
+
* be told (no result files now or at the baseline, or none of them failed, so
|
|
109
|
+
* the command broke some other way).
|
|
110
|
+
*/
|
|
111
|
+
function splitBaselineFailures(v, run) {
|
|
112
|
+
const before = v.baseline.checks?.find((c) => c.project === run.project && c.check === "test")?.results;
|
|
113
|
+
if (!before || !run.results?.failed)
|
|
114
|
+
return undefined;
|
|
115
|
+
return splitFailures(before.failedNames, run.results.failedNames);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* A failed test run fails the check, unless every failed test already failed
|
|
119
|
+
* at the baseline: those are known failures, and only warn.
|
|
120
|
+
*/
|
|
121
|
+
function failedTestFindings(run, failures) {
|
|
122
|
+
const list = (names, prefix) => shortTestNames(names)
|
|
123
|
+
.slice(0, 20)
|
|
124
|
+
.map((name) => ({ severity: "info", message: `${prefix}: ${name}`, project: run.project }));
|
|
125
|
+
if (!failures) {
|
|
126
|
+
return [...failedRunFindings(run).slice(0, 1), ...list(run.results?.failedNames ?? [], "failed")];
|
|
127
|
+
}
|
|
128
|
+
if (failures.new.length === 0) {
|
|
129
|
+
return [
|
|
130
|
+
{
|
|
131
|
+
severity: "warn",
|
|
132
|
+
message: `${plural(failures.known.length, "test fails", "tests fail")}, as at the baseline (known failures; log: ${run.log})`,
|
|
133
|
+
project: run.project,
|
|
134
|
+
file: run.log,
|
|
135
|
+
},
|
|
136
|
+
...list(failures.known, "known failure"),
|
|
137
|
+
];
|
|
138
|
+
}
|
|
139
|
+
return [
|
|
140
|
+
{
|
|
141
|
+
severity: "fail",
|
|
142
|
+
message: `${plural(failures.new.length, "test fails that", "tests fail that")} passed or didn't run at the baseline (log: ${run.log})`,
|
|
143
|
+
project: run.project,
|
|
144
|
+
file: run.log,
|
|
145
|
+
},
|
|
146
|
+
...list(failures.new, "new failure"),
|
|
147
|
+
...(failures.known.length
|
|
148
|
+
? [
|
|
149
|
+
{
|
|
150
|
+
severity: "info",
|
|
151
|
+
message: `plus ${plural(failures.known.length, "known failure")} from the baseline`,
|
|
152
|
+
project: run.project,
|
|
153
|
+
},
|
|
154
|
+
]
|
|
155
|
+
: []),
|
|
156
|
+
];
|
|
157
|
+
}
|
|
68
158
|
export function testsCheck(v, comparisons) {
|
|
69
159
|
const runs = v.runs.filter((r) => r.check === "test");
|
|
70
160
|
if (runs.length === 0)
|
|
71
|
-
return
|
|
72
|
-
const findings =
|
|
161
|
+
return noCommandResult(v, "tests");
|
|
162
|
+
const findings = configFindings(v, "tests");
|
|
163
|
+
let failed = 0;
|
|
164
|
+
let known = 0;
|
|
73
165
|
for (const run of runs) {
|
|
74
166
|
if (run.exitCode !== 0) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
167
|
+
const failures = splitBaselineFailures(v, run);
|
|
168
|
+
if (failures?.new.length !== 0)
|
|
169
|
+
failed++;
|
|
170
|
+
known += failures?.known.length ?? 0;
|
|
171
|
+
findings.push(...failedTestFindings(run, failures));
|
|
172
|
+
if (run.failure)
|
|
173
|
+
findings.push(setupFinding(run));
|
|
79
174
|
}
|
|
80
175
|
const ctx = v.projects.find(({ ctx }) => ctx.name === run.project)?.ctx;
|
|
81
176
|
const project = ctx?.project;
|
|
@@ -94,13 +189,16 @@ export function testsCheck(v, comparisons) {
|
|
|
94
189
|
project: run.project,
|
|
95
190
|
});
|
|
96
191
|
}
|
|
97
|
-
const
|
|
98
|
-
if (
|
|
192
|
+
const unexplained = comparison?.unexplained ?? [];
|
|
193
|
+
if (unexplained.length) {
|
|
99
194
|
findings.push({
|
|
100
195
|
severity: "warn",
|
|
101
|
-
message: `${plural(
|
|
196
|
+
message: `${plural(unexplained.length, "test no longer runs", "tests no longer run")} though the diff doesn't delete or rename it and the test config is unchanged: account for ${unexplained.length === 1 ? "it" : "them"} in the PR`,
|
|
102
197
|
project: run.project,
|
|
103
198
|
});
|
|
199
|
+
for (const name of shortTestNames(unexplained).slice(0, 20)) {
|
|
200
|
+
findings.push({ severity: "info", message: `no longer runs: ${name}`, project: run.project });
|
|
201
|
+
}
|
|
104
202
|
}
|
|
105
203
|
if (run.results && ctx) {
|
|
106
204
|
for (const file of changedTestFiles(v, ctx.root)) {
|
|
@@ -117,11 +215,21 @@ export function testsCheck(v, comparisons) {
|
|
|
117
215
|
}
|
|
118
216
|
const counts = comparisons
|
|
119
217
|
.filter((c) => c.after !== undefined)
|
|
120
|
-
.map((c) =>
|
|
121
|
-
|
|
218
|
+
.map((c) => {
|
|
219
|
+
if (c.before === undefined)
|
|
220
|
+
return `${c.project} ${c.after}`;
|
|
221
|
+
const why = [
|
|
222
|
+
c.deleted.length && `${c.deleted.length} deleted`,
|
|
223
|
+
c.renamed.length && `${c.renamed.length} renamed`,
|
|
224
|
+
c.excludedByConfig.length && `${c.excludedByConfig.length} excluded by config`,
|
|
225
|
+
c.unexplained.length && `${c.unexplained.length} unexplained`,
|
|
226
|
+
].filter(Boolean);
|
|
227
|
+
return `${c.project} ${c.before} → ${c.after}${why.length ? `: ${why.join(", ")}` : ""}`;
|
|
228
|
+
});
|
|
122
229
|
const summary = [
|
|
123
230
|
failed ? `${plural(failed, "test run")} failed` : "tests passed",
|
|
124
|
-
|
|
231
|
+
known ? `apart from ${plural(known, "known failure")}` : undefined,
|
|
232
|
+
counts.length ? `(${counts.join("; ")})` : undefined,
|
|
125
233
|
]
|
|
126
234
|
.filter(Boolean)
|
|
127
235
|
.join(" ");
|
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
/** Warnings about the configuration changes that affect check `id`. */
|
|
2
|
+
export function configFindings(v, id) {
|
|
3
|
+
return v.configChanges
|
|
4
|
+
.filter((c) => c.check === id)
|
|
5
|
+
.flatMap((c) => [
|
|
6
|
+
{ severity: "warn", message: c.message, project: c.project },
|
|
7
|
+
...(c.details ?? []).map((line) => ({ severity: "info", message: line, project: c.project })),
|
|
8
|
+
]);
|
|
9
|
+
}
|
|
1
10
|
/** A check's status is the worst severity among its findings. */
|
|
2
11
|
export function checkResult(id, summary, findings) {
|
|
3
12
|
const status = findings.some((f) => f.severity === "fail")
|