flagrm 0.2.1 → 0.4.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/README.md +70 -31
  3. package/dist/adapters/angular/component-files.js +41 -0
  4. package/dist/adapters/angular/flow.js +121 -0
  5. package/dist/adapters/angular/holders.js +194 -0
  6. package/dist/adapters/angular/index.js +20 -1
  7. package/dist/adapters/angular/inputs.js +181 -0
  8. package/dist/adapters/dotnet/index.js +11 -1
  9. package/dist/cli.js +62 -21
  10. package/dist/core/baseline.js +98 -45
  11. package/dist/core/change-filter.js +68 -0
  12. package/dist/core/config-snapshot.js +36 -6
  13. package/dist/core/config.js +23 -7
  14. package/dist/core/detect.js +2 -2
  15. package/dist/core/doctor.js +51 -7
  16. package/dist/core/environment.js +25 -3
  17. package/dist/core/git.js +80 -16
  18. package/dist/core/hook.js +9 -6
  19. package/dist/core/install.js +26 -11
  20. package/dist/core/inventory.js +3 -1
  21. package/dist/core/markdown.js +4 -0
  22. package/dist/core/name-owner.js +179 -0
  23. package/dist/core/report.js +13 -0
  24. package/dist/core/run-cache.js +137 -0
  25. package/dist/core/scope-files.js +14 -0
  26. package/dist/core/scope.js +11 -2
  27. package/dist/core/test-runner.js +136 -0
  28. package/dist/core/types.js +9 -4
  29. package/dist/core/verify/commands.js +170 -29
  30. package/dist/core/verify/coverage.js +92 -0
  31. package/dist/core/verify/dead-code.js +9 -2
  32. package/dist/core/verify/index.js +42 -8
  33. package/dist/core/verify/log-results.js +127 -0
  34. package/dist/core/verify/orphans.js +175 -0
  35. package/dist/core/verify/removed-tests.js +90 -7
  36. package/dist/core/verify/test-results.js +56 -7
  37. package/package.json +3 -1
  38. package/skills/flagrm-remove/SKILL.md +31 -11
  39. package/skills/flagrm-remove/batch.md +38 -0
  40. package/skills/flagrm-verify/SKILL.md +12 -6
