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
|
@@ -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
|
-
|
|
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
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -4,20 +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 { acceptBaseline, addNames, assertNoEditsSinceBaseline, baselinePath, baselineSummary, discoverFlag,
|
|
7
|
+
import { acceptBaseline, addNames, assertNoEditsSinceBaseline, baselinePath, baselineSummary, discoverFlag, mergeNames, parseAccept, validateAgentNames, writeBaseline, } from "./core/baseline.js";
|
|
8
8
|
import { changeFilter } from "./core/change-filter.js";
|
|
9
9
|
import { loadConfig, parseTools, TOOL_IDS } from "./core/config.js";
|
|
10
10
|
import { projectCommands } from "./core/config-snapshot.js";
|
|
11
11
|
import { runDoctor } from "./core/doctor.js";
|
|
12
|
-
import { gitState } from "./core/git.js";
|
|
12
|
+
import { blobIds, gitState } from "./core/git.js";
|
|
13
13
|
import { hookRoot, parseHookInput, stopDecision } from "./core/hook.js";
|
|
14
14
|
import { initProject, installRoot, updateProject } from "./core/install.js";
|
|
15
15
|
import { buildInventory } from "./core/inventory.js";
|
|
16
16
|
import { verifyMarkdown } from "./core/markdown.js";
|
|
17
|
+
import { assertOwnNames } from "./core/name-owner.js";
|
|
17
18
|
import { packageVersion } from "./core/package.js";
|
|
18
19
|
import { resolveProjects } from "./core/registry.js";
|
|
19
20
|
import { printBaseline, printDoctor, printInventory, printScope, printSteps, printVerifyReport, } from "./core/report.js";
|
|
21
|
+
import { baselineRuns } from "./core/run-cache.js";
|
|
20
22
|
import { setupReport, writeScope } from "./core/scope.js";
|
|
23
|
+
import { withScopeFiles } from "./core/scope-files.js";
|
|
21
24
|
import { askTools, detectTools, savedTools, TOOL_LABELS } from "./core/tools.js";
|
|
22
25
|
import { CHECK_IDS } from "./core/types.js";
|
|
23
26
|
import { onPath, relativePath } from "./core/util.js";
|
|
@@ -63,21 +66,23 @@ function loadWorkspace(opts) {
|
|
|
63
66
|
// --- baseline ------------------------------------------------------------------
|
|
64
67
|
addCommonOptions(program
|
|
65
68
|
.command("baseline <flag>")
|
|
66
|
-
.description("record the state before removing a flag: the git commit, each project's build and
|
|
69
|
+
.description("record the state before removing a flag: the git commit, each project's build, test and lint results, and the " +
|
|
67
70
|
"flag's names for the leftovers check; writes .flagrm/<flag>/baseline.json for verify. With --name on an " +
|
|
68
71
|
"existing baseline, only adds names"))
|
|
69
|
-
.option("--no-checks", "record only the git state, without running build and
|
|
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")
|
|
70
74
|
.option("--name <name>", "add a name the flag is read through (repeatable)", collect, [])
|
|
71
75
|
.option("--kind <kind>", "how --name entries are matched: alias (qualified constant) or wrapper (identifier, calls included); default alias, wrapper with --file")
|
|
72
|
-
.option("--file <path>", "check the --name entries only in
|
|
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, [])
|
|
73
78
|
.option("--force", "start over: replace the baseline, only while the code is unedited since it")
|
|
74
79
|
.option("--accept <what>", "acknowledge without starting over, also mid-removal: config (verify stops warning about changes to the flagrm " +
|
|
75
80
|
"config so far), failures (the baseline's failing tests); comma-separated")
|
|
76
81
|
.action(async (flag, opts) => {
|
|
77
82
|
const { root, projects } = loadWorkspace(opts);
|
|
78
83
|
if (opts.accept !== undefined) {
|
|
79
|
-
if (opts.force || opts.name.length || opts.file || opts.kind || !opts.checks) {
|
|
80
|
-
fail(new Error("--accept only records an acknowledgement; run it without --force, --name, --file, --kind and --no-
|
|
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."));
|
|
81
86
|
}
|
|
82
87
|
let accepted;
|
|
83
88
|
try {
|
|
@@ -93,26 +98,34 @@ addCommonOptions(program
|
|
|
93
98
|
return;
|
|
94
99
|
}
|
|
95
100
|
// Only wrappers are matched per file: a name scoped with --file is a local, parameter or field.
|
|
96
|
-
const kind = opts.kind ?? (opts.file ? "wrapper" : "alias");
|
|
101
|
+
const kind = opts.kind ?? (opts.file.length ? "wrapper" : "alias");
|
|
97
102
|
if (kind !== "alias" && kind !== "wrapper") {
|
|
98
103
|
fail(new Error(`--kind must be alias or wrapper, not "${kind}".`));
|
|
99
104
|
}
|
|
100
|
-
if (opts.
|
|
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)
|
|
101
108
|
fail(new Error("--file scopes --name entries; pass --name too."));
|
|
102
|
-
if (opts.file && kind !== "wrapper")
|
|
109
|
+
if (opts.file.length && kind !== "wrapper")
|
|
103
110
|
fail(new Error("--file scopes wrapper names; drop --kind alias."));
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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) => ({
|
|
108
120
|
name,
|
|
109
121
|
kind: kind,
|
|
110
122
|
source: "agent",
|
|
111
|
-
...(
|
|
112
|
-
}));
|
|
123
|
+
...(file ? { file } : {}),
|
|
124
|
+
})));
|
|
113
125
|
let result;
|
|
114
126
|
try {
|
|
115
127
|
validateAgentNames(projects, added);
|
|
128
|
+
await assertOwnNames(root, projects, flag, added);
|
|
116
129
|
const exists = fs.existsSync(baselinePath(root, flag));
|
|
117
130
|
if (exists && opts.force) {
|
|
118
131
|
const testResults = projects.flatMap(({ adapter, ctx }) => projectCommands(adapter, ctx).testResults ?? []);
|
|
@@ -126,7 +139,9 @@ addCommonOptions(program
|
|
|
126
139
|
}
|
|
127
140
|
else {
|
|
128
141
|
const git = gitState(root);
|
|
129
|
-
|
|
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;
|
|
130
145
|
const { names, ...flow } = await discoverFlag(projects, flag);
|
|
131
146
|
result = writeBaseline(root, flag, projects, git, checks, mergeNames(names, added), flow);
|
|
132
147
|
}
|
|
@@ -144,18 +159,23 @@ addCommonOptions(program
|
|
|
144
159
|
// --- verify ------------------------------------------------------------------
|
|
145
160
|
addCommonOptions(program
|
|
146
161
|
.command("verify <flag>")
|
|
147
|
-
.description("gate a flag removal against its baseline (`flagrm baseline <flag>`): build, tests and
|
|
162
|
+
.description("gate a flag removal against its baseline (`flagrm baseline <flag>`): leftovers, new unused code, build, tests, lint and coverage; " +
|
|
148
163
|
"writes .flagrm/<flag>/verify.json; exit 0 pass, 1 fail, 2 usage error"))
|
|
149
164
|
.option("--skip <checks>", `comma-separated checks to skip (${CHECK_IDS.join(", ")})`)
|
|
150
165
|
.option("--strict", "treat warnings as failures")
|
|
151
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")
|
|
152
168
|
.action(async (flag, opts) => {
|
|
153
169
|
if (opts.md && opts.json)
|
|
154
170
|
fail(new Error("--md and --json can't be combined."));
|
|
155
171
|
const { root, projects } = loadWorkspace(opts);
|
|
156
172
|
let result;
|
|
157
173
|
try {
|
|
158
|
-
result = await verifyFlag(root, projects, flag, {
|
|
174
|
+
result = await verifyFlag(root, projects, flag, {
|
|
175
|
+
skip: parseSkip(opts.skip),
|
|
176
|
+
strict: opts.strict,
|
|
177
|
+
reuse: opts.reuse,
|
|
178
|
+
});
|
|
159
179
|
}
|
|
160
180
|
catch (err) {
|
|
161
181
|
fail(err);
|
|
@@ -273,10 +293,10 @@ function printInstallRoot(root) {
|
|
|
273
293
|
// --- doctor ------------------------------------------------------------------
|
|
274
294
|
program
|
|
275
295
|
.command("doctor")
|
|
276
|
-
.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, " +
|
|
277
297
|
"installed skills and Stop hook, abandoned baselines; exit 0 ok, 1 problems")
|
|
278
298
|
.option("--config <path>", "path to a config file (default: the nearest flagrm.config.yaml up to the git repository root)")
|
|
279
|
-
.option("--run", "also run each build and
|
|
299
|
+
.option("--run", "also run each build, test and lint command")
|
|
280
300
|
.option("--json", "machine-readable JSON output")
|
|
281
301
|
.action(async (opts) => {
|
|
282
302
|
const report = await runDoctor(process.cwd(), { config: opts.config, run: opts.run });
|
package/dist/core/baseline.js
CHANGED
|
@@ -2,14 +2,16 @@ 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
5
|
import { changeFilter } from "./change-filter.js";
|
|
7
6
|
import { configSnapshot, projectCommands } from "./config-snapshot.js";
|
|
8
7
|
import { environmentProblem } from "./environment.js";
|
|
9
8
|
import { changedFilesSince } from "./git.js";
|
|
10
9
|
import { buildInventory } from "./inventory.js";
|
|
10
|
+
import { globAbsolute } from "./scan.js";
|
|
11
|
+
import { withScopeFiles } from "./scope-files.js";
|
|
11
12
|
import { JSON_SCHEMA_VERSION, READABLE_BASELINE_VERSIONS, } from "./types.js";
|
|
12
13
|
import { readJson, relativePath } from "./util.js";
|
|
14
|
+
import { parseTestLog } from "./verify/log-results.js";
|
|
13
15
|
import { collectTestResults, shortTestNames } from "./verify/test-results.js";
|
|
14
16
|
/**
|
|
15
17
|
* `.flagrm/<flag>/` under the config root. A name that isn't safe as a directory
|
|
@@ -78,14 +80,22 @@ export function readBaseline(root, flag) {
|
|
|
78
80
|
throw new UsageError(`${file} was written by schema version ${baseline.schemaVersion}; this flagrm expects ${JSON_SCHEMA_VERSION}. ` +
|
|
79
81
|
`Re-run \`flagrm baseline ${flag} --force\` on the code as it was before the removal.`);
|
|
80
82
|
}
|
|
83
|
+
const sha = baseline.git?.sha;
|
|
84
|
+
if (sha != null && !COMMIT_ID.test(sha)) {
|
|
85
|
+
throw new UsageError(`${file} has git.sha "${sha}", which is not a git commit id. ` +
|
|
86
|
+
`Re-run \`flagrm baseline ${flag} --force\` on the code as it was before the removal.`);
|
|
87
|
+
}
|
|
81
88
|
return { baseline, file };
|
|
82
89
|
}
|
|
90
|
+
/** A full SHA-1 or SHA-256 commit id, as `git rev-parse HEAD` prints it. */
|
|
91
|
+
const COMMIT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
83
92
|
/**
|
|
84
93
|
* Run one shell command inside a project, streaming its output to `logFile`
|
|
85
94
|
* (no buffer limit, stdout and stderr interleaved as printed). A command that
|
|
86
95
|
* can't start, is killed or outlives the project's `timeout` fails with the
|
|
87
96
|
* reason at the end of the log. A test run also reads the `testResults`
|
|
88
|
-
* files (absolute path or glob) written during it
|
|
97
|
+
* files (absolute path or glob) written during it, or without any, what its
|
|
98
|
+
* log says (`log-results.ts`).
|
|
89
99
|
*/
|
|
90
100
|
export async function runCheck(ctx, check, command, logFile, claimed, testResults) {
|
|
91
101
|
const started = Date.now();
|
|
@@ -110,12 +120,17 @@ export async function runCheck(ctx, check, command, logFile, claimed, testResult
|
|
|
110
120
|
durationMs: Date.now() - started,
|
|
111
121
|
log: logFile,
|
|
112
122
|
};
|
|
123
|
+
if (reason)
|
|
124
|
+
run.interrupted = reason;
|
|
113
125
|
if (run.exitCode !== 0) {
|
|
114
126
|
const problem = environmentProblem(fs.readFileSync(logFile, "utf8"), ctx.root);
|
|
115
127
|
if (problem)
|
|
116
128
|
run.failure = { kind: "environment", message: problem };
|
|
117
129
|
}
|
|
118
|
-
|
|
130
|
+
let results = check === "test" && testResults ? collectTestResults(testResults, started, claimed) : undefined;
|
|
131
|
+
// No result files: the runner's log still names failing tests and prints counts.
|
|
132
|
+
if (check === "test" && !results)
|
|
133
|
+
results = parseTestLog(fs.readFileSync(logFile, "utf8"));
|
|
119
134
|
if (results)
|
|
120
135
|
run.results = results;
|
|
121
136
|
return run;
|
|
@@ -189,11 +204,12 @@ export function incrementalBuildGap(adapter, build) {
|
|
|
189
204
|
return "`dotnet build` without `--no-incremental` skips up-to-date projects and prints none of their warnings, so the dead-code check may report existing unused code as new: add `--no-incremental` to `build`";
|
|
190
205
|
}
|
|
191
206
|
/**
|
|
192
|
-
* Build then
|
|
193
|
-
* `projectCommands`): the configured one, none for `false`, else the
|
|
194
|
-
* adapter's default (`dotnet build --no-incremental`, `npx ng test`,
|
|
207
|
+
* Build, test, then lint every project, each command resolved on its own
|
|
208
|
+
* (see `projectCommands`): the configured one, none for `false`, else the
|
|
209
|
+
* adapter's default (`dotnet build --no-incremental`, `npx ng test`, ...;
|
|
210
|
+
* lint has none).
|
|
195
211
|
*/
|
|
196
|
-
export async function runChecks(projects, logDir, only = ["build", "test"]) {
|
|
212
|
+
export async function runChecks(projects, logDir, only = ["build", "test", "lint"]) {
|
|
197
213
|
const runs = [];
|
|
198
214
|
// Projects whose `testResults` patterns overlap must not count each other's result files.
|
|
199
215
|
const claimed = new Map();
|
|
@@ -248,23 +264,26 @@ export async function discoverFlag(projects, flag) {
|
|
|
248
264
|
const flow = entry?.flow ?? [];
|
|
249
265
|
const names = mergeNames([{ name: flag, kind: "literal", source: "discovery" }], [
|
|
250
266
|
...(entry?.definitions ?? []).map((d) => ({ name: d.name, kind: "alias", source: "discovery" })),
|
|
251
|
-
...flow.filter((n) => n.record).
|
|
267
|
+
...flow.filter((n) => n.record).flatMap((n) => flowNames(projects, n)),
|
|
252
268
|
]);
|
|
253
269
|
const key = (n) => `${n.name}\0${n.file ?? ""}`;
|
|
254
270
|
const recorded = new Set(names.map(key));
|
|
255
271
|
const suggestedNames = flow
|
|
256
|
-
.filter((n) => !n.record && !recorded.has(key(
|
|
272
|
+
.filter((n) => !n.record && !recorded.has(key(flowNames(projects, n)[0])))
|
|
257
273
|
.map(({ record: _record, ...name }) => name)
|
|
258
274
|
.filter((n, i, all) => all.findIndex((m) => m.name === n.name && m.file === n.file) === i);
|
|
259
275
|
return { names, suggestedNames, parameterizedTests: entry?.parameterizedTests ?? [] };
|
|
260
276
|
}
|
|
261
|
-
/** A flow name as a wrapper
|
|
262
|
-
function
|
|
277
|
+
/** A flow name as recorded: an alias everywhere; a wrapper, a local, parameter or field only in its own file. */
|
|
278
|
+
function flowNames(projects, n) {
|
|
279
|
+
if (n.kind === "alias")
|
|
280
|
+
return [{ name: n.name, kind: "alias", source: "discovery" }];
|
|
263
281
|
const name = { name: n.name, kind: "wrapper", source: "discovery" };
|
|
264
282
|
const root = projects[0]?.ctx.config.root;
|
|
265
|
-
if (root && (n.kind === "local" || n.kind === "parameter" || n.kind === "field"))
|
|
266
|
-
|
|
267
|
-
|
|
283
|
+
if (root && (n.kind === "local" || n.kind === "parameter" || n.kind === "field")) {
|
|
284
|
+
return withScopeFiles(projects, root, relativePath(root, n.file)).map((file) => ({ ...name, file }));
|
|
285
|
+
}
|
|
286
|
+
return [name];
|
|
268
287
|
}
|
|
269
288
|
/** The names {@link discoverFlag} records. */
|
|
270
289
|
export async function discoverNames(projects, flag) {
|
|
@@ -295,11 +314,13 @@ export function mergeNames(existing, added) {
|
|
|
295
314
|
* when git can't tell (not a repository, or `sha` isn't in it).
|
|
296
315
|
*/
|
|
297
316
|
export function editsSince(root, sha, testResults = [], ignore = changeFilter()) {
|
|
298
|
-
const results =
|
|
299
|
-
.sync(path.sep === "\\" ? pattern.replace(/\\/g, "/") : pattern, { absolute: true, dot: true })
|
|
300
|
-
.map((file) => relativePath(root, file))));
|
|
317
|
+
const results = matchingFiles(root, testResults);
|
|
301
318
|
return changedFilesSince(root, sha, ignore)?.filter((rel) => !results.has(rel));
|
|
302
319
|
}
|
|
320
|
+
/** The files (relative to `root`) matching absolute paths or globs, such as `testResults` patterns. */
|
|
321
|
+
export function matchingFiles(root, patterns) {
|
|
322
|
+
return new Set(patterns.flatMap((pattern) => globAbsolute(pattern).map((file) => relativePath(root, file))));
|
|
323
|
+
}
|
|
303
324
|
/**
|
|
304
325
|
* `baseline --force` starts over only before the removal began: the working
|
|
305
326
|
* tree must still match the baseline commit. Otherwise the new baseline would
|
|
@@ -373,30 +394,38 @@ export function validateAgentNames(projects, names) {
|
|
|
373
394
|
}
|
|
374
395
|
}
|
|
375
396
|
}
|
|
376
|
-
/**
|
|
377
|
-
|
|
378
|
-
* why a command failed without opening the log. Compiler warnings and summary
|
|
379
|
-
* lines such as `0 Error(s)` are skipped: in a large build they bury the one
|
|
380
|
-
* real error. When no line looks like an error, the last `max` lines instead.
|
|
381
|
-
*/
|
|
382
|
-
export function errorExcerpt(log, max = 10) {
|
|
397
|
+
/** A log's non-blank lines, trimmed, without ANSI colors or the `$ <command>` header; undefined when unreadable. */
|
|
398
|
+
export function logLines(log) {
|
|
383
399
|
if (!log)
|
|
384
|
-
return
|
|
400
|
+
return undefined;
|
|
385
401
|
let text;
|
|
386
402
|
try {
|
|
387
403
|
text = fs.readFileSync(log, "utf8");
|
|
388
404
|
}
|
|
389
405
|
catch {
|
|
390
|
-
return
|
|
406
|
+
return undefined;
|
|
391
407
|
}
|
|
392
|
-
|
|
408
|
+
return (text
|
|
393
409
|
.split(/\r?\n/)
|
|
394
410
|
// biome-ignore lint/suspicious/noControlCharactersInRegex: strips ANSI colors (ngc colors even when piped)
|
|
395
411
|
.map((line) => line.replace(/\u001b\[[0-9;]*m/g, "").trim())
|
|
396
|
-
.filter((line) => line && !line.startsWith("$ "));
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
412
|
+
.filter((line) => line && !line.startsWith("$ ")));
|
|
413
|
+
}
|
|
414
|
+
/** A log line that reports an error: not a compiler warning, nor a summary such as `0 Error(s)`. */
|
|
415
|
+
export function looksLikeError(line) {
|
|
416
|
+
return /\berror\b|\bfailed\b|✗|×/i.test(line) && !/:\s*warning\b/i.test(line) && !/^\d+ Error\(s\)$/i.test(line);
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Up to `max` distinct lines of a log that look like errors, so the reader sees
|
|
420
|
+
* why a command failed without opening the log. Compiler warnings and summary
|
|
421
|
+
* lines such as `0 Error(s)` are skipped: in a large build they bury the one
|
|
422
|
+
* real error. When no line looks like an error, the last `max` lines instead.
|
|
423
|
+
*/
|
|
424
|
+
export function errorExcerpt(log, max = 10) {
|
|
425
|
+
const all = logLines(log);
|
|
426
|
+
if (!all)
|
|
427
|
+
return [];
|
|
428
|
+
const lines = all.filter(looksLikeError);
|
|
400
429
|
// No line says error (e.g. "A compatible .NET SDK was not found"): the end of the log usually does.
|
|
401
430
|
if (!lines.length)
|
|
402
431
|
return all.slice(-max);
|
|
@@ -42,11 +42,12 @@ export function changeFilter(config) {
|
|
|
42
42
|
};
|
|
43
43
|
}
|
|
44
44
|
/**
|
|
45
|
-
* Matches `exclude` the way scanning's
|
|
45
|
+
* Matches `exclude` the way scanning's tinyglobby `ignore` does: a pattern
|
|
46
46
|
* that matches a directory (`graphify-out`, `graphify-out/`) excludes
|
|
47
47
|
* everything under it, so test the path and each of its parent directories.
|
|
48
|
+
* `covers` reads the same way.
|
|
48
49
|
*/
|
|
49
|
-
function excludeMatcher(patterns) {
|
|
50
|
+
export function excludeMatcher(patterns) {
|
|
50
51
|
const isMatch = picomatch(patterns.map((p) => p.replace(/\/+$/, "")), { dot: true });
|
|
51
52
|
return (rel) => {
|
|
52
53
|
for (let end = rel.length; end > 0; end = rel.lastIndexOf("/", end - 1)) {
|
|
@@ -9,8 +9,8 @@ import path from "node:path";
|
|
|
9
9
|
import YAML from "yaml";
|
|
10
10
|
import { relativePath } from "./util.js";
|
|
11
11
|
/**
|
|
12
|
-
* A project's build/test commands, each resolved on its own: the
|
|
13
|
-
* one, none for `false`, else the adapter's default. `testResults` defaults
|
|
12
|
+
* A project's build/test/lint commands, each resolved on its own: the
|
|
13
|
+
* configured one, none for `false`, else the adapter's default (lint has none). `testResults` defaults
|
|
14
14
|
* only along with the default test command, whose output location it names.
|
|
15
15
|
*/
|
|
16
16
|
export function projectCommands(adapter, ctx) {
|
|
@@ -27,6 +27,8 @@ export function projectCommands(adapter, ctx) {
|
|
|
27
27
|
out.defaults.push(key);
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
+
if (typeof ctx.project.lint === "string")
|
|
31
|
+
out.lint = ctx.project.lint;
|
|
30
32
|
if (ctx.project.testResults)
|
|
31
33
|
out.testResults = ctx.project.testResults;
|
|
32
34
|
else if (out.defaults.includes("test") && fallback.testResults) {
|
|
@@ -58,6 +60,8 @@ export function configSnapshot(projects) {
|
|
|
58
60
|
entry.build = command(commands.build);
|
|
59
61
|
if (commands.test)
|
|
60
62
|
entry.test = command(commands.test);
|
|
63
|
+
if (commands.lint)
|
|
64
|
+
entry.lint = command(commands.lint);
|
|
61
65
|
if (commands.testResults)
|
|
62
66
|
entry.testResults = relativePath(root, commands.testResults);
|
|
63
67
|
snapshot.projects.push(entry);
|
|
@@ -101,6 +105,7 @@ export function configChanges(before, now) {
|
|
|
101
105
|
for (const [kind, check] of [
|
|
102
106
|
["build", "build"],
|
|
103
107
|
["test", "tests"],
|
|
108
|
+
["lint", "lint"],
|
|
104
109
|
]) {
|
|
105
110
|
const a = was?.[kind];
|
|
106
111
|
const b = is?.[kind];
|
|
@@ -137,6 +142,23 @@ export function configChanges(before, now) {
|
|
|
137
142
|
}
|
|
138
143
|
return changes;
|
|
139
144
|
}
|
|
145
|
+
/**
|
|
146
|
+
* Whether the config file under `root` changed since `before` in what is
|
|
147
|
+
* scanned or ignored (`exclude`, globs, paths), not just in its commands.
|
|
148
|
+
* False when `before` recorded no config file.
|
|
149
|
+
*/
|
|
150
|
+
export function scanConfigChanged(before, root) {
|
|
151
|
+
if (!before?.file)
|
|
152
|
+
return false;
|
|
153
|
+
let text;
|
|
154
|
+
try {
|
|
155
|
+
text = fs.readFileSync(path.join(root, before.file.path), "utf8");
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
return withoutCommands(before.file.text) !== withoutCommands(text);
|
|
161
|
+
}
|
|
140
162
|
const quote = (text) => (text === undefined ? "(none)" : `\`${text}\``);
|
|
141
163
|
function changedFiles(a, b) {
|
|
142
164
|
const hashes = new Map(a.map((f) => [f.path, f.sha256]));
|
|
@@ -161,7 +183,8 @@ function withoutCommands(text) {
|
|
|
161
183
|
const strip = (project) => {
|
|
162
184
|
if (!project || typeof project !== "object")
|
|
163
185
|
return project;
|
|
164
|
-
|
|
186
|
+
// `covers` changes no command's result, and `coverage` always reads the current config.
|
|
187
|
+
const { build: _b, test: _t, lint: _l, testResults: _r, timeout: _o, covers: _c, ...rest } = project;
|
|
165
188
|
return rest;
|
|
166
189
|
};
|
|
167
190
|
if (parsed && typeof parsed === "object") {
|