flagrm 0.1.0 → 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 +103 -1
- package/LICENSE +202 -21
- package/NOTICE +5 -0
- package/README.md +110 -65
- 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 +4 -3
- package/skills/flagrm-remove/SKILL.md +14 -14
package/dist/cli.js
CHANGED
|
@@ -3,8 +3,9 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { Command } from "commander";
|
|
5
5
|
import pc from "picocolors";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { realProbes } from "./adapters/dotnet/setup.js";
|
|
7
|
+
import { addNames, assertNoEditsSinceBaseline, baselinePath, baselineSummary, discoverFlag, flagDir, mergeNames, runChecks, validateAgentNames, writeBaseline, } from "./core/baseline.js";
|
|
8
|
+
import { loadConfig, parseTools, TOOL_IDS } from "./core/config.js";
|
|
8
9
|
import { runDoctor } from "./core/doctor.js";
|
|
9
10
|
import { gitState } from "./core/git.js";
|
|
10
11
|
import { hookRoot, parseHookInput, stopDecision } from "./core/hook.js";
|
|
@@ -13,9 +14,11 @@ import { buildInventory } from "./core/inventory.js";
|
|
|
13
14
|
import { verifyMarkdown } from "./core/markdown.js";
|
|
14
15
|
import { packageVersion } from "./core/package.js";
|
|
15
16
|
import { resolveProjects } from "./core/registry.js";
|
|
16
|
-
import { printBaseline, printDoctor, printInventory, printSteps, printVerifyReport } from "./core/report.js";
|
|
17
|
+
import { printBaseline, printDoctor, printInventory, printScope, printSteps, printVerifyReport, } from "./core/report.js";
|
|
18
|
+
import { setupReport, writeScope } from "./core/scope.js";
|
|
19
|
+
import { askTools, detectTools, savedTools, TOOL_LABELS } from "./core/tools.js";
|
|
17
20
|
import { CHECK_IDS } from "./core/types.js";
|
|
18
|
-
import { relativePath } from "./core/util.js";
|
|
21
|
+
import { onPath, relativePath } from "./core/util.js";
|
|
19
22
|
import { parseSkip, verifyFlag } from "./core/verify/index.js";
|
|
20
23
|
const program = new Command();
|
|
21
24
|
program
|
|
@@ -63,22 +66,38 @@ addCommonOptions(program
|
|
|
63
66
|
"existing baseline, only adds names"))
|
|
64
67
|
.option("--no-checks", "record only the git state, without running build and test commands")
|
|
65
68
|
.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)
|
|
67
|
-
.option("--
|
|
69
|
+
.option("--kind <kind>", "how --name entries are matched: alias (qualified constant) or wrapper (identifier, calls included); default alias, wrapper with --file")
|
|
70
|
+
.option("--file <path>", "check the --name entries only in this file (a local, parameter or field; implies --kind wrapper)")
|
|
71
|
+
.option("--force", "start over: replace the baseline, only while the code is unedited since it")
|
|
68
72
|
.action(async (flag, opts) => {
|
|
69
73
|
const { root, projects } = loadWorkspace(opts);
|
|
70
|
-
|
|
71
|
-
|
|
74
|
+
// Only wrappers are matched per file: a name scoped with --file is a local, parameter or field.
|
|
75
|
+
const kind = opts.kind ?? (opts.file ? "wrapper" : "alias");
|
|
76
|
+
if (kind !== "alias" && kind !== "wrapper") {
|
|
77
|
+
fail(new Error(`--kind must be alias or wrapper, not "${kind}".`));
|
|
72
78
|
}
|
|
79
|
+
if (opts.file && opts.name.length === 0)
|
|
80
|
+
fail(new Error("--file scopes --name entries; pass --name too."));
|
|
81
|
+
if (opts.file && kind !== "wrapper")
|
|
82
|
+
fail(new Error("--file scopes wrapper names; drop --kind alias."));
|
|
83
|
+
const scope = opts.file && relativePath(root, path.resolve(opts.file));
|
|
84
|
+
if (scope?.startsWith(".."))
|
|
85
|
+
fail(new Error(`--file ${opts.file} is outside ${root}.`));
|
|
73
86
|
const added = opts.name.map((name) => ({
|
|
74
87
|
name,
|
|
75
|
-
kind:
|
|
88
|
+
kind: kind,
|
|
76
89
|
source: "agent",
|
|
90
|
+
...(scope ? { file: scope } : {}),
|
|
77
91
|
}));
|
|
78
92
|
let result;
|
|
79
93
|
try {
|
|
80
94
|
validateAgentNames(projects, added);
|
|
81
|
-
|
|
95
|
+
const exists = fs.existsSync(baselinePath(root, flag));
|
|
96
|
+
if (exists && opts.force) {
|
|
97
|
+
const testResults = projects.flatMap(({ ctx }) => (ctx.project.testResults ? [ctx.project.testResults] : []));
|
|
98
|
+
assertNoEditsSinceBaseline(root, flag, testResults);
|
|
99
|
+
}
|
|
100
|
+
if (exists && !opts.force) {
|
|
82
101
|
if (added.length === 0) {
|
|
83
102
|
throw new Error(`Baseline for ${flag} already exists; pass --name to add names, or --force to start over.`);
|
|
84
103
|
}
|
|
@@ -87,14 +106,15 @@ addCommonOptions(program
|
|
|
87
106
|
else {
|
|
88
107
|
const git = gitState(root);
|
|
89
108
|
const checks = opts.checks ? await runChecks(projects, flagDir(root, flag)) : undefined;
|
|
90
|
-
|
|
109
|
+
const { names, ...flow } = await discoverFlag(projects, flag);
|
|
110
|
+
result = writeBaseline(root, flag, projects, git, checks, mergeNames(names, added), flow);
|
|
91
111
|
}
|
|
92
112
|
}
|
|
93
113
|
catch (err) {
|
|
94
114
|
fail(err);
|
|
95
115
|
}
|
|
96
116
|
if (opts.json) {
|
|
97
|
-
console.log(JSON.stringify(
|
|
117
|
+
console.log(JSON.stringify(baselineSummary(result.baseline, result.file), null, 2));
|
|
98
118
|
}
|
|
99
119
|
else {
|
|
100
120
|
printBaseline(result.baseline, result.file);
|
|
@@ -165,25 +185,63 @@ hook
|
|
|
165
185
|
// --- init --------------------------------------------------------------------
|
|
166
186
|
program
|
|
167
187
|
.command("init")
|
|
168
|
-
.description("set up flagrm in this repository: flagrm.config.yaml, the flagrm
|
|
169
|
-
"
|
|
170
|
-
.
|
|
188
|
+
.description("set up flagrm in this repository: flagrm.config.yaml, the .flagrm/ gitignore entry, and for each AI coding " +
|
|
189
|
+
"tool its files (Claude Code: skills and Stop hook; GitHub Copilot: skills and prompt files; Codex: skills " +
|
|
190
|
+
"and the AGENTS.md block); asks which tools on a terminal and saves the choice as `tools:`; never overwrites")
|
|
191
|
+
.option("--tool <tools>", `comma-separated tools to set up, without asking: ${TOOL_IDS.join(", ")}`)
|
|
192
|
+
.action(async (opts) => {
|
|
171
193
|
const root = installRoot(process.cwd());
|
|
172
194
|
printInstallRoot(root);
|
|
173
|
-
|
|
195
|
+
let tools;
|
|
196
|
+
try {
|
|
197
|
+
tools = await chooseTools(root, opts.tool);
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
fail(err);
|
|
201
|
+
}
|
|
202
|
+
printSteps(initProject(root, tools));
|
|
174
203
|
console.log(pc.bold("\nflagrm doctor"));
|
|
175
204
|
printDoctor(await runDoctor(root));
|
|
176
205
|
console.log(pc.dim("\nNext: fill in flagrm.config.yaml, then ask your agent: /flagrm-remove <FlagName>"));
|
|
177
206
|
});
|
|
207
|
+
/**
|
|
208
|
+
* The tools `init` sets up: `--tool`, else the config's saved `tools:`, else
|
|
209
|
+
* the user's answer with the tools the repository already uses pre-selected.
|
|
210
|
+
* Without a terminal to ask on: those detected tools, or all when there are none.
|
|
211
|
+
*/
|
|
212
|
+
async function chooseTools(root, flag) {
|
|
213
|
+
if (flag !== undefined) {
|
|
214
|
+
const tools = parseTools(flag, "--tool") ?? [];
|
|
215
|
+
if (tools.length === 0)
|
|
216
|
+
throw new Error(`--tool needs at least one of ${TOOL_IDS.join(", ")}.`);
|
|
217
|
+
return tools;
|
|
218
|
+
}
|
|
219
|
+
const saved = savedTools(root);
|
|
220
|
+
if (saved?.length) {
|
|
221
|
+
console.log(pc.dim(`Tools: ${saved.map((t) => TOOL_LABELS[t]).join(", ")} (from tools: in the config; --tool changes it)`));
|
|
222
|
+
return saved;
|
|
223
|
+
}
|
|
224
|
+
const detected = detectTools(root);
|
|
225
|
+
if (process.stdin.isTTY && process.stdout.isTTY)
|
|
226
|
+
return askTools(detected);
|
|
227
|
+
return detected.length ? detected : [...TOOL_IDS];
|
|
228
|
+
}
|
|
178
229
|
// --- update ------------------------------------------------------------------
|
|
179
230
|
program
|
|
180
231
|
.command("update")
|
|
181
|
-
.description("refresh the installed skills,
|
|
182
|
-
"(only where flagrm init installed them)")
|
|
232
|
+
.description("refresh the installed skills, prompt files, AGENTS.md block and Stop hook from this flagrm version " +
|
|
233
|
+
"(only where flagrm init installed them, and only for the config's tools: when it lists them)")
|
|
183
234
|
.action(() => {
|
|
184
235
|
const root = installRoot(process.cwd());
|
|
185
236
|
printInstallRoot(root);
|
|
186
|
-
|
|
237
|
+
let tools;
|
|
238
|
+
try {
|
|
239
|
+
tools = savedTools(root);
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
fail(err);
|
|
243
|
+
}
|
|
244
|
+
printSteps(updateProject(root, tools));
|
|
187
245
|
});
|
|
188
246
|
/** Say where init/update install when it isn't the working directory. */
|
|
189
247
|
function printInstallRoot(root) {
|
|
@@ -207,6 +265,26 @@ program
|
|
|
207
265
|
printDoctor(report);
|
|
208
266
|
process.exitCode = report.status === "ok" ? 0 : 1;
|
|
209
267
|
});
|
|
268
|
+
// --- scope -------------------------------------------------------------------
|
|
269
|
+
addCommonOptions(program
|
|
270
|
+
.command("scope")
|
|
271
|
+
.description("check that this machine can build and test each .NET project (the SDK global.json asks for, `cargo`, " +
|
|
272
|
+
"Docker for Testcontainers) and show which projects a solution filter (flagrm.slnf) would leave out; " +
|
|
273
|
+
"exit 0 nothing missing, 1 something missing"))
|
|
274
|
+
.option("--write", "create flagrm.slnf, keep it out of git and point the config's build and test at it")
|
|
275
|
+
.action((opts) => {
|
|
276
|
+
const { root, projects } = loadWorkspace(opts);
|
|
277
|
+
const setups = setupReport(projects, root, realProbes((exe) => onPath(exe, root)));
|
|
278
|
+
const steps = opts.write ? writeScope(setups, projects, root, projects[0]?.ctx.config.file) : [];
|
|
279
|
+
if (opts.json)
|
|
280
|
+
console.log(JSON.stringify({ projects: setups, ...(opts.write ? { steps } : {}) }, null, 2));
|
|
281
|
+
else {
|
|
282
|
+
printScope(setups, root, Boolean(opts.write));
|
|
283
|
+
printSteps(steps);
|
|
284
|
+
}
|
|
285
|
+
const missing = setups.some((s) => s.sdk || s.setup?.problems.length);
|
|
286
|
+
process.exitCode = missing ? 1 : 0;
|
|
287
|
+
});
|
|
210
288
|
// exitCode, not exit(): exit() can cut off output still being flushed to a pipe.
|
|
211
289
|
program.parseAsync().catch((err) => {
|
|
212
290
|
if (!(err instanceof CliError))
|
package/dist/core/baseline.js
CHANGED
|
@@ -2,9 +2,15 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import fg from "fast-glob";
|
|
6
|
+
import { CONFIG_FILENAMES } from "./config.js";
|
|
7
|
+
import { configSnapshot, projectCommands } from "./config-snapshot.js";
|
|
8
|
+
import { environmentProblem } from "./environment.js";
|
|
9
|
+
import { changedFilesSince } from "./git.js";
|
|
5
10
|
import { buildInventory } from "./inventory.js";
|
|
6
|
-
import { JSON_SCHEMA_VERSION } from "./types.js";
|
|
7
|
-
import {
|
|
11
|
+
import { JSON_SCHEMA_VERSION, READABLE_BASELINE_VERSIONS, } from "./types.js";
|
|
12
|
+
import { readJson, relativePath } from "./util.js";
|
|
13
|
+
import { collectTestResults, shortTestNames } from "./verify/test-results.js";
|
|
8
14
|
/**
|
|
9
15
|
* `.flagrm/<flag>/` under the config root. A name that isn't safe as a directory
|
|
10
16
|
* (`team/new`, `..`) is sanitized and suffixed with a hash of the original, so
|
|
@@ -24,6 +30,40 @@ export function baselinePath(root, flag) {
|
|
|
24
30
|
export function verifyPath(root, flag) {
|
|
25
31
|
return path.join(flagDir(root, flag), "verify.json");
|
|
26
32
|
}
|
|
33
|
+
/** At most this many failed test names in `baseline --json`. */
|
|
34
|
+
const MAX_FAILED_NAMES = 20;
|
|
35
|
+
/**
|
|
36
|
+
* What `baseline --json` prints: the baseline without the per-test name lists
|
|
37
|
+
* (every test of a large suite, with its data-row arguments: megabytes) and
|
|
38
|
+
* the config snapshot. Failed tests are listed by short name, at most 20;
|
|
39
|
+
* `baseline.json` keeps everything.
|
|
40
|
+
*/
|
|
41
|
+
export function baselineSummary(baseline, file) {
|
|
42
|
+
const { config: _config, checks, ...rest } = baseline;
|
|
43
|
+
return {
|
|
44
|
+
...rest,
|
|
45
|
+
...(checks
|
|
46
|
+
? {
|
|
47
|
+
checks: checks.map(({ results, ...run }) => {
|
|
48
|
+
if (!results)
|
|
49
|
+
return run;
|
|
50
|
+
const { names: _names, failedNames, files, ...counts } = results;
|
|
51
|
+
const failed = shortTestNames(failedNames);
|
|
52
|
+
return {
|
|
53
|
+
...run,
|
|
54
|
+
results: {
|
|
55
|
+
...counts,
|
|
56
|
+
files: files.length,
|
|
57
|
+
failedTests: failed.slice(0, MAX_FAILED_NAMES),
|
|
58
|
+
...(failed.length > MAX_FAILED_NAMES ? { moreFailedTests: failed.length - MAX_FAILED_NAMES } : {}),
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}),
|
|
62
|
+
}
|
|
63
|
+
: {}),
|
|
64
|
+
baselineFile: file,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
27
67
|
/** A problem with how flagrm was run (missing baseline, stale schema) rather than with the code under check. */
|
|
28
68
|
export class UsageError extends Error {
|
|
29
69
|
}
|
|
@@ -34,7 +74,7 @@ export function readBaseline(root, flag) {
|
|
|
34
74
|
throw new UsageError(`No baseline for ${flag} at ${file}. Run \`flagrm baseline ${flag}\` before changing the code.`);
|
|
35
75
|
}
|
|
36
76
|
const baseline = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
37
|
-
if (baseline.schemaVersion
|
|
77
|
+
if (!READABLE_BASELINE_VERSIONS.includes(baseline.schemaVersion)) {
|
|
38
78
|
throw new UsageError(`${file} was written by schema version ${baseline.schemaVersion}; this flagrm expects ${JSON_SCHEMA_VERSION}. ` +
|
|
39
79
|
`Re-run \`flagrm baseline ${flag} --force\` on the code as it was before the removal.`);
|
|
40
80
|
}
|
|
@@ -47,7 +87,7 @@ export function readBaseline(root, flag) {
|
|
|
47
87
|
* reason at the end of the log. A test run also reads the project's
|
|
48
88
|
* `testResults` files written during it.
|
|
49
89
|
*/
|
|
50
|
-
export async function runCheck(ctx, check, command, logFile) {
|
|
90
|
+
export async function runCheck(ctx, check, command, logFile, claimed) {
|
|
51
91
|
const started = Date.now();
|
|
52
92
|
fs.mkdirSync(path.dirname(logFile), { recursive: true });
|
|
53
93
|
fs.writeFileSync(logFile, `$ ${command}\n`, "utf8");
|
|
@@ -70,7 +110,14 @@ export async function runCheck(ctx, check, command, logFile) {
|
|
|
70
110
|
durationMs: Date.now() - started,
|
|
71
111
|
log: logFile,
|
|
72
112
|
};
|
|
73
|
-
|
|
113
|
+
if (run.exitCode !== 0) {
|
|
114
|
+
const problem = environmentProblem(fs.readFileSync(logFile, "utf8"), ctx.root);
|
|
115
|
+
if (problem)
|
|
116
|
+
run.failure = { kind: "environment", message: problem };
|
|
117
|
+
}
|
|
118
|
+
const results = check === "test" && ctx.project.testResults
|
|
119
|
+
? collectTestResults(ctx.project.testResults, started, claimed)
|
|
120
|
+
: undefined;
|
|
74
121
|
if (results)
|
|
75
122
|
run.results = results;
|
|
76
123
|
return run;
|
|
@@ -118,11 +165,6 @@ function failureReason(exit, timeout) {
|
|
|
118
165
|
return `killed by ${exit.signal ?? "a signal"}`;
|
|
119
166
|
return undefined;
|
|
120
167
|
}
|
|
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
168
|
/**
|
|
127
169
|
* Why an Angular project's build can't catch template errors, if it can't:
|
|
128
170
|
* `tsc` never reads templates, so a member removed or renamed in a component
|
|
@@ -155,12 +197,15 @@ export function incrementalBuildGap(adapter, build) {
|
|
|
155
197
|
*/
|
|
156
198
|
export async function runChecks(projects, logDir, only = ["build", "test"]) {
|
|
157
199
|
const runs = [];
|
|
200
|
+
// Projects whose `testResults` patterns overlap must not count each other's result files.
|
|
201
|
+
const claimed = new Map();
|
|
158
202
|
for (const { adapter, ctx } of projects) {
|
|
159
203
|
const commands = projectCommands(adapter, ctx);
|
|
160
204
|
for (const check of only) {
|
|
161
205
|
const command = commands[check];
|
|
162
|
-
if (command)
|
|
163
|
-
|
|
206
|
+
if (!command)
|
|
207
|
+
continue;
|
|
208
|
+
runs.push(await runCheck(ctx, check, command, path.join(logDir, `${ctx.name}.${check}.log`), claimed));
|
|
164
209
|
}
|
|
165
210
|
}
|
|
166
211
|
return runs;
|
|
@@ -170,7 +215,7 @@ export async function runChecks(projects, logDir, only = ["build", "test"]) {
|
|
|
170
215
|
* `git` before running checks, whose logs land under `.flagrm/` and would
|
|
171
216
|
* otherwise make the tree look dirty.
|
|
172
217
|
*/
|
|
173
|
-
export function writeBaseline(root, flag, projects, git, checks, names = []) {
|
|
218
|
+
export function writeBaseline(root, flag, projects, git, checks, names = [], flow) {
|
|
174
219
|
const baseline = {
|
|
175
220
|
schemaVersion: JSON_SCHEMA_VERSION,
|
|
176
221
|
flag,
|
|
@@ -178,9 +223,14 @@ export function writeBaseline(root, flag, projects, git, checks, names = []) {
|
|
|
178
223
|
projects: projects.map(({ ctx }) => ({ name: ctx.name, adapter: ctx.project.adapter })),
|
|
179
224
|
git,
|
|
180
225
|
names,
|
|
226
|
+
config: configSnapshot(projects),
|
|
181
227
|
};
|
|
182
228
|
if (checks)
|
|
183
229
|
baseline.checks = checks;
|
|
230
|
+
if (flow?.suggestedNames.length)
|
|
231
|
+
baseline.suggestedNames = flow.suggestedNames;
|
|
232
|
+
if (flow?.parameterizedTests.length)
|
|
233
|
+
baseline.parameterizedTests = flow.parameterizedTests;
|
|
184
234
|
return saveBaseline(root, baseline);
|
|
185
235
|
}
|
|
186
236
|
function saveBaseline(root, baseline) {
|
|
@@ -189,23 +239,93 @@ function saveBaseline(root, baseline) {
|
|
|
189
239
|
fs.writeFileSync(file, `${JSON.stringify(baseline, null, 2)}\n`, "utf8");
|
|
190
240
|
return { baseline, file };
|
|
191
241
|
}
|
|
192
|
-
/**
|
|
193
|
-
|
|
242
|
+
/**
|
|
243
|
+
* The flag's names from discovery: the literal, then each definition's
|
|
244
|
+
* qualified name, then the names its value travels through that contain one
|
|
245
|
+
* of the flag's words, as wrappers.
|
|
246
|
+
*/
|
|
247
|
+
export async function discoverFlag(projects, flag) {
|
|
194
248
|
const entry = (await buildInventory(projects)).flags.find((f) => f.flag === flag);
|
|
195
|
-
|
|
249
|
+
const flow = entry?.flow ?? [];
|
|
250
|
+
const names = mergeNames([{ name: flag, kind: "literal", source: "discovery" }], [
|
|
251
|
+
...(entry?.definitions ?? []).map((d) => ({ name: d.name, kind: "alias", source: "discovery" })),
|
|
252
|
+
...flow.filter((n) => n.record).map((n) => flowName(projects, n)),
|
|
253
|
+
]);
|
|
254
|
+
const key = (n) => `${n.name}\0${n.file ?? ""}`;
|
|
255
|
+
const recorded = new Set(names.map(key));
|
|
256
|
+
const suggestedNames = flow
|
|
257
|
+
.filter((n) => !n.record && !recorded.has(key(flowName(projects, n))))
|
|
258
|
+
.map(({ record: _record, ...name }) => name)
|
|
259
|
+
.filter((n, i, all) => all.findIndex((m) => m.name === n.name && m.file === n.file) === i);
|
|
260
|
+
return { names, suggestedNames, parameterizedTests: entry?.parameterizedTests ?? [] };
|
|
261
|
+
}
|
|
262
|
+
/** A flow name as a wrapper; a local, parameter or field only in its own file. */
|
|
263
|
+
function flowName(projects, n) {
|
|
264
|
+
const name = { name: n.name, kind: "wrapper", source: "discovery" };
|
|
265
|
+
const root = projects[0]?.ctx.config.root;
|
|
266
|
+
if (root && (n.kind === "local" || n.kind === "parameter" || n.kind === "field"))
|
|
267
|
+
name.file = relativePath(root, n.file);
|
|
268
|
+
return name;
|
|
196
269
|
}
|
|
197
|
-
/**
|
|
270
|
+
/** The names {@link discoverFlag} records. */
|
|
271
|
+
export async function discoverNames(projects, flag) {
|
|
272
|
+
return (await discoverFlag(projects, flag)).names;
|
|
273
|
+
}
|
|
274
|
+
/** `existing` followed by the names in `added` it doesn't already cover. */
|
|
198
275
|
export function mergeNames(existing, added) {
|
|
199
|
-
|
|
276
|
+
// A name recorded for every file already covers the same name in one file.
|
|
277
|
+
const key = (n) => `${n.name}\0${n.file ?? ""}`;
|
|
278
|
+
const seen = new Set(existing.map(key));
|
|
279
|
+
const everywhere = new Set(existing.filter((n) => !n.file).map((n) => n.name));
|
|
200
280
|
const merged = [...existing];
|
|
201
281
|
for (const n of added) {
|
|
202
|
-
if (seen.has(n.name))
|
|
282
|
+
if (seen.has(key(n)) || everywhere.has(n.name))
|
|
203
283
|
continue;
|
|
204
|
-
seen.add(n
|
|
284
|
+
seen.add(key(n));
|
|
285
|
+
if (!n.file)
|
|
286
|
+
everywhere.add(n.name);
|
|
205
287
|
merged.push(n);
|
|
206
288
|
}
|
|
207
289
|
return merged;
|
|
208
290
|
}
|
|
291
|
+
/**
|
|
292
|
+
* Files changed since `sha` that count as edits to the code: everything but
|
|
293
|
+
* `.flagrm/`, the flagrm config, which may change to fix the setup, and the
|
|
294
|
+
* files matching `testResults` patterns (absolute), which the baseline's own
|
|
295
|
+
* test run writes. Undefined when git can't tell (not a repository, or `sha`
|
|
296
|
+
* isn't in it).
|
|
297
|
+
*/
|
|
298
|
+
export function editsSince(root, sha, testResults = []) {
|
|
299
|
+
const results = new Set(testResults.flatMap((pattern) => fg
|
|
300
|
+
.sync(path.sep === "\\" ? pattern.replace(/\\/g, "/") : pattern, { absolute: true, dot: true })
|
|
301
|
+
.map((file) => relativePath(root, file))));
|
|
302
|
+
return changedFilesSince(root, sha)?.filter((rel) => !CONFIG_FILENAMES.includes(rel) && !results.has(rel));
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* `baseline --force` starts over only before the removal began: the working
|
|
306
|
+
* tree must still match the baseline commit. Otherwise the new baseline would
|
|
307
|
+
* record half-removed code, and verify would no longer see those edits.
|
|
308
|
+
* Without git there is nothing to compare, so it is allowed. Files matching
|
|
309
|
+
* `testResults` (absolute patterns) are not edits.
|
|
310
|
+
*/
|
|
311
|
+
export function assertNoEditsSinceBaseline(root, flag, testResults = []) {
|
|
312
|
+
const sha = readJson(baselinePath(root, flag))?.git?.sha;
|
|
313
|
+
if (!sha)
|
|
314
|
+
return;
|
|
315
|
+
const edits = editsSince(root, sha, testResults);
|
|
316
|
+
if (edits === undefined) {
|
|
317
|
+
throw new UsageError(`The baseline commit ${sha.slice(0, 7)} of ${flag} is not in this repository, so flagrm can't tell whether the ` +
|
|
318
|
+
`code was edited since. If it wasn't, delete ${relativePath(root, flagDir(root, flag))}/ and take a new baseline.`);
|
|
319
|
+
}
|
|
320
|
+
if (edits.length === 0)
|
|
321
|
+
return;
|
|
322
|
+
const shown = edits.slice(0, 5).join(", ") + (edits.length > 5 ? `, and ${edits.length - 5} more` : "");
|
|
323
|
+
const uncommitted = new Set(changedFilesSince(root, "HEAD") ?? []);
|
|
324
|
+
const fix = edits.every((rel) => uncommitted.has(rel))
|
|
325
|
+
? `Set the edits aside with \`git stash -u\`, run \`flagrm baseline ${flag} --force\` again, then \`git stash pop\`.`
|
|
326
|
+
: `Some are committed since the baseline commit ${sha.slice(0, 7)}: check out code without the removal first.`;
|
|
327
|
+
throw new UsageError(`The code changed since the baseline of ${flag} (${shown}), so --force would record a half-done removal. ${fix}`);
|
|
328
|
+
}
|
|
209
329
|
/** Append names to an existing baseline; its git state and checks stay as they are. */
|
|
210
330
|
export function addNames(root, flag, added) {
|
|
211
331
|
const { baseline } = readBaseline(root, flag);
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The configuration a baseline was taken with, so `verify` can tell when it
|
|
3
|
+
* changed since: a narrower test command or filter runs fewer tests, and the
|
|
4
|
+
* comparison against the baseline no longer means what it did.
|
|
5
|
+
*/
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import YAML from "yaml";
|
|
10
|
+
import { relativePath } from "./util.js";
|
|
11
|
+
/** A project's build/test commands: the configured ones, or the adapter's defaults when it configures neither. */
|
|
12
|
+
export function projectCommands(adapter, ctx) {
|
|
13
|
+
const configured = ctx.project.build !== undefined || ctx.project.test !== undefined;
|
|
14
|
+
return configured ? { build: ctx.project.build, test: ctx.project.test } : (adapter.defaultCommands?.(ctx) ?? {});
|
|
15
|
+
}
|
|
16
|
+
/** Solution and project files: a removal may edit them, so they don't count as a command's configuration. */
|
|
17
|
+
const PROJECT_FILE = /\.(sln|slnx|\w*proj)$/i;
|
|
18
|
+
/** The resolved build/test commands of every project, the files they name, and the config file's text. */
|
|
19
|
+
export function configSnapshot(projects) {
|
|
20
|
+
const configFile = projects[0]?.ctx.config.file;
|
|
21
|
+
const root = projects[0]?.ctx.config.root ?? process.cwd();
|
|
22
|
+
const snapshot = { projects: [] };
|
|
23
|
+
if (configFile) {
|
|
24
|
+
try {
|
|
25
|
+
snapshot.file = { path: relativePath(root, configFile), text: fs.readFileSync(configFile, "utf8") };
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// Unreadable: nothing to compare later.
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
for (const { adapter, ctx } of projects) {
|
|
32
|
+
const commands = projectCommands(adapter, ctx);
|
|
33
|
+
const command = (text) => ({ command: text, files: namedFiles(text, ctx.root, root) });
|
|
34
|
+
const entry = { name: ctx.name };
|
|
35
|
+
if (commands.build)
|
|
36
|
+
entry.build = command(commands.build);
|
|
37
|
+
if (commands.test)
|
|
38
|
+
entry.test = command(commands.test);
|
|
39
|
+
if (ctx.project.testResults)
|
|
40
|
+
entry.testResults = relativePath(root, ctx.project.testResults);
|
|
41
|
+
snapshot.projects.push(entry);
|
|
42
|
+
}
|
|
43
|
+
return snapshot;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Files a command names (`flagrm.slnf`, `--settings ci.runsettings`,
|
|
47
|
+
* `--filter-file=filters.txt`), with a hash of their content. Words are
|
|
48
|
+
* resolved against the directory the command runs in.
|
|
49
|
+
*/
|
|
50
|
+
function namedFiles(command, cwd, root) {
|
|
51
|
+
const words = command
|
|
52
|
+
.split(/\s+/)
|
|
53
|
+
.flatMap((word) => [word, word.slice(word.indexOf("=") + 1), word.slice(word.indexOf(":") + 1)])
|
|
54
|
+
.map((word) => word.replace(/^["']|["']$/g, ""))
|
|
55
|
+
.filter((word) => word && !word.startsWith("-") && !PROJECT_FILE.test(word));
|
|
56
|
+
const files = new Map();
|
|
57
|
+
for (const word of new Set(words)) {
|
|
58
|
+
const file = path.resolve(cwd, word);
|
|
59
|
+
try {
|
|
60
|
+
if (!fs.statSync(file).isFile())
|
|
61
|
+
continue;
|
|
62
|
+
files.set(relativePath(root, file), createHash("sha256").update(fs.readFileSync(file)).digest("hex"));
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Not a file.
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return [...files].sort(([a], [b]) => a.localeCompare(b)).map(([p, sha256]) => ({ path: p, sha256 }));
|
|
69
|
+
}
|
|
70
|
+
/** What changed between the configuration at the baseline and now. None when the baseline predates snapshots. */
|
|
71
|
+
export function configChanges(before, now) {
|
|
72
|
+
if (!before)
|
|
73
|
+
return [];
|
|
74
|
+
const changes = [];
|
|
75
|
+
const names = [...new Set([...before.projects, ...now.projects].map((p) => p.name))];
|
|
76
|
+
for (const name of names) {
|
|
77
|
+
const was = before.projects.find((p) => p.name === name);
|
|
78
|
+
const is = now.projects.find((p) => p.name === name);
|
|
79
|
+
for (const [kind, check] of [
|
|
80
|
+
["build", "build"],
|
|
81
|
+
["test", "tests"],
|
|
82
|
+
]) {
|
|
83
|
+
const a = was?.[kind];
|
|
84
|
+
const b = is?.[kind];
|
|
85
|
+
if (a?.command !== b?.command) {
|
|
86
|
+
changes.push({
|
|
87
|
+
check,
|
|
88
|
+
project: name,
|
|
89
|
+
message: `${kind} command changed since the baseline: ${quote(a?.command)} → ${quote(b?.command)}`,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
const changed = changedFiles(a?.files ?? [], b?.files ?? []);
|
|
93
|
+
if (changed.length) {
|
|
94
|
+
changes.push({
|
|
95
|
+
check,
|
|
96
|
+
project: name,
|
|
97
|
+
message: `${changed.join(", ")}, named by the ${kind} command, changed since the baseline`,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (was?.testResults !== is?.testResults) {
|
|
102
|
+
changes.push({
|
|
103
|
+
check: "tests",
|
|
104
|
+
project: name,
|
|
105
|
+
message: `testResults changed since the baseline: ${quote(was?.testResults)} → ${quote(is?.testResults)}`,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (before.file && now.file && withoutCommands(before.file.text) !== withoutCommands(now.file.text)) {
|
|
110
|
+
changes.push({
|
|
111
|
+
check: "leftovers",
|
|
112
|
+
message: `${now.file.path} changed since the baseline, which changes what is scanned`,
|
|
113
|
+
details: lineDiff(before.file.text, now.file.text),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return changes;
|
|
117
|
+
}
|
|
118
|
+
const quote = (text) => (text === undefined ? "(none)" : `\`${text}\``);
|
|
119
|
+
function changedFiles(a, b) {
|
|
120
|
+
const hashes = new Map(a.map((f) => [f.path, f.sha256]));
|
|
121
|
+
const out = new Set();
|
|
122
|
+
for (const f of b)
|
|
123
|
+
if (hashes.get(f.path) !== f.sha256)
|
|
124
|
+
out.add(f.path);
|
|
125
|
+
for (const f of a)
|
|
126
|
+
if (!b.some((g) => g.path === f.path))
|
|
127
|
+
out.add(f.path);
|
|
128
|
+
return [...out].sort();
|
|
129
|
+
}
|
|
130
|
+
/** The config without what the command comparison already covers, normalized for comparison. */
|
|
131
|
+
function withoutCommands(text) {
|
|
132
|
+
let parsed;
|
|
133
|
+
try {
|
|
134
|
+
parsed = YAML.parse(text);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return text;
|
|
138
|
+
}
|
|
139
|
+
const strip = (project) => {
|
|
140
|
+
if (!project || typeof project !== "object")
|
|
141
|
+
return project;
|
|
142
|
+
const { build: _b, test: _t, testResults: _r, timeout: _o, ...rest } = project;
|
|
143
|
+
return rest;
|
|
144
|
+
};
|
|
145
|
+
if (parsed && typeof parsed === "object") {
|
|
146
|
+
const config = { ...parsed };
|
|
147
|
+
if (Array.isArray(config.projects))
|
|
148
|
+
config.projects = config.projects.map(strip);
|
|
149
|
+
for (const legacy of ["frontend", "backend"]) {
|
|
150
|
+
if (config[legacy] && typeof config[legacy] === "object")
|
|
151
|
+
config[legacy] = strip(config[legacy]);
|
|
152
|
+
}
|
|
153
|
+
return JSON.stringify(config);
|
|
154
|
+
}
|
|
155
|
+
return JSON.stringify(parsed);
|
|
156
|
+
}
|
|
157
|
+
/** Lines removed (`- `) and added (`+ `) from `a` to `b`, by longest common subsequence; at most 20. */
|
|
158
|
+
export function lineDiff(a, b, max = 20) {
|
|
159
|
+
const x = a.split(/\r?\n/);
|
|
160
|
+
const y = b.split(/\r?\n/);
|
|
161
|
+
const lcs = Array.from({ length: x.length + 1 }, () => new Array(y.length + 1).fill(0));
|
|
162
|
+
for (let i = x.length - 1; i >= 0; i--) {
|
|
163
|
+
for (let j = y.length - 1; j >= 0; j--) {
|
|
164
|
+
lcs[i][j] = x[i] === y[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const out = [];
|
|
168
|
+
let i = 0;
|
|
169
|
+
let j = 0;
|
|
170
|
+
while (i < x.length || j < y.length) {
|
|
171
|
+
if (i < x.length && j < y.length && x[i] === y[j]) {
|
|
172
|
+
i++;
|
|
173
|
+
j++;
|
|
174
|
+
}
|
|
175
|
+
else if (i < x.length && (j === y.length || lcs[i + 1][j] >= lcs[i][j + 1])) {
|
|
176
|
+
out.push(`- ${x[i++]}`);
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
out.push(`+ ${y[j++]}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return out.length > max ? [...out.slice(0, max), `… ${out.length - max} more changed lines`] : out;
|
|
183
|
+
}
|
package/dist/core/config.js
CHANGED
|
@@ -2,6 +2,23 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import YAML from "yaml";
|
|
4
4
|
export const ADAPTER_IDS = ["angular", "dotnet", "generic"];
|
|
5
|
+
/** The AI coding tools `init` can set up: Claude Code, GitHub Copilot, Codex. */
|
|
6
|
+
export const TOOL_IDS = ["claude-code", "copilot", "codex"];
|
|
7
|
+
/** `tools:` as configured, checked; undefined when the key is absent. */
|
|
8
|
+
export function parseTools(value, where = "tools") {
|
|
9
|
+
if (value === undefined || value === null)
|
|
10
|
+
return undefined;
|
|
11
|
+
const list = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : undefined;
|
|
12
|
+
if (!list)
|
|
13
|
+
throw new Error(`${where} must be a list of ${TOOL_IDS.join(", ")}.`);
|
|
14
|
+
const tools = list.map((t) => String(t).trim()).filter(Boolean);
|
|
15
|
+
for (const t of tools) {
|
|
16
|
+
if (!TOOL_IDS.includes(t)) {
|
|
17
|
+
throw new Error(`${where}: "${t}" is not one of ${TOOL_IDS.join(", ")}.`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return TOOL_IDS.filter((t) => tools.includes(t));
|
|
21
|
+
}
|
|
5
22
|
export const DEFAULT_FRONTEND_METHODS = ["isEnabled", "isFeatureEnabled", "hasFeature", "getFlag", "isOn"];
|
|
6
23
|
export const DEFAULT_BACKEND_METHODS = [
|
|
7
24
|
"IsEnabledAsync",
|
|
@@ -12,10 +29,11 @@ export const DEFAULT_BACKEND_METHODS = [
|
|
|
12
29
|
"GetValue<bool>",
|
|
13
30
|
];
|
|
14
31
|
export const DEFAULT_BACKEND_ATTRIBUTES = ["FeatureGate", "Feature"];
|
|
15
|
-
const CONFIG_FILENAMES = ["flagrm.config.yaml", "flagrm.config.yml", "flagrm.config.json"];
|
|
32
|
+
export const CONFIG_FILENAMES = ["flagrm.config.yaml", "flagrm.config.yml", "flagrm.config.json"];
|
|
16
33
|
/** Known top-level `flagrm.config.yaml` keys — anything else is likely a typo. */
|
|
17
34
|
const KNOWN_CONFIG_KEYS = [
|
|
18
35
|
"projects",
|
|
36
|
+
"tools",
|
|
19
37
|
"frontend",
|
|
20
38
|
"backend",
|
|
21
39
|
"frontendMethods",
|
|
@@ -245,8 +263,11 @@ export function loadConfig(cli, cwd = process.cwd()) {
|
|
|
245
263
|
if (projects.length === 0) {
|
|
246
264
|
throw new Error("No projects configured. Add a `projects:` list to flagrm.config.yaml, or pass --frontend and/or --backend.");
|
|
247
265
|
}
|
|
266
|
+
const tools = parseTools(file.tools);
|
|
248
267
|
return {
|
|
249
268
|
root: fileDir,
|
|
269
|
+
file: configPath && fs.existsSync(configPath) ? configPath : undefined,
|
|
270
|
+
...(tools ? { tools } : {}),
|
|
250
271
|
projects,
|
|
251
272
|
exclude: [...(file.exclude ?? []), ...(cli.exclude ?? [])],
|
|
252
273
|
include: [...(file.include ?? []), ...(cli.include ?? [])],
|