@@ -0,0 +1,194 @@
1
+ /**
2
+ * The names a flag's value is held in: fields, getters, parameterless
3
+ * methods and locals whose value is an evaluation of the flag (or its
4
+ * negation), and those whose value is such a name of the same class or
5
+ * function. A getter or method returning a ternary on an evaluation is
6
+ * recorded but not followed: it holds the ternary's value, not the flag's.
7
+ * Parsed, never type-checked.
8
+ */
9
+ import ts from "typescript";
10
+ export function skipParens(expr) {
11
+ let e = expr;
12
+ while (ts.isParenthesizedExpression(e))
13
+ e = e.expression;
14
+ return e;
15
+ }
16
+ /** `expr` without parentheses and leading `!`s. */
17
+ export function skipNot(expr) {
18
+ let e = expr;
19
+ for (;;) {
20
+ if (ts.isParenthesizedExpression(e))
21
+ e = e.expression;
22
+ else if (ts.isPrefixUnaryExpression(e) && e.operator === ts.SyntaxKind.ExclamationToken)
23
+ e = e.operand;
24
+ else
25
+ return e;
26
+ }
27
+ }
28
+ /**
29
+ * An evaluation: a call of a configured eval method, by the last name of its
30
+ * callee (`a.b.isFeatureFlagEnabled`), whose first argument names the flag;
31
+ * parentheses and `!` around it are allowed.
32
+ */
33
+ export function evaluationMatcher(evalMethods, flagOfArgument) {
34
+ return (file, expr) => {
35
+ const e = skipNot(expr);
36
+ if (!ts.isCallExpression(e) || e.arguments.length === 0)
37
+ return undefined;
38
+ const callee = skipParens(e.expression);
39
+ const name = ts.isPropertyAccessExpression(callee)
40
+ ? callee.name.text
41
+ : ts.isIdentifier(callee)
42
+ ? callee.text
43
+ : undefined;
44
+ if (!name || !evalMethods.includes(name))
45
+ return undefined;
46
+ return flagOfArgument(file, e.arguments[0]);
47
+ };
48
+ }
49
+ /**
50
+ * Every place a holder can be declared: a property with an initializer, a
51
+ * `this.x = …` statement, a getter or parameterless method whose body is a
52
+ * single `return`, a `const`/`let` in a function.
53
+ */
54
+ export function collectSites(files) {
55
+ const sites = [];
56
+ for (const file of files) {
57
+ const { sf } = file;
58
+ const lineOf = (node) => sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
59
+ const visit = (node, cls, fn) => {
60
+ if (cls && ts.isPropertyDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
61
+ sites.push({
62
+ file,
63
+ name: node.name.text,
64
+ kind: "field",
65
+ line: lineOf(node.name),
66
+ expr: node.initializer,
67
+ ternary: false,
68
+ cls,
69
+ });
70
+ }
71
+ else if (cls &&
72
+ ts.isExpressionStatement(node) &&
73
+ ts.isBinaryExpression(node.expression) &&
74
+ node.expression.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
75
+ ts.isPropertyAccessExpression(node.expression.left) &&
76
+ node.expression.left.expression.kind === ts.SyntaxKind.ThisKeyword &&
77
+ ts.isIdentifier(node.expression.left.name)) {
78
+ const { left, right } = node.expression;
79
+ sites.push({ file, name: left.name.text, kind: "field", line: lineOf(node), expr: right, ternary: false, cls });
80
+ }
81
+ else if (cls &&
82
+ (ts.isGetAccessorDeclaration(node) || (ts.isMethodDeclaration(node) && node.parameters.length === 0)) &&
83
+ ts.isIdentifier(node.name) &&
84
+ node.body?.statements.length === 1) {
85
+ const only = node.body.statements[0];
86
+ if (ts.isReturnStatement(only) && only.expression) {
87
+ const kind = ts.isGetAccessorDeclaration(node) ? "property" : "method";
88
+ const value = skipParens(only.expression);
89
+ const base = { file, name: node.name.text, kind, line: lineOf(node.name), cls };
90
+ if (ts.isConditionalExpression(value))
91
+ sites.push({ ...base, expr: value.condition, ternary: true });
92
+ else
93
+ sites.push({ ...base, expr: only.expression, ternary: false });
94
+ }
95
+ }
96
+ else if (fn &&
97
+ ts.isVariableDeclaration(node) &&
98
+ ts.isIdentifier(node.name) &&
99
+ node.initializer &&
100
+ ts.isVariableDeclarationList(node.parent) &&
101
+ node.parent.flags & (ts.NodeFlags.Const | ts.NodeFlags.Let)) {
102
+ sites.push({
103
+ file,
104
+ name: node.name.text,
105
+ kind: "local",
106
+ line: lineOf(node.name),
107
+ expr: node.initializer,
108
+ ternary: false,
109
+ cls,
110
+ fn,
111
+ });
112
+ }
113
+ const nextCls = ts.isClassLike(node) ? node : cls;
114
+ const nextFn = ts.isFunctionLike(node) ? node : fn;
115
+ ts.forEachChild(node, (child) => visit(child, nextCls, nextFn));
116
+ };
117
+ visit(sf, undefined, undefined);
118
+ }
119
+ return sites;
120
+ }
121
+ /**
122
+ * The holders among `sites`, repeated until nothing new: a site whose value
123
+ * is an evaluation, or a read of a holder of its class (`this.x`, `!this.x`,
124
+ * `this.x()`) or, for a local, of a local of its function (`x`, `!x`, `x()`).
125
+ * `seeds` (a child component's inputs) are holders from the start.
126
+ */
127
+ export function deriveHolders(sites, evaluates, seeds = []) {
128
+ const holders = [];
129
+ const members = new Map();
130
+ const locals = new Map();
131
+ const index = (scopes, scope, name, flag) => {
132
+ const names = scopes.get(scope) ?? new Map();
133
+ scopes.set(scope, names);
134
+ const flags = names.get(name) ?? new Set();
135
+ names.set(name, flags);
136
+ flags.add(flag);
137
+ };
138
+ const add = (h) => {
139
+ const same = (o) => o.flag === h.flag && o.name === h.name && o.kind === h.kind && o.cls === h.cls && o.fn === h.fn;
140
+ if (holders.some(same))
141
+ return false;
142
+ holders.push(h);
143
+ if (h.ternary || h.ambiguous)
144
+ return true;
145
+ if (h.kind === "local") {
146
+ if (h.fn)
147
+ index(locals, h.fn, h.name, h.flag);
148
+ }
149
+ else if (h.cls) {
150
+ index(members, h.cls, h.name, h.flag);
151
+ }
152
+ return true;
153
+ };
154
+ const flagsOf = (site) => {
155
+ const flag = evaluates(site.file, site.expr);
156
+ if (flag)
157
+ return [flag];
158
+ if (site.ternary)
159
+ return [];
160
+ let read = skipNot(site.expr);
161
+ if (ts.isCallExpression(read) && read.arguments.length === 0)
162
+ read = skipParens(read.expression);
163
+ if (ts.isPropertyAccessExpression(read) && read.expression.kind === ts.SyntaxKind.ThisKeyword) {
164
+ return [...((site.cls && members.get(site.cls)?.get(read.name.text)) ?? [])];
165
+ }
166
+ if (ts.isIdentifier(read))
167
+ return [...((site.fn && locals.get(site.fn)?.get(read.text)) ?? [])];
168
+ return [];
169
+ };
170
+ for (const seed of seeds)
171
+ add(seed);
172
+ for (let changed = true; changed;) {
173
+ changed = false;
174
+ for (const site of sites) {
175
+ for (const flag of flagsOf(site)) {
176
+ const holder = {
177
+ flag,
178
+ name: site.name,
179
+ kind: site.kind,
180
+ file: site.file.file,
181
+ line: site.line,
182
+ cls: site.cls,
183
+ };
184
+ if (site.kind === "local")
185
+ holder.fn = site.fn;
186
+ if (site.ternary)
187
+ holder.ternary = true;
188
+ if (add(holder))
189
+ changed = true;
190
+ }
191
+ }
192
+ }
193
+ return holders;
194
+ }
@@ -1,7 +1,11 @@
1
1
  import fs from "node:fs";
