flagrm 0.3.0 → 0.4.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 +100 -0
- package/README.md +56 -29
- package/dist/adapters/angular/component-files.js +41 -0
- package/dist/adapters/angular/flow.js +121 -0
- package/dist/adapters/angular/holders.js +194 -0
- package/dist/adapters/angular/index.js +20 -1
- package/dist/adapters/angular/inputs.js +181 -0
- package/dist/cli.js +41 -21
- package/dist/core/baseline.js +60 -31
- package/dist/core/change-filter.js +3 -2
- package/dist/core/config-snapshot.js +26 -3
- package/dist/core/config.js +5 -1
- package/dist/core/detect.js +4 -4
- package/dist/core/doctor.js +26 -6
- package/dist/core/environment.js +25 -3
- package/dist/core/git.js +50 -3
- package/dist/core/hook.js +13 -2
- package/dist/core/install.js +26 -11
- package/dist/core/inventory.js +3 -1
- package/dist/core/markdown.js +4 -0
- package/dist/core/name-owner.js +179 -0
- package/dist/core/report.js +7 -0
- package/dist/core/run-cache.js +147 -0
- package/dist/core/scan.js +30 -3
- package/dist/core/scope-files.js +14 -0
- package/dist/core/scope.js +3 -3
- package/dist/core/test-runner.js +136 -0
- package/dist/core/types.js +9 -4
- package/dist/core/verify/commands.js +160 -22
- package/dist/core/verify/coverage.js +92 -0
- package/dist/core/verify/index.js +32 -5
- package/dist/core/verify/log-results.js +127 -0
- package/dist/core/verify/removed-tests.js +90 -7
- package/dist/core/verify/test-results.js +58 -10
- package/package.json +4 -4
- package/skills/flagrm-remove/SKILL.md +28 -8
- package/skills/flagrm-remove/batch.md +38 -0
- package/skills/flagrm-verify/SKILL.md +12 -6
package/dist/core/config.js
CHANGED
|
@@ -48,9 +48,11 @@ const KNOWN_PROJECT_KEYS = [
|
|
|
48
48
|
"path",
|
|
49
49
|
"build",
|
|
50
50
|
"test",
|
|
51
|
+
"lint",
|
|
51
52
|
"testResults",
|
|
52
53
|
"timeout",
|
|
53
54
|
"globs",
|
|
55
|
+
"covers",
|
|
54
56
|
"methods",
|
|
55
57
|
"attributes",
|
|
56
58
|
];
|
|
@@ -200,14 +202,16 @@ function parseProject(raw, index, file, fileDir, quiet = false) {
|
|
|
200
202
|
path: resolveProjectDir(fileDir, p.path, `Project "${name}"`),
|
|
201
203
|
build: optionalCommand(p.build, `${where}.build`),
|
|
202
204
|
test: optionalCommand(p.test, `${where}.test`),
|
|
205
|
+
lint: optionalCommand(p.lint, `${where}.lint`),
|
|
203
206
|
testResults: testResults && path.resolve(fileDir, testResults),
|
|
204
207
|
timeout: optionalPositiveNumber(p.timeout, `${where}.timeout`),
|
|
205
208
|
globs: optionalStringList(p.globs, `${where}.globs`),
|
|
209
|
+
covers: optionalStringList(p.covers, `${where}.covers`),
|
|
206
210
|
methods: optionalStringList(p.methods, `${where}.methods`) ?? defaults.methods,
|
|
207
211
|
attributes: optionalStringList(p.attributes, `${where}.attributes`) ?? defaults.attributes,
|
|
208
212
|
};
|
|
209
213
|
// Keep the resolved shape free of absent optional keys.
|
|
210
|
-
for (const key of ["build", "test", "testResults", "timeout", "globs"]) {
|
|
214
|
+
for (const key of ["build", "test", "lint", "testResults", "timeout", "globs", "covers"]) {
|
|
211
215
|
if (project[key] === undefined)
|
|
212
216
|
delete project[key];
|
|
213
217
|
}
|
package/dist/core/detect.js
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* saves the user from editing a template, and they check the result.
|
|
6
6
|
*/
|
|
7
7
|
import path from "node:path";
|
|
8
|
-
import
|
|
8
|
+
import { globSync } from "tinyglobby";
|
|
9
|
+
import { detectTestRunner } from "./test-runner.js";
|
|
9
10
|
const IGNORE = ["**/node_modules/**", "**/.git/**", "**/bin/**", "**/obj/**", "**/dist/**"];
|
|
10
11
|
const depth = (file) => file.split("/").length;
|
|
11
12
|
/** Markers whose directory isn't inside another marker's directory, one per directory. */
|
|
@@ -31,12 +32,11 @@ function commonDir(files) {
|
|
|
31
32
|
return common.join("/") || ".";
|
|
32
33
|
}
|
|
33
34
|
export function detectProjects(cwd) {
|
|
34
|
-
const find = (pattern) =>
|
|
35
|
+
const find = (pattern) => globSync(pattern, { expandDirectories: false, cwd, deep: 3, ignore: IGNORE });
|
|
35
36
|
const found = [];
|
|
36
37
|
for (const marker of topmost(find("**/angular.json"))) {
|
|
37
38
|
const dir = path.posix.dirname(marker);
|
|
38
|
-
|
|
39
|
-
found.push({ adapter: "angular", path: dir, marker, jest });
|
|
39
|
+
found.push({ adapter: "angular", path: dir, marker, testRunner: detectTestRunner(path.join(cwd, dir)) });
|
|
40
40
|
}
|
|
41
41
|
const solutions = topmost(find("**/*.{sln,slnx}"));
|
|
42
42
|
if (solutions.length) {
|
package/dist/core/doctor.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `flagrm doctor`: is this repository ready for an agent to remove a flag?
|
|
3
|
-
* Config, build/test commands, git, .gitignore, the installed skills and
|
|
3
|
+
* Config, build/test/lint commands, git, .gitignore, the installed skills and
|
|
4
4
|
* hook, and baselines left behind by abandoned removals.
|
|
5
5
|
*/
|
|
6
6
|
import fs from "node:fs";
|
|
@@ -15,8 +15,20 @@ import { installedSkillVersion, SKILLS, stopHookState, TOOL_SKILL_DIRS } from ".
|
|
|
15
15
|
import { packageVersion } from "./package.js";
|
|
16
16
|
import { resolveProjects } from "./registry.js";
|
|
17
17
|
import { setupReport } from "./scope.js";
|
|
18
|
+
import { detectTestRunner, testResultsHint } from "./test-runner.js";
|
|
18
19
|
import { savedTools } from "./tools.js";
|
|
19
20
|
import { onPath, readJson, relativePath } from "./util.js";
|
|
21
|
+
import { hasTestNames } from "./verify/test-results.js";
|
|
22
|
+
/** How to get test results for a project without `testResults`: .NET writes TRX, JavaScript runners by detection. */
|
|
23
|
+
function missingResultsFix(adapterId, ctx, root) {
|
|
24
|
+
const dir = path.join(root, ".flagrm", "test-results", ctx.name);
|
|
25
|
+
const fromProject = relativePath(ctx.root, dir) || ".";
|
|
26
|
+
const fromConfig = `./${relativePath(root, dir)}`;
|
|
27
|
+
if (adapterId === "dotnet") {
|
|
28
|
+
return `add \`--logger trx --results-directory "${fromProject}"\` to the test command and set testResults: ${fromConfig}/*.trx`;
|
|
29
|
+
}
|
|
30
|
+
return testResultsHint(detectTestRunner(ctx.root), fromProject, fromConfig, adapterId === "angular", root);
|
|
31
|
+
}
|
|
20
32
|
/** Check the setup in `cwd`: config and commands, git, .gitignore, skills, hook and abandoned baselines. */
|
|
21
33
|
export async function runDoctor(cwd, options = {}) {
|
|
22
34
|
const checks = [];
|
|
@@ -93,10 +105,13 @@ async function configChecks(cwd, configFile, options, add) {
|
|
|
93
105
|
setupChecks(projects, root, options.probes ?? realProbes((exe) => onPath(exe, root)), add);
|
|
94
106
|
for (const { adapter, ctx } of projects) {
|
|
95
107
|
const commands = projectCommands(adapter, ctx);
|
|
96
|
-
for (const check of ["build", "test"]) {
|
|
108
|
+
for (const check of ["build", "test", "lint"]) {
|
|
97
109
|
const command = commands[check];
|
|
98
110
|
if (!command) {
|
|
99
|
-
|
|
111
|
+
// Lint is optional: verify skips it without a command, which is no problem.
|
|
112
|
+
if (check !== "lint") {
|
|
113
|
+
add("warn", `${ctx.name}: no ${check} command — verify can't ${check === "build" ? "check the build" : "compare tests"}`);
|
|
114
|
+
}
|
|
100
115
|
continue;
|
|
101
116
|
}
|
|
102
117
|
const missing = simpleCommandProgram(command);
|
|
@@ -111,7 +126,7 @@ async function configChecks(cwd, configFile, options, add) {
|
|
|
111
126
|
}
|
|
112
127
|
}
|
|
113
128
|
if (check === "test" && !commands.testResults) {
|
|
114
|
-
add("warn", `${ctx.name}: test command has no testResults — verify
|
|
129
|
+
add("warn", `${ctx.name}: test command has no testResults — verify reads failing tests and counts from the log only and deleted tests from the diff, so it can't compare tests by name`, [`fix: ${missingResultsFix(adapter.id, ctx, root)}`]);
|
|
115
130
|
}
|
|
116
131
|
if (!options.run) {
|
|
117
132
|
const source = commands.defaults.includes(check) ? " (adapter default)" : "";
|
|
@@ -121,16 +136,21 @@ async function configChecks(cwd, configFile, options, add) {
|
|
|
121
136
|
const log = path.join(root, ".flagrm", "doctor", `${ctx.name}.${check}.log`);
|
|
122
137
|
const run = await runCheck(ctx, check, command, log, undefined, commands.testResults);
|
|
123
138
|
// Proven here rather than halfway through a removal: without readable results, verify can't compare tests.
|
|
124
|
-
if (check === "test" && commands.testResults && !run.results) {
|
|
139
|
+
if (check === "test" && commands.testResults && !hasTestNames(run.results)) {
|
|
125
140
|
add("fail", `${ctx.name}: \`${command}\` wrote no test results matching ${relativePath(cwd, commands.testResults)}`, run.exitCode === 0 ? undefined : errorExcerpt(run.log, 3));
|
|
126
141
|
continue;
|
|
127
142
|
}
|
|
128
143
|
// A suite that is already red is no setup problem: the baseline records those tests as known failures.
|
|
129
|
-
if (run.exitCode !== 0 && run.results?.failed) {
|
|
144
|
+
if (run.exitCode !== 0 && run.results?.failed && run.results.failedNames.length >= run.results.failed) {
|
|
130
145
|
const failed = run.results.failed;
|
|
131
146
|
add("warn", `${ctx.name}: \`${command}\`: ${failed === 1 ? "1 test fails already — the baseline records it as a known failure" : `${failed} tests fail already — the baseline records them as known failures`}, which verify only warns about`);
|
|
132
147
|
continue;
|
|
133
148
|
}
|
|
149
|
+
// Lint debt is no setup problem: verify only warns about a lint run that already failed at the baseline.
|
|
150
|
+
if (check === "lint" && run.exitCode !== 0 && !run.failure && !run.interrupted) {
|
|
151
|
+
add("warn", `${ctx.name}: \`${command}\` fails already (exit ${run.exitCode}) — verify only warns about lint errors the baseline had`, errorExcerpt(run.log, 3));
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
134
154
|
if (run.exitCode === 0) {
|
|
135
155
|
add("ok", `${ctx.name}: \`${command}\` passed`);
|
|
136
156
|
continue;
|
package/dist/core/environment.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tell a machine setup problem apart from broken code in a failed build or
|
|
3
3
|
* test log: a missing .NET SDK, a tool a build step calls that isn't
|
|
4
|
-
* installed, Docker not running for Testcontainers
|
|
5
|
-
* that says what to fix.
|
|
4
|
+
* installed, Docker not running for Testcontainers, a package script or
|
|
5
|
+
* ESLint config that doesn't exist. Each becomes one line that says what to fix.
|
|
6
6
|
*/
|
|
7
7
|
import fs from "node:fs";
|
|
8
8
|
import path from "node:path";
|
|
@@ -12,7 +12,13 @@ import { relativePath } from "./util.js";
|
|
|
12
12
|
* it shows none. `root` is the project root: paths are reported relative to it.
|
|
13
13
|
*/
|
|
14
14
|
export function environmentProblem(log, root) {
|
|
15
|
-
const problems = [
|
|
15
|
+
const problems = [
|
|
16
|
+
missingSdk(log, root),
|
|
17
|
+
...missingCommands(log, root),
|
|
18
|
+
dockerUnavailable(log),
|
|
19
|
+
missingScript(log),
|
|
20
|
+
eslintBroken(log),
|
|
21
|
+
].filter(Boolean);
|
|
16
22
|
return problems.length ? problems.join("; ") : undefined;
|
|
17
23
|
}
|
|
18
24
|
/**
|
|
@@ -77,3 +83,19 @@ function dockerUnavailable(log) {
|
|
|
77
83
|
return undefined;
|
|
78
84
|
return "Docker is not running (Testcontainers tests need it): start Docker or leave those tests out of `test`";
|
|
79
85
|
}
|
|
86
|
+
/** npm and pnpm print `Missing script: "lint"`, Yarn 1 `Command "lint" not found`, Yarn 2+ `Couldn't find a script named "lint"`. */
|
|
87
|
+
function missingScript(log) {
|
|
88
|
+
const name = /\bMissing script: "?([^"\s]+)"?/.exec(log)?.[1] ??
|
|
89
|
+
/\berror Command "([^"]+)" not found/.exec(log)?.[1] ??
|
|
90
|
+
/Couldn't find a script named "([^"]+)"/.exec(log)?.[1];
|
|
91
|
+
if (!name)
|
|
92
|
+
return undefined;
|
|
93
|
+
return `package.json has no "${name}" script: add it or change the command in the config`;
|
|
94
|
+
}
|
|
95
|
+
/** ESLint that can't find its config or crashes exits 2 before it lints anything. */
|
|
96
|
+
function eslintBroken(log) {
|
|
97
|
+
if (!/ESLint couldn't find (?:a configuration file|an eslint\.config)|Oops! Something went wrong! :\(/.test(log)) {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
return "ESLint could not run (no config found, or it crashed): fix its setup or change the command in the config";
|
|
101
|
+
}
|
package/dist/core/git.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* to the config root (`cwd`), and flagrm's own `.flagrm/` directory is ignored.
|
|
4
4
|
*/
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
6
7
|
import fs from "node:fs";
|
|
7
8
|
import os from "node:os";
|
|
8
9
|
import path from "node:path";
|
|
@@ -12,6 +13,8 @@ function git(cwd, ...args) {
|
|
|
12
13
|
const result = spawnSync("git", args, { cwd, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
|
13
14
|
return { ok: result.status === 0, stdout: result.stdout ?? "" };
|
|
14
15
|
}
|
|
16
|
+
/** Whether git reads `rev` as a revision: one starting with `-` (`--output=<file>`) would be an option. */
|
|
17
|
+
const isRevision = (rev) => rev !== "" && !rev.startsWith("-");
|
|
15
18
|
/** NUL-separated paths (`-z`), which git never quotes or escapes, whatever the file name. */
|
|
16
19
|
const paths = (text) => text.split("\0").filter(Boolean);
|
|
17
20
|
/** Untracked (and not ignored) files under `cwd`. */
|
|
@@ -26,11 +29,41 @@ export function gitState(cwd) {
|
|
|
26
29
|
const dirtyFiles = changedFilesSince(cwd, "HEAD") ?? [];
|
|
27
30
|
return { sha: head.stdout.trim(), dirty: dirtyFiles.length > 0, dirtyFiles };
|
|
28
31
|
}
|
|
32
|
+
function hashObjects(cwd, files) {
|
|
33
|
+
const result = spawnSync("git", ["hash-object", "--stdin-paths"], {
|
|
34
|
+
cwd,
|
|
35
|
+
encoding: "utf8",
|
|
36
|
+
input: `${files.join("\n")}\n`,
|
|
37
|
+
});
|
|
38
|
+
return result.status === 0 ? (result.stdout ?? "").split("\n") : undefined;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Each file's git blob id as it is in the working tree (null when it doesn't
|
|
42
|
+
* exist), so a later run can tell whether it changed since. A file git can't
|
|
43
|
+
* hash (a submodule directory) gets an id of its own each time, so it always
|
|
44
|
+
* counts as changed. Paths are relative to `cwd`.
|
|
45
|
+
*/
|
|
46
|
+
export function blobIds(cwd, files) {
|
|
47
|
+
const ids = {};
|
|
48
|
+
const existing = files.filter((rel) => fs.existsSync(path.join(cwd, rel)));
|
|
49
|
+
for (const rel of files)
|
|
50
|
+
ids[rel] = null;
|
|
51
|
+
if (existing.length === 0)
|
|
52
|
+
return ids;
|
|
53
|
+
// One git call for all files; when it fails, one per file, so a single unhashable path doesn't spoil the rest.
|
|
54
|
+
const out = hashObjects(cwd, existing);
|
|
55
|
+
existing.forEach((rel, i) => {
|
|
56
|
+
ids[rel] = (out ? out[i] : hashObjects(cwd, [rel])?.[0]) || `unhashable:${randomUUID()}`;
|
|
57
|
+
});
|
|
58
|
+
return ids;
|
|
59
|
+
}
|
|
29
60
|
/**
|
|
30
61
|
* Files added, modified or deleted in the working tree since `sha` (tracked
|
|
31
62
|
* diff plus untracked files), without the ones `ignore` names.
|
|
32
63
|
*/
|
|
33
64
|
export function changedFilesSince(cwd, sha, ignore) {
|
|
65
|
+
if (!isRevision(sha))
|
|
66
|
+
return undefined;
|
|
34
67
|
const diff = git(cwd, "diff", "-z", "--name-only", "--no-renames", "--relative", sha, ...PATHSPEC);
|
|
35
68
|
if (!diff.ok)
|
|
36
69
|
return undefined;
|
|
@@ -43,6 +76,8 @@ export function changedFilesSince(cwd, sha, ignore) {
|
|
|
43
76
|
* Undefined when git can't tell.
|
|
44
77
|
*/
|
|
45
78
|
export function trackedChangesSince(cwd, sha, ignore) {
|
|
79
|
+
if (!isRevision(sha))
|
|
80
|
+
return undefined;
|
|
46
81
|
const diff = git(cwd, "diff", "-z", "--name-only", "--no-renames", "--relative", sha, ...PATHSPEC);
|
|
47
82
|
if (!diff.ok)
|
|
48
83
|
return undefined;
|
|
@@ -51,6 +86,8 @@ export function trackedChangesSince(cwd, sha, ignore) {
|
|
|
51
86
|
}
|
|
52
87
|
/** A file's content at `sha`, or undefined when it did not exist there. */
|
|
53
88
|
export function fileAt(cwd, sha, relativePath) {
|
|
89
|
+
if (!isRevision(sha))
|
|
90
|
+
return undefined;
|
|
54
91
|
const result = git(cwd, "show", `${sha}:./${relativePath.split(path.sep).join("/")}`);
|
|
55
92
|
return result.ok ? result.stdout : undefined;
|
|
56
93
|
}
|
|
@@ -108,17 +145,27 @@ export function treeFingerprint(cwd, ignore) {
|
|
|
108
145
|
* doesn't have it), so the fingerprint doesn't see changes to it.
|
|
109
146
|
*/
|
|
110
147
|
function dropIgnored(cwd, index, ignore) {
|
|
111
|
-
const
|
|
148
|
+
const head = headSha(cwd);
|
|
149
|
+
// The paths that differ from HEAD, deleted ones included: `ls-files` no longer lists a deleted file.
|
|
150
|
+
// Before the first commit there is no HEAD to compare with, so every path in the index.
|
|
151
|
+
const listing = head
|
|
152
|
+
? gitWithIndex(cwd, index, ["diff-index", "--cached", "--name-only", "--no-renames", "--relative", "-z", "HEAD"])
|
|
153
|
+
: gitWithIndex(cwd, index, ["ls-files", "-z"]);
|
|
154
|
+
if (!listing.ok)
|
|
155
|
+
return false;
|
|
156
|
+
const listed = paths(listing.stdout).filter(ignore);
|
|
112
157
|
if (listed.length === 0)
|
|
113
158
|
return true;
|
|
114
159
|
const input = `${listed.join("\0")}\0`;
|
|
115
160
|
const fromFile = ["--pathspec-from-file=-", "--pathspec-file-nul"];
|
|
116
161
|
// `reset` also drops a path HEAD doesn't have; before the first commit there is no HEAD to reset to.
|
|
117
|
-
const args =
|
|
162
|
+
const args = head ? ["reset", "-q", "HEAD", ...fromFile] : ["rm", "-q", "--cached", ...fromFile];
|
|
118
163
|
return gitWithIndex(cwd, index, args, input).ok;
|
|
119
164
|
}
|
|
120
165
|
/** The tree id of `cwd` at commit `sha`. */
|
|
121
166
|
export function treeAt(cwd, sha) {
|
|
167
|
+
if (!isRevision(sha))
|
|
168
|
+
return undefined;
|
|
122
169
|
const prefix = repoPrefix(cwd);
|
|
123
170
|
if (prefix === undefined)
|
|
124
171
|
return undefined;
|
|
@@ -132,5 +179,5 @@ export function headSha(cwd) {
|
|
|
132
179
|
}
|
|
133
180
|
/** Whether `sha` is HEAD or one of its ancestors. */
|
|
134
181
|
export function isAncestor(cwd, sha) {
|
|
135
|
-
return git(cwd, "merge-base", "--is-ancestor", sha, "HEAD").ok;
|
|
182
|
+
return isRevision(sha) && git(cwd, "merge-base", "--is-ancestor", sha, "HEAD").ok;
|
|
136
183
|
}
|
package/dist/core/hook.js
CHANGED
|
@@ -8,6 +8,7 @@ import fs from "node:fs";
|
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import { changeFilterAt } from "./change-filter.js";
|
|
10
10
|
import { findConfigRoot } from "./config.js";
|
|
11
|
+
import { scanConfigChanged } from "./config-snapshot.js";
|
|
11
12
|
import { headSha, trackedChangesSince, treeFingerprint } from "./git.js";
|
|
12
13
|
import { readJson } from "./util.js";
|
|
13
14
|
/** The hook's stdin: only a JSON object counts, and only fields of the expected type are kept. */
|
|
@@ -38,7 +39,8 @@ export function hookRoot(cwd) {
|
|
|
38
39
|
* matches the current tree. In progress means HEAD is still the baseline
|
|
39
40
|
* commit and a file git tracks changed, other than flagrm's setup files and
|
|
40
41
|
* paths matching `exclude` (a removal paused on a question to the user, or a
|
|
41
|
-
* stray test result file, doesn't count),
|
|
42
|
+
* stray test result file, doesn't count), the config changed in what it
|
|
43
|
+
* scans or ignores, or the last verify skipped checks.
|
|
42
44
|
* The fingerprint leaves out the same paths, as verify's does. Once HEAD moves (a
|
|
43
45
|
* commit, a branch switch, a merge), that removal is no longer guarded.
|
|
44
46
|
*/
|
|
@@ -61,7 +63,7 @@ export function stopDecision(root, input) {
|
|
|
61
63
|
return { block: false };
|
|
62
64
|
const ignore = changeFilterAt(root);
|
|
63
65
|
const edited = (trackedChangesSince(root, head, ignore) ?? []).length > 0;
|
|
64
|
-
const started = active.filter((flagDir) => edited || lastVerifySkipped(flagDir));
|
|
66
|
+
const started = active.filter((flagDir) => edited || lastVerifySkipped(flagDir) || configChanged(flagDir, root));
|
|
65
67
|
if (started.length === 0)
|
|
66
68
|
return { block: false };
|
|
67
69
|
const current = treeFingerprint(root, ignore);
|
|
@@ -74,6 +76,15 @@ export function stopDecision(root, input) {
|
|
|
74
76
|
return { block: false };
|
|
75
77
|
}
|
|
76
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Whether the config changed since the baseline (or `--accept config`) in what
|
|
81
|
+
* is scanned or ignored: widening `exclude` would otherwise hide the removal's
|
|
82
|
+
* edits from the hook. A verify passing on this tree shows the change and allows.
|
|
83
|
+
*/
|
|
84
|
+
function configChanged(flagDir, root) {
|
|
85
|
+
const baseline = readJson(path.join(flagDir, "baseline.json"));
|
|
86
|
+
return scanConfigChanged(baseline?.acceptedConfig ?? baseline?.config, root);
|
|
87
|
+
}
|
|
77
88
|
function lastVerifySkipped(flagDir) {
|
|
78
89
|
const skipped = readJson(path.join(flagDir, "verify.json"))?.skipped;
|
|
79
90
|
return Array.isArray(skipped) && skipped.length > 0;
|
package/dist/core/install.js
CHANGED
|
@@ -11,6 +11,7 @@ import path from "node:path";
|
|
|
11
11
|
import { DEFAULT_BACKEND_ATTRIBUTES, DEFAULT_BACKEND_METHODS, DEFAULT_FRONTEND_METHODS, findConfigRoot, findDefaultConfigFile, repositoryRoot, TOOL_IDS, } from "./config.js";
|
|
12
12
|
import { detectProjects } from "./detect.js";
|
|
13
13
|
import { packageRoot, packageVersion } from "./package.js";
|
|
14
|
+
import { runnerTestCommands, testResultsHint } from "./test-runner.js";
|
|
14
15
|
import { saveTools } from "./tools.js";
|
|
15
16
|
import { relativePath } from "./util.js";
|
|
16
17
|
export const SKILLS = ["flagrm-remove", "flagrm-verify"];
|
|
@@ -27,11 +28,11 @@ export const PROMPT_FILES = {
|
|
|
27
28
|
"flagrm-remove": [
|
|
28
29
|
"---",
|
|
29
30
|
"description: Remove a feature flag with flagrm's guardrails (keep the ON path, verify until it passes)",
|
|
30
|
-
"argument-hint: <flag>",
|
|
31
|
+
"argument-hint: <flag> [flag...]",
|
|
31
32
|
"agent: agent",
|
|
32
33
|
"---",
|
|
33
34
|
"",
|
|
34
|
-
"Follow `.github/skills/flagrm-remove/SKILL.md` to remove the feature
|
|
35
|
+
"Follow `.github/skills/flagrm-remove/SKILL.md` to remove the feature flags named after this command.",
|
|
35
36
|
"",
|
|
36
37
|
].join("\n"),
|
|
37
38
|
"flagrm-verify": [
|
|
@@ -61,8 +62,8 @@ export const AGENTS_BLOCK = [
|
|
|
61
62
|
AGENTS_START,
|
|
62
63
|
"## Feature flag removal (flagrm)",
|
|
63
64
|
"",
|
|
64
|
-
"To remove
|
|
65
|
-
"
|
|
65
|
+
"To remove one or more feature flags, follow `.agents/skills/flagrm-remove/SKILL.md`. To check a removal",
|
|
66
|
+
"later, follow `.agents/skills/flagrm-verify/SKILL.md`.",
|
|
66
67
|
AGENTS_END,
|
|
67
68
|
].join("\n");
|
|
68
69
|
const CONFIG_HEADER = [
|
|
@@ -89,14 +90,21 @@ const EXAMPLE_PROJECTS = [
|
|
|
89
90
|
{ name: "backend", adapter: "dotnet", path: "backend", marker: "backend/Backend.sln" },
|
|
90
91
|
];
|
|
91
92
|
/** The config lines for one project, with commented-out commands that fit it. */
|
|
92
|
-
function projectLines(p) {
|
|
93
|
+
function projectLines(p, cwd) {
|
|
93
94
|
const dir = p.path === "." ? "." : `./${p.path}`;
|
|
94
95
|
const inDir = (rel) => (p.path === "." ? `./${rel}` : `${dir}/${rel}`);
|
|
95
96
|
const lines = [` - name: ${p.name}`, ` adapter: ${p.adapter}`, ` path: ${dir}`];
|
|
96
97
|
if (p.adapter === "angular") {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
const results = path.join(cwd, ".flagrm", "test-results", p.name);
|
|
99
|
+
const runner = p.testRunner ?? { kind: "unknown" };
|
|
100
|
+
const tests = runnerTestCommands(runner, path.join(cwd, p.path), results);
|
|
101
|
+
lines.push(" # build: npx ng build", ` # test: ${tests?.test ?? "npx ng test --watch=false"}`, tests
|
|
102
|
+
? ` # testResults: ./${relativePath(cwd, tests.testResults)} # the default test command writes these`
|
|
103
|
+
: ` # testResults: ${inDir("test-results/junit.xml")} # JUnit XML, TRX or Jest JSON, so verify can compare tests`, ...(tests
|
|
104
|
+
? []
|
|
105
|
+
: [
|
|
106
|
+
` # to get test results: ${testResultsHint(runner, relativePath(path.join(cwd, p.path), results) || ".", `./${relativePath(cwd, results)}`, true, cwd)}`,
|
|
107
|
+
]), ` # methods: [${DEFAULT_FRONTEND_METHODS.join(", ")}] # flag evaluation methods (defaults shown)`);
|
|
100
108
|
}
|
|
101
109
|
else {
|
|
102
110
|
const solution = /\.slnx?$/.test(p.marker) ? ` ${path.posix.basename(p.marker)}` : "";
|
|
@@ -104,13 +112,20 @@ function projectLines(p) {
|
|
|
104
112
|
}
|
|
105
113
|
return lines;
|
|
106
114
|
}
|
|
107
|
-
export function configTemplate(projects, tools) {
|
|
115
|
+
export function configTemplate(projects, tools, cwd = process.cwd()) {
|
|
108
116
|
const listed = projects.length ? projects : EXAMPLE_PROJECTS;
|
|
109
117
|
const [comment1, comment2, projectsKey] = CONFIG_HEADER;
|
|
110
118
|
const toolLines = tools
|
|
111
119
|
? ["# The AI coding tools `flagrm init` and `flagrm update` set up.", `tools: [${tools.join(", ")}]`]
|
|
112
120
|
: [];
|
|
113
|
-
return [
|
|
121
|
+
return [
|
|
122
|
+
comment1,
|
|
123
|
+
comment2,
|
|
124
|
+
...toolLines,
|
|
125
|
+
projectsKey,
|
|
126
|
+
...listed.flatMap((p) => projectLines(p, cwd)),
|
|
127
|
+
...CONFIG_FOOTER,
|
|
128
|
+
].join("\n");
|
|
114
129
|
}
|
|
115
130
|
/** Write `flagrm.config.yaml` for the projects found in `cwd` (an example when none are) unless a config file exists. */
|
|
116
131
|
export function writeConfigTemplate(cwd, tools) {
|
|
@@ -119,7 +134,7 @@ export function writeConfigTemplate(cwd, tools) {
|
|
|
119
134
|
return { path: relativePath(cwd, existing), status: "skipped" };
|
|
120
135
|
const file = path.join(cwd, "flagrm.config.yaml");
|
|
121
136
|
const projects = detectProjects(cwd);
|
|
122
|
-
fs.writeFileSync(file, configTemplate(projects, tools), "utf8");
|
|
137
|
+
fs.writeFileSync(file, configTemplate(projects, tools, cwd), "utf8");
|
|
123
138
|
const note = projects.length
|
|
124
139
|
? `detected ${projects.map((p) => `${p.adapter} (${p.marker})`).join(", ")}`
|
|
125
140
|
: "no Angular or .NET project found: edit the example projects";
|
package/dist/core/inventory.js
CHANGED
|
@@ -138,7 +138,9 @@ function followFlow(projects, entries) {
|
|
|
138
138
|
continue;
|
|
139
139
|
const words = flagWords([{ name: entry.flag }, ...entry.definitions]);
|
|
140
140
|
const flagNames = [entry.flag, ...entry.definitions.map((d) => d.name.split(".").pop() ?? d.name)];
|
|
141
|
-
|
|
141
|
+
// A renamed import of one of the flag's definitions is as specific as the definition.
|
|
142
|
+
const record = name.kind === "alias" || (!name.ambiguous && recordable(name, flagNames, words));
|
|
143
|
+
entry.flow.push({ ...name, record });
|
|
142
144
|
}
|
|
143
145
|
for (const test of tests)
|
|
144
146
|
byFlag.get(test.flag)?.parameterizedTests.push(test);
|
package/dist/core/markdown.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* for the user or a PR. Every fact comes from the report; the agent adds
|
|
4
4
|
* notes and the commit message around it.
|
|
5
5
|
*/
|
|
6
|
+
import { reusedNote } from "./run-cache.js";
|
|
6
7
|
import { relativePath } from "./util.js";
|
|
7
8
|
import { shortTestName, shortTestNames } from "./verify/test-results.js";
|
|
8
9
|
const ICON = { pass: "✅", fail: "❌", warn: "⚠️", skipped: "➖" };
|
|
@@ -30,6 +31,9 @@ export function verifyMarkdown(report, root) {
|
|
|
30
31
|
"|---|---|---|",
|
|
31
32
|
...report.checks.map((c) => `| ${c.id} | ${ICON[c.status]} ${c.status} | ${c.summary.replace(/\|/g, "\\|")} |`),
|
|
32
33
|
];
|
|
34
|
+
const note = reusedNote(report.runs, report.createdAt);
|
|
35
|
+
if (note)
|
|
36
|
+
out.push("", `_${note}_`);
|
|
33
37
|
const changed = report.changedFiles ?? [];
|
|
34
38
|
if (changed.length)
|
|
35
39
|
out.push("", `**Files changed since baseline (${changed.length}):** ${codeList(changed)}`);
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which flags a name's code reads: where the name is declared or assigned
|
|
3
|
+
* (`name =`, `name:`, `name(...) {`, `Name =>`, `Name {`), and the flag
|
|
4
|
+
* literals and qualified names in what follows, up to the end of that
|
|
5
|
+
* statement or body. Lexical like the rest of discovery (comments and strings
|
|
6
|
+
* masked), so it works for TypeScript and C# alike and misses unusual shapes.
|
|
7
|
+
*
|
|
8
|
+
* `baseline --name` refuses a name that reads only other flags: once
|
|
9
|
+
* recorded, `leftovers` would make the agent delete another flag's code, and
|
|
10
|
+
* a recorded name can't be removed again.
|
|
11
|
+
*/
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { UsageError } from "./baseline.js";
|
|
15
|
+
import { buildInventory } from "./inventory.js";
|
|
16
|
+
import { commentSyntax, computeLexicalMask, escapeRegExp, LEX_CODE, LEX_STRING } from "./scan.js";
|
|
17
|
+
import { SourceText } from "./source-text.js";
|
|
18
|
+
import { toLf } from "./text-utils.js";
|
|
19
|
+
import { relativePath } from "./util.js";
|
|
20
|
+
const MAX_LINES = 20;
|
|
21
|
+
const OPEN = "([{";
|
|
22
|
+
const CLOSE = ")]}";
|
|
23
|
+
/** Every declaration or assignment of `name` in `files` (absolute; every project file when undefined). */
|
|
24
|
+
export function nameReads(projects, name, files, flags) {
|
|
25
|
+
const only = files && new Set(files);
|
|
26
|
+
const word = new RegExp(`(?<![\\w$])${escapeRegExp(name)}(?![\\w$])`, "g");
|
|
27
|
+
const out = [];
|
|
28
|
+
const seen = new Set();
|
|
29
|
+
for (const { ctx } of projects) {
|
|
30
|
+
for (const file of ctx.files) {
|
|
31
|
+
if (seen.has(file) || (only && !only.has(file)) || commentSyntax(file) !== "slash")
|
|
32
|
+
continue;
|
|
33
|
+
seen.add(file);
|
|
34
|
+
let text;
|
|
35
|
+
try {
|
|
36
|
+
text = toLf(fs.readFileSync(file, "utf8"));
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (!text.includes(name))
|
|
42
|
+
continue;
|
|
43
|
+
const mask = computeLexicalMask(text, "slash");
|
|
44
|
+
const source = new SourceText(file, text);
|
|
45
|
+
for (const m of text.matchAll(word)) {
|
|
46
|
+
if (mask[m.index] !== LEX_CODE)
|
|
47
|
+
continue;
|
|
48
|
+
const after = m.index + name.length;
|
|
49
|
+
if (!isSite(text, mask, m.index, after))
|
|
50
|
+
continue;
|
|
51
|
+
const body = text.slice(after, statementEnd(text, mask, after));
|
|
52
|
+
out.push({ file, line: source.position(m.index).line, flags: flagsIn(body, flags) });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
/** A declaration or assignment of the name at `start..after`, not a read, a call, a ternary branch or a case label. */
|
|
59
|
+
function isSite(text, mask, start, after) {
|
|
60
|
+
const before = text.slice(Math.max(0, start - 40), start);
|
|
61
|
+
if (/\?\s*$/.test(before) || /\bcase\s+$/.test(before))
|
|
62
|
+
return false;
|
|
63
|
+
let i = after;
|
|
64
|
+
while (i < text.length && /[ \t]/.test(text[i]))
|
|
65
|
+
i++;
|
|
66
|
+
// `x?:` / `x!:` (optional, definite) and `x ??= y` declare or assign; `x != y`, `x ? a : b` and `x!.y` don't.
|
|
67
|
+
if (text[i] === "?" || text[i] === "!") {
|
|
68
|
+
const op = text.slice(i, i + 3);
|
|
69
|
+
return op[1] === ":" || op === "??=";
|
|
70
|
+
}
|
|
71
|
+
const rest = text.slice(i, i + 3);
|
|
72
|
+
if (rest.startsWith("=>"))
|
|
73
|
+
return true;
|
|
74
|
+
if (rest.startsWith("="))
|
|
75
|
+
return !rest.startsWith("==");
|
|
76
|
+
if (rest.startsWith(":"))
|
|
77
|
+
return true;
|
|
78
|
+
if (rest.startsWith("{"))
|
|
79
|
+
return true;
|
|
80
|
+
if (!rest.startsWith("("))
|
|
81
|
+
return false;
|
|
82
|
+
// A parameter list, then (after an optional return type, which may hold `|`, `.` or `<>`) a body or
|
|
83
|
+
// `=>`: a method. Anything else is a call: `;`/`,`/`}` or a line break first, or a `)` closing an
|
|
84
|
+
// enclosing bracket (`if (isX()) {`). Arrows and braces inside the call's own brackets don't count.
|
|
85
|
+
let depth = 0;
|
|
86
|
+
for (let j = i; j < Math.min(text.length, i + 400); j++) {
|
|
87
|
+
if (mask[j] !== LEX_CODE)
|
|
88
|
+
continue;
|
|
89
|
+
const c = text[j];
|
|
90
|
+
if (c === "(")
|
|
91
|
+
depth++;
|
|
92
|
+
else if (c === ")") {
|
|
93
|
+
if (--depth < 0)
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
else if (depth > 0)
|
|
97
|
+
continue;
|
|
98
|
+
else if (c === "{")
|
|
99
|
+
return true;
|
|
100
|
+
else if (c === "=" && text[j + 1] === ">")
|
|
101
|
+
return true;
|
|
102
|
+
else if (c === ";" || c === "," || c === "}")
|
|
103
|
+
return false;
|
|
104
|
+
else if (c === "\n") {
|
|
105
|
+
const head = text.slice(j + 1).trimStart();
|
|
106
|
+
return head.startsWith("{") || head.startsWith("=>");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
/** Where the statement or body starting at `from` ends: `;` or `,` at depth 0, a closing bracket below it, or a `{` body's `}`. */
|
|
112
|
+
function statementEnd(text, mask, from) {
|
|
113
|
+
let depth = 0;
|
|
114
|
+
let lines = 0;
|
|
115
|
+
for (let i = from; i < text.length; i++) {
|
|
116
|
+
if (text[i] === "\n" && ++lines >= MAX_LINES)
|
|
117
|
+
return i;
|
|
118
|
+
if (mask[i] !== LEX_CODE)
|
|
119
|
+
continue;
|
|
120
|
+
const c = text[i];
|
|
121
|
+
if (OPEN.includes(c))
|
|
122
|
+
depth++;
|
|
123
|
+
else if (CLOSE.includes(c)) {
|
|
124
|
+
depth--;
|
|
125
|
+
if (depth < 0)
|
|
126
|
+
return i;
|
|
127
|
+
if (depth === 0 && c === "}")
|
|
128
|
+
return i + 1;
|
|
129
|
+
}
|
|
130
|
+
else if (depth === 0 && (c === ";" || c === ","))
|
|
131
|
+
return i;
|
|
132
|
+
}
|
|
133
|
+
return text.length;
|
|
134
|
+
}
|
|
135
|
+
/** The flags whose literal (quoted) or qualified name (in code) occurs in `body`. */
|
|
136
|
+
function flagsIn(body, flags) {
|
|
137
|
+
const mask = computeLexicalMask(body, "slash");
|
|
138
|
+
return flags
|
|
139
|
+
.filter(({ flag, aliases }) => {
|
|
140
|
+
for (const m of body.matchAll(new RegExp(`(['"\`])${escapeRegExp(flag)}\\1`, "g"))) {
|
|
141
|
+
if (mask[m.index] === LEX_STRING)
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
return aliases.some((alias) => {
|
|
145
|
+
const pattern = alias.split(".").map(escapeRegExp).join("\\s*\\.\\s*");
|
|
146
|
+
for (const m of body.matchAll(new RegExp(`(?<![\\w$])${pattern}(?![\\w$])`, "g"))) {
|
|
147
|
+
if (mask[m.index] === LEX_CODE)
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
151
|
+
});
|
|
152
|
+
})
|
|
153
|
+
.map((f) => f.flag);
|
|
154
|
+
}
|
|
155
|
+
/** Throw a {@link UsageError} for the first agent name whose code reads only flags other than `flag`. */
|
|
156
|
+
export async function assertOwnNames(root, projects, flag, names) {
|
|
157
|
+
if (names.length === 0)
|
|
158
|
+
return;
|
|
159
|
+
const inventory = (await buildInventory(projects)).flags;
|
|
160
|
+
// A renamed import of the flag's registry (`AppFeatureFlags.X`) reads it as much as the definition does.
|
|
161
|
+
const refs = inventory.map((e) => ({
|
|
162
|
+
flag: e.flag,
|
|
163
|
+
aliases: [...e.definitions.map((d) => d.name), ...e.flow.filter((n) => n.kind === "alias").map((n) => n.name)],
|
|
164
|
+
}));
|
|
165
|
+
if (!refs.some((r) => r.flag === flag))
|
|
166
|
+
refs.push({ flag, aliases: [] });
|
|
167
|
+
for (const name of [...new Set(names.map((n) => n.name))]) {
|
|
168
|
+
const entries = names.filter((n) => n.name === name);
|
|
169
|
+
const files = entries.every((n) => n.file) ? entries.map((n) => path.resolve(root, n.file)) : undefined;
|
|
170
|
+
const sites = nameReads(projects, name, files, refs);
|
|
171
|
+
if (sites.some((s) => s.flags.includes(flag)))
|
|
172
|
+
continue;
|
|
173
|
+
const foreign = sites.find((s) => s.flags.length > 0);
|
|
174
|
+
if (!foreign)
|
|
175
|
+
continue;
|
|
176
|
+
throw new UsageError(`"${name}" reads ${foreign.flags.join(", ")}, not ${flag} (${relativePath(root, foreign.file)}:${foreign.line}); ` +
|
|
177
|
+
"not recorded. Record only names this flag's value goes through.");
|
|
178
|
+
}
|
|
179
|
+
}
|