2
+ import path from "node:path";
2
3
  import { discoverInText } from "../../core/discover.js";
3
4
  import { FRONTEND_GLOBS } from "../../core/scan.js";
5
+ import { detectTestRunner, runnerTestCommands } from "../../core/test-runner.js";
6
+ import { componentScopeFiles } from "./component-files.js";
4
7
  import { discoverTypeScript } from "./discover.js";
8
+ import { angularFlow } from "./flow.js";
5
9
  import { angularUnusedDiagnostics } from "./unused.js";
6
10
  /**
7
11
  * Discover candidate flag names: string literals passed to configured eval
@@ -25,10 +29,25 @@ export function discoverAngularFlags(ctx) {
25
29
  }
26
30
  return out;
27
31
  }
32
+ /** The runner per project directory: projectCommands asks for the default commands many times per command. */
33
+ const runners = new Map();
34
+ function testRunner(root) {
35
+ let runner = runners.get(root);
36
+ if (!runner)
37
+ runners.set(root, (runner = detectTestRunner(root)));
38
+ return runner;
39
+ }
28
40
  export const angularAdapter = {
29
41
  id: "angular",
30
42
  defaultGlobs: FRONTEND_GLOBS,
31
43
  discover: async (ctx) => discoverAngularFlags(ctx),
32
- defaultCommands: () => ({ build: "npx ng build", test: "npx ng test --watch=false" }),
44
+ flow: (ctx, flags) => angularFlow(ctx, flags, ctx.project.methods),
45
+ scopeFiles: (_ctx, file) => componentScopeFiles(file),
46
+ // Like the .NET adapter: when the runner can write results without setup, the default test command does, under .flagrm/.
47
+ defaultCommands: (ctx) => {
48
+ const dir = path.join(ctx.config.root, ".flagrm", "test-results", ctx.name);
49
+ const tests = runnerTestCommands(testRunner(ctx.root), ctx.root, dir);
50
+ return { build: "npx ng build", ...(tests ?? { test: "npx ng test --watch=false" }) };
51
+ },
33
52
  unusedDiagnostics: (_ctx, input) => angularUnusedDiagnostics(input),
34
53
  };
@@ -0,0 +1,181 @@
1
+ /**
2
+ * One hop of a flag's value into a child component: a parent's template
3
+ * binds a holder (`[express]="expressShipping()"`) on an element whose tag is
4
+ * another component's selector, and the child's input of that binding name
5
+ * holds the flag too. Templates are scanned lexically; components are parsed,
6
+ * never type-checked. The child's own bindings are not followed.
7
+ */
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import ts from "typescript";
11
+ const ELEMENT_SELECTOR = /^[a-z][\w-]*$/i;
12
+ /** A start tag and its attributes; quoted values may contain `>`. */
13
+ const START_TAG = /<([a-zA-Z][\w-]*)((?:\s+[^\s"'=<>/]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*)\s*\/?>/g;
14
+ /** `[prop]="expr"` or `[prop]='expr'`; `[attr.x]`, `[class.x]`, `[style.x]` don't match. */
15
+ const PROPERTY_BINDING = /\[([\w$]+)\]\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
16
+ /** `h`, `!h`, `h()` or `!h()`. */
17
+ const HOLDER_READ = /^!?\s*([A-Za-z_$][\w$]*)(?:\s*\(\s*\))?$/;
18
+ const SIGNAL_INPUT = /^(?:input|model)(?:\.required)?$/;
19
+ /**
20
+ * The inputs of child components that parents' templates bind one of
21
+ * `holders` to. An input is `ambiguous` (suggested, never recorded) when its
22
+ * tag maps to several components, or when another binding passes it
23
+ * something else: a reusable component keeps that input after the removal.
24
+ */
25
+ export function childInputs(files, holders) {
26
+ const all = components(files);
27
+ const bySelector = new Map();
28
+ for (const c of all)
29
+ for (const s of c.selectors)
30
+ bySelector.set(s, [...(bySelector.get(s) ?? []), c]);
31
+ const inputs = new Map();
32
+ for (const parent of all) {
33
+ const bound = new Map();
34
+ for (const h of holders) {
35
+ if (h.cls !== parent.cls || h.kind === "local" || h.ternary || h.ambiguous)
36
+ continue;
37
+ bound.set(h.name, (bound.get(h.name) ?? new Set()).add(h.flag));
38
+ }
39
+ const template = templateOf(parent)?.replace(/<!--[\s\S]*?-->/g, "");
40
+ if (!template)
41
+ continue;
42
+ for (const tag of template.matchAll(START_TAG)) {
43
+ const children = bySelector.get(tag[1].toLowerCase());
44
+ if (!children)
45
+ continue;
46
+ for (const binding of tag[2].matchAll(PROPERTY_BINDING)) {
47
+ const read = HOLDER_READ.exec((binding[2] ?? binding[3]).trim());
48
+ const flags = read ? bound.get(read[1]) : undefined;
49
+ for (const child of children) {
50
+ const input = findInput(child, binding[1]);
51
+ if (!input)
52
+ continue;
53
+ const key = `${child.file.file}\0${child.cls.pos}\0${input.name}`;
54
+ const entry = inputs.get(key) ?? { child, input, shared: false, reads: [] };
55
+ inputs.set(key, entry);
56
+ entry.shared ||= children.length > 1;
57
+ entry.reads.push(flags);
58
+ }
59
+ }
60
+ }
61
+ }
62
+ const out = [];
63
+ for (const { child, input, shared, reads } of inputs.values()) {
64
+ const flags = new Set(reads.flatMap((r) => [...(r ?? [])]));
65
+ for (const flag of flags) {
66
+ const ambiguous = shared || reads.some((r) => !r?.has(flag));
67
+ out.push({
68
+ flag,
69
+ name: input.name,
70
+ kind: "field",
71
+ file: child.file.file,
72
+ line: input.line,
73
+ cls: child.cls,
74
+ ...(ambiguous ? { ambiguous: true } : {}),
75
+ });
76
+ }
77
+ }
78
+ return out;
79
+ }
80
+ /** The `@Component` classes of `files`, with their selectors and template. */
81
+ function components(files) {
82
+ const out = [];
83
+ for (const file of files) {
84
+ if (!file.text.includes("@Component"))
85
+ continue;
86
+ for (const statement of file.sf.statements) {
87
+ if (!ts.isClassDeclaration(statement))
88
+ continue;
89
+ for (const decorator of ts.getDecorators(statement) ?? []) {
90
+ const call = decorator.expression;
91
+ if (!ts.isCallExpression(call) || call.expression.getText(file.sf) !== "Component")
92
+ continue;
93
+ const options = call.arguments[0];
94
+ if (!options || !ts.isObjectLiteralExpression(options))
95
+ continue;
96
+ const info = { file, cls: statement, selectors: [] };
97
+ for (const prop of options.properties) {
98
+ if (!ts.isPropertyAssignment(prop) || !ts.isStringLiteralLike(prop.initializer))
99
+ continue;
100
+ const key = prop.name.getText(file.sf);
101
+ const value = prop.initializer.text;
102
+ if (key === "selector") {
103
+ info.selectors = value
104
+ .split(",")
105
+ .map((s) => s.trim())
106
+ .filter((s) => ELEMENT_SELECTOR.test(s))
107
+ .map((s) => s.toLowerCase());
108
+ }
109
+ else if (key === "template") {
110
+ info.template = value;
111
+ }
112
+ else if (key === "templateUrl") {
113
+ info.templateUrl = path.resolve(path.dirname(file.file), value);
114
+ }
115
+ }
116
+ out.push(info);
117
+ }
118
+ }
119
+ }
120
+ return out;
121
+ }
122
+ function templateOf(c) {
123
+ if (c.template !== undefined)
124
+ return c.template;
125
+ if (!c.templateUrl)
126
+ return undefined;
127
+ try {
128
+ return fs.readFileSync(c.templateUrl, "utf8");
129
+ }
130
+ catch {
131
+ return undefined;
132
+ }
133
+ }
134
+ /** The child's member whose binding name (its `alias`, else its own name) is `binding`. */
135
+ function findInput(child, binding) {
136
+ const { sf } = child.file;
137
+ for (const member of child.cls.members) {
138
+ if (!(ts.isPropertyDeclaration(member) || ts.isSetAccessorDeclaration(member)) || !ts.isIdentifier(member.name)) {
139
+ continue;
140
+ }
141
+ const name = member.name.text;
142
+ const line = sf.getLineAndCharacterOfPosition(member.name.getStart(sf)).line + 1;
143
+ const decorator = (ts.getDecorators(member) ?? [])
144
+ .map((d) => d.expression)
145
+ .find((e) => ts.isCallExpression(e) && e.expression.getText(sf) === "Input");
146
+ if (decorator) {
147
+ if ((aliasOf(decorator.arguments, true) ?? name) === binding)
148
+ return { name, line };
149
+ continue;
150
+ }
151
+ const init = ts.isPropertyDeclaration(member) ? member.initializer : undefined;
152
+ if (init &&
153
+ ts.isCallExpression(init) &&
154
+ SIGNAL_INPUT.test(init.expression.getText(sf)) &&
155
+ (aliasOf(init.arguments, false) ?? name) === binding) {
156
+ return { name, line };
157
+ }
158
+ }
159
+ return undefined;
160
+ }
161
+ /**
162
+ * An input's alias: `{ alias: 'x' }` among the arguments, or (for `@Input`
163
+ * only, where a signal input's first argument is its initial value) `'x'`.
164
+ */
165
+ function aliasOf(args, stringAlias) {
166
+ for (const arg of args) {
167
+ if (stringAlias && ts.isStringLiteralLike(arg))
168
+ return arg.text;
169
+ if (!ts.isObjectLiteralExpression(arg))
170
+ continue;
171
+ for (const p of arg.properties) {
172
+ if (ts.isPropertyAssignment(p) &&
173
+ ts.isIdentifier(p.name) &&
174
+ p.name.text === "alias" &&
175
+ ts.isStringLiteralLike(p.initializer)) {
176
+ return p.initializer.text;
177
+ }
178
+ }
179
+ }
180
+ return undefined;
181
+ }
@@ -1,6 +1,8 @@
1
1
  import fs from "node:fs";
2
+ import path from "node:path";
2
3
  import { discoverInText } from "../../core/discover.js";
3
4
  import { BACKEND_GLOBS } from "../../core/scan.js";
5
+ import { relativePath } from "../../core/util.js";
4
6
  import { dotnetFlow } from "./flow.js";
5
7
  import { dotnetUnusedDiagnostics } from "./unused.js";
6
8
  /** Discover candidate flag names (appsettings config keys, [FeatureGate] args, eval-call literals, *Flags constants/enums). */
@@ -26,6 +28,14 @@ export const dotnetAdapter = {
26
28
  flow: (ctx, flags) => dotnetFlow(ctx, flags, ctx.project.methods),
27
29
  // --no-incremental: an up-to-date project skips compiling and prints none of its
28
30
  // warnings, so the baseline's dead-code comparison would have nothing to subtract.
29
- defaultCommands: () => ({ build: "dotnet build --no-incremental", test: "dotnet test" }),
31
+ // TRX under .flagrm/, so verify compares tests by name (known failures) out of the box.
32
+ defaultCommands: (ctx) => {
33
+ const dir = path.join(ctx.config.root, ".flagrm", "test-results", ctx.name);
34
+ return {
35
+ build: "dotnet build --no-incremental",
36
+ test: `dotnet test --logger trx --results-directory "${relativePath(ctx.root, dir) || "."}"`,
37
+ testResults: path.join(dir, "*.trx"),
38
+ };
39
+ },
30
40
  unusedDiagnostics: (_ctx, input) => dotnetUnusedDiagnostics(input),
31
41
  };
package/dist/cli.js CHANGED
@@ -4,18 +4,23 @@ import path from "node:path";
4
4
  import { Command } from "commander";
5
5
  import pc from "picocolors";
6
6
  import { realProbes } from "./adapters/dotnet/setup.js";
7
- import { addNames, assertNoEditsSinceBaseline, baselinePath, baselineSummary, discoverFlag, flagDir, mergeNames, runChecks, validateAgentNames, writeBaseline, } from "./core/baseline.js";
7
+ import { acceptBaseline, addNames, assertNoEditsSinceBaseline, baselinePath, baselineSummary, discoverFlag, mergeNames, parseAccept, validateAgentNames, writeBaseline, } from "./core/baseline.js";
8
+ import { changeFilter } from "./core/change-filter.js";
8
9
  import { loadConfig, parseTools, TOOL_IDS } from "./core/config.js";
10
+ import { projectCommands } from "./core/config-snapshot.js";
9
11
  import { runDoctor } from "./core/doctor.js";
10
- import { gitState } from "./core/git.js";
12
+ import { blobIds, gitState } from "./core/git.js";
11
13
  import { hookRoot, parseHookInput, stopDecision } from "./core/hook.js";
12
14
  import { initProject, installRoot, updateProject } from "./core/install.js";
13
15
  import { buildInventory } from "./core/inventory.js";
14
16
  import { verifyMarkdown } from "./core/markdown.js";
17
+ import { assertOwnNames } from "./core/name-owner.js";
15
18
  import { packageVersion } from "./core/package.js";
16
19
  import { resolveProjects } from "./core/registry.js";
17
20
  import { printBaseline, printDoctor, printInventory, printScope, printSteps, printVerifyReport, } from "./core/report.js";
21
+ import { baselineRuns } from "./core/run-cache.js";
18
22
  import { setupReport, writeScope } from "./core/scope.js";
23
+ import { withScopeFiles } from "./core/scope-files.js";
19
24
  import { askTools, detectTools, savedTools, TOOL_LABELS } from "./core/tools.js";
20
25
  import { CHECK_IDS } from "./core/types.js";
21
26
  import { onPath, relativePath } from "./core/util.js";
@@ -61,41 +66,70 @@ function loadWorkspace(opts) {
61
66
  // --- baseline ------------------------------------------------------------------
62
67
  addCommonOptions(program
63
68
  .command("baseline <flag>")
64
- .description("record the state before removing a flag: the git commit, each project's build and test results, and the " +
69
+ .description("record the state before removing a flag: the git commit, each project's build, test and lint results, and the " +
65
70
  "flag's names for the leftovers check; writes .flagrm/<flag>/baseline.json for verify. With --name on an " +
66
71
  "existing baseline, only adds names"))
67
- .option("--no-checks", "record only the git state, without running build and test commands")
72
+ .option("--no-checks", "record only the git state, without running build, test and lint commands")
73
+ .option("--no-reuse", "run build, tests and lint even when a passing verify on the same tree and commands saved their results")
68
74
  .option("--name <name>", "add a name the flag is read through (repeatable)", collect, [])
69
75
  .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)")
76
+ .option("--file <path>", "check the --name entries only in these files and the files sharing their scope, such as a component's template " +
77
+ "(repeatable; a local, parameter or field; implies --kind wrapper)", collect, [])
71
78
  .option("--force", "start over: replace the baseline, only while the code is unedited since it")
79
+ .option("--accept <what>", "acknowledge without starting over, also mid-removal: config (verify stops warning about changes to the flagrm " +
80
+ "config so far), failures (the baseline's failing tests); comma-separated")
72
81
  .action(async (flag, opts) => {
73
82
  const { root, projects } = loadWorkspace(opts);
83
+ if (opts.accept !== undefined) {
84
+ if (opts.force || opts.name.length || opts.file.length || opts.kind || !opts.checks || !opts.reuse) {
85
+ fail(new Error("--accept only records an acknowledgement; run it without --force, --name, --file, --kind, --no-checks and --no-reuse."));
86
+ }
87
+ let accepted;
88
+ try {
89
+ accepted = acceptBaseline(root, flag, projects, parseAccept(opts.accept));
90
+ }
91
+ catch (err) {
92
+ fail(err);
93
+ }
94
+ if (opts.json)
95
+ console.log(JSON.stringify(baselineSummary(accepted.baseline, accepted.file), null, 2));
96
+ else
97
+ printBaseline(accepted.baseline, accepted.file);
98
+ return;
99
+ }
74
100
  // 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");
101
+ const kind = opts.kind ?? (opts.file.length ? "wrapper" : "alias");
76
102
  if (kind !== "alias" && kind !== "wrapper") {
77
103
  fail(new Error(`--kind must be alias or wrapper, not "${kind}".`));
78
104
  }
79
- if (opts.file && opts.name.length === 0)
105
+ if (!opts.checks && !opts.reuse)
106
+ fail(new Error("--no-reuse has nothing to reuse with --no-checks."));
107
+ if (opts.file.length && opts.name.length === 0)
80
108
  fail(new Error("--file scopes --name entries; pass --name too."));
81
- if (opts.file && kind !== "wrapper")
109
+ if (opts.file.length && kind !== "wrapper")
82
110
  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}.`));
86
- const added = opts.name.map((name) => ({
111
+ const scopes = [
112
+ ...new Set(opts.file.flatMap((file) => {
113
+ const rel = relativePath(root, path.resolve(file));
114
+ if (rel.startsWith(".."))
115
+ fail(new Error(`--file ${file} is outside ${root}.`));
116
+ return withScopeFiles(projects, root, rel);
117
+ })),
118
+ ];
119
+ const added = opts.name.flatMap((name) => (scopes.length ? scopes : [undefined]).map((file) => ({
87
120
  name,
88
121
  kind: kind,
89
122
  source: "agent",
90
- ...(scope ? { file: scope } : {}),
91
- }));
123
+ ...(file ? { file } : {}),
124
+ })));
92
125
  let result;
93
126
  try {
94
127
  validateAgentNames(projects, added);
128
+ await assertOwnNames(root, projects, flag, added);
95
129
  const exists = fs.existsSync(baselinePath(root, flag));
96
130
  if (exists && opts.force) {
97
- const testResults = projects.flatMap(({ ctx }) => (ctx.project.testResults ? [ctx.project.testResults] : []));
98
- assertNoEditsSinceBaseline(root, flag, testResults);
131
+ const testResults = projects.flatMap(({ adapter, ctx }) => projectCommands(adapter, ctx).testResults ?? []);
132
+ assertNoEditsSinceBaseline(root, flag, testResults, changeFilter(projects[0]?.ctx.config));
99
133
  }
100
134
  if (exists && !opts.force) {
101
135
  if (added.length === 0) {
@@ -105,7 +139,9 @@ addCommonOptions(program
105
139
  }
106
140
  else {
107
141
  const git = gitState(root);
108
- const checks = opts.checks ? await runChecks(projects, flagDir(root, flag)) : undefined;
142
+ if (git.dirtyFiles?.length)
143
+ git.dirtyHashes = blobIds(root, git.dirtyFiles);
144
+ const checks = opts.checks ? await baselineRuns(root, flag, projects, opts.reuse) : undefined;
109
145
  const { names, ...flow } = await discoverFlag(projects, flag);
110
146
  result = writeBaseline(root, flag, projects, git, checks, mergeNames(names, added), flow);
111
147
  }
@@ -123,18 +159,23 @@ addCommonOptions(program
123
159
  // --- verify ------------------------------------------------------------------
124
160
  addCommonOptions(program
125
161
  .command("verify <flag>")
126
- .description("gate a flag removal against its baseline (`flagrm baseline <flag>`): build, tests and new unused code; " +
162
+ .description("gate a flag removal against its baseline (`flagrm baseline <flag>`): leftovers, new unused code, build, tests, lint and coverage; " +
127
163
  "writes .flagrm/<flag>/verify.json; exit 0 pass, 1 fail, 2 usage error"))
128
164
  .option("--skip <checks>", `comma-separated checks to skip (${CHECK_IDS.join(", ")})`)
129
165
  .option("--strict", "treat warnings as failures")
130
166
  .option("--md", "print a markdown overview (for the user or a PR) instead of text")
167
+ .option("--no-reuse", "run build, tests and lint even when a passing verify on the same tree and commands saved their results")
131
168
  .action(async (flag, opts) => {
132
169
  if (opts.md && opts.json)
133
170
  fail(new Error("--md and --json can't be combined."));
134
171
  const { root, projects } = loadWorkspace(opts);
135
172
  let result;
136
173
  try {
137
- result = await verifyFlag(root, projects, flag, { skip: parseSkip(opts.skip), strict: opts.strict });
174
+ result = await verifyFlag(root, projects, flag, {
175
+ skip: parseSkip(opts.skip),
176
+ strict: opts.strict,
177
+ reuse: opts.reuse,
178
+ });
138
179
  }
139
180
  catch (err) {
140
181
  fail(err);
@@ -252,10 +293,10 @@ function printInstallRoot(root) {
252
293
  // --- doctor ------------------------------------------------------------------
253
294
  program
254
295
  .command("doctor")
255
- .description("check that this repository is ready for flag removal: config, build/test commands, git, .gitignore, " +
296
+ .description("check that this repository is ready for flag removal: config, build/test/lint commands, git, .gitignore, " +
256
297
  "installed skills and Stop hook, abandoned baselines; exit 0 ok, 1 problems")
257
298
  .option("--config <path>", "path to a config file (default: the nearest flagrm.config.yaml up to the git repository root)")
258
- .option("--run", "also run each build and test command")
299
+ .option("--run", "also run each build, test and lint command")
259
300
  .option("--json", "machine-readable JSON output")
260
301
  .action(async (opts) => {
261
302
  const report = await runDoctor(process.cwd(), { config: opts.config, run: opts.run });