flagrm 0.3.0 → 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.
- package/CHANGELOG.md +83 -0
- package/README.md +52 -28
- 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 +53 -29
- package/dist/core/change-filter.js +2 -1
- package/dist/core/config-snapshot.js +9 -3
- package/dist/core/config.js +5 -1
- package/dist/core/detect.js +2 -2
- package/dist/core/doctor.js +26 -6
- package/dist/core/environment.js +25 -3
- package/dist/core/git.js +39 -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 +137 -0
- package/dist/core/scope-files.js +14 -0
- 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 +56 -7
- package/package.json +1 -1
- package/skills/flagrm-remove/SKILL.md +28 -8
- package/skills/flagrm-remove/batch.md +38 -0
- package/skills/flagrm-verify/SKILL.md +12 -6
|
@@ -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
|
@@ -8,8 +8,10 @@ import { configSnapshot, projectCommands } from "./config-snapshot.js";
|
|
|
8
8
|
import { environmentProblem } from "./environment.js";
|
|
9
9
|
import { changedFilesSince } from "./git.js";
|
|
10
10
|
import { buildInventory } from "./inventory.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
|
|
@@ -85,7 +87,8 @@ export function readBaseline(root, flag) {
|
|
|
85
87
|
* (no buffer limit, stdout and stderr interleaved as printed). A command that
|
|
86
88
|
* can't start, is killed or outlives the project's `timeout` fails with the
|
|
87
89
|
* reason at the end of the log. A test run also reads the `testResults`
|
|
88
|
-
* files (absolute path or glob) written during it
|
|
90
|
+
* files (absolute path or glob) written during it, or without any, what its
|
|
91
|
+
* log says (`log-results.ts`).
|
|
89
92
|
*/
|
|
90
93
|
export async function runCheck(ctx, check, command, logFile, claimed, testResults) {
|
|
91
94
|
const started = Date.now();
|
|
@@ -110,12 +113,17 @@ export async function runCheck(ctx, check, command, logFile, claimed, testResult
|
|
|
110
113
|
durationMs: Date.now() - started,
|
|
111
114
|
log: logFile,
|
|
112
115
|
};
|
|
116
|
+
if (reason)
|
|
117
|
+
run.interrupted = reason;
|
|
113
118
|
if (run.exitCode !== 0) {
|
|
114
119
|
const problem = environmentProblem(fs.readFileSync(logFile, "utf8"), ctx.root);
|
|
115
120
|
if (problem)
|
|
116
121
|
run.failure = { kind: "environment", message: problem };
|
|
117
122
|
}
|
|
118
|
-
|
|
123
|
+
let results = check === "test" && testResults ? collectTestResults(testResults, started, claimed) : undefined;
|
|
124
|
+
// No result files: the runner's log still names failing tests and prints counts.
|
|
125
|
+
if (check === "test" && !results)
|
|
126
|
+
results = parseTestLog(fs.readFileSync(logFile, "utf8"));
|
|
119
127
|
if (results)
|
|
120
128
|
run.results = results;
|
|
121
129
|
return run;
|
|
@@ -189,11 +197,12 @@ export function incrementalBuildGap(adapter, build) {
|
|
|
189
197
|
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
198
|
}
|
|
191
199
|
/**
|
|
192
|
-
* Build then
|
|
193
|
-
* `projectCommands`): the configured one, none for `false`, else the
|
|
194
|
-
* adapter's default (`dotnet build --no-incremental`, `npx ng test`,
|
|
200
|
+
* Build, test, then lint every project, each command resolved on its own
|
|
201
|
+
* (see `projectCommands`): the configured one, none for `false`, else the
|
|
202
|
+
* adapter's default (`dotnet build --no-incremental`, `npx ng test`, ...;
|
|
203
|
+
* lint has none).
|
|
195
204
|
*/
|
|
196
|
-
export async function runChecks(projects, logDir, only = ["build", "test"]) {
|
|
205
|
+
export async function runChecks(projects, logDir, only = ["build", "test", "lint"]) {
|
|
197
206
|
const runs = [];
|
|
198
207
|
// Projects whose `testResults` patterns overlap must not count each other's result files.
|
|
199
208
|
const claimed = new Map();
|
|
@@ -248,23 +257,26 @@ export async function discoverFlag(projects, flag) {
|
|
|
248
257
|
const flow = entry?.flow ?? [];
|
|
249
258
|
const names = mergeNames([{ name: flag, kind: "literal", source: "discovery" }], [
|
|
250
259
|
...(entry?.definitions ?? []).map((d) => ({ name: d.name, kind: "alias", source: "discovery" })),
|
|
251
|
-
...flow.filter((n) => n.record).
|
|
260
|
+
...flow.filter((n) => n.record).flatMap((n) => flowNames(projects, n)),
|
|
252
261
|
]);
|
|
253
262
|
const key = (n) => `${n.name}\0${n.file ?? ""}`;
|
|
254
263
|
const recorded = new Set(names.map(key));
|
|
255
264
|
const suggestedNames = flow
|
|
256
|
-
.filter((n) => !n.record && !recorded.has(key(
|
|
265
|
+
.filter((n) => !n.record && !recorded.has(key(flowNames(projects, n)[0])))
|
|
257
266
|
.map(({ record: _record, ...name }) => name)
|
|
258
267
|
.filter((n, i, all) => all.findIndex((m) => m.name === n.name && m.file === n.file) === i);
|
|
259
268
|
return { names, suggestedNames, parameterizedTests: entry?.parameterizedTests ?? [] };
|
|
260
269
|
}
|
|
261
|
-
/** A flow name as a wrapper
|
|
262
|
-
function
|
|
270
|
+
/** A flow name as recorded: an alias everywhere; a wrapper, a local, parameter or field only in its own file. */
|
|
271
|
+
function flowNames(projects, n) {
|
|
272
|
+
if (n.kind === "alias")
|
|
273
|
+
return [{ name: n.name, kind: "alias", source: "discovery" }];
|
|
263
274
|
const name = { name: n.name, kind: "wrapper", source: "discovery" };
|
|
264
275
|
const root = projects[0]?.ctx.config.root;
|
|
265
|
-
if (root && (n.kind === "local" || n.kind === "parameter" || n.kind === "field"))
|
|
266
|
-
|
|
267
|
-
|
|
276
|
+
if (root && (n.kind === "local" || n.kind === "parameter" || n.kind === "field")) {
|
|
277
|
+
return withScopeFiles(projects, root, relativePath(root, n.file)).map((file) => ({ ...name, file }));
|
|
278
|
+
}
|
|
279
|
+
return [name];
|
|
268
280
|
}
|
|
269
281
|
/** The names {@link discoverFlag} records. */
|
|
270
282
|
export async function discoverNames(projects, flag) {
|
|
@@ -295,10 +307,14 @@ export function mergeNames(existing, added) {
|
|
|
295
307
|
* when git can't tell (not a repository, or `sha` isn't in it).
|
|
296
308
|
*/
|
|
297
309
|
export function editsSince(root, sha, testResults = [], ignore = changeFilter()) {
|
|
298
|
-
const results =
|
|
310
|
+
const results = matchingFiles(root, testResults);
|
|
311
|
+
return changedFilesSince(root, sha, ignore)?.filter((rel) => !results.has(rel));
|
|
312
|
+
}
|
|
313
|
+
/** The files (relative to `root`) matching absolute paths or globs, such as `testResults` patterns. */
|
|
314
|
+
export function matchingFiles(root, patterns) {
|
|
315
|
+
return new Set(patterns.flatMap((pattern) => fg
|
|
299
316
|
.sync(path.sep === "\\" ? pattern.replace(/\\/g, "/") : pattern, { absolute: true, dot: true })
|
|
300
317
|
.map((file) => relativePath(root, file))));
|
|
301
|
-
return changedFilesSince(root, sha, ignore)?.filter((rel) => !results.has(rel));
|
|
302
318
|
}
|
|
303
319
|
/**
|
|
304
320
|
* `baseline --force` starts over only before the removal began: the working
|
|
@@ -373,30 +389,38 @@ export function validateAgentNames(projects, names) {
|
|
|
373
389
|
}
|
|
374
390
|
}
|
|
375
391
|
}
|
|
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) {
|
|
392
|
+
/** A log's non-blank lines, trimmed, without ANSI colors or the `$ <command>` header; undefined when unreadable. */
|
|
393
|
+
export function logLines(log) {
|
|
383
394
|
if (!log)
|
|
384
|
-
return
|
|
395
|
+
return undefined;
|
|
385
396
|
let text;
|
|
386
397
|
try {
|
|
387
398
|
text = fs.readFileSync(log, "utf8");
|
|
388
399
|
}
|
|
389
400
|
catch {
|
|
390
|
-
return
|
|
401
|
+
return undefined;
|
|
391
402
|
}
|
|
392
|
-
|
|
403
|
+
return (text
|
|
393
404
|
.split(/\r?\n/)
|
|
394
405
|
// biome-ignore lint/suspicious/noControlCharactersInRegex: strips ANSI colors (ngc colors even when piped)
|
|
395
406
|
.map((line) => line.replace(/\u001b\[[0-9;]*m/g, "").trim())
|
|
396
|
-
.filter((line) => line && !line.startsWith("$ "));
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
407
|
+
.filter((line) => line && !line.startsWith("$ ")));
|
|
408
|
+
}
|
|
409
|
+
/** A log line that reports an error: not a compiler warning, nor a summary such as `0 Error(s)`. */
|
|
410
|
+
export function looksLikeError(line) {
|
|
411
|
+
return /\berror\b|\bfailed\b|✗|×/i.test(line) && !/:\s*warning\b/i.test(line) && !/^\d+ Error\(s\)$/i.test(line);
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Up to `max` distinct lines of a log that look like errors, so the reader sees
|
|
415
|
+
* why a command failed without opening the log. Compiler warnings and summary
|
|
416
|
+
* lines such as `0 Error(s)` are skipped: in a large build they bury the one
|
|
417
|
+
* real error. When no line looks like an error, the last `max` lines instead.
|
|
418
|
+
*/
|
|
419
|
+
export function errorExcerpt(log, max = 10) {
|
|
420
|
+
const all = logLines(log);
|
|
421
|
+
if (!all)
|
|
422
|
+
return [];
|
|
423
|
+
const lines = all.filter(looksLikeError);
|
|
400
424
|
// No line says error (e.g. "A compatible .NET SDK was not found"): the end of the log usually does.
|
|
401
425
|
if (!lines.length)
|
|
402
426
|
return all.slice(-max);
|
|
@@ -45,8 +45,9 @@ export function changeFilter(config) {
|
|
|
45
45
|
* Matches `exclude` the way scanning's fast-glob `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];
|
|
@@ -161,7 +166,8 @@ function withoutCommands(text) {
|
|
|
161
166
|
const strip = (project) => {
|
|
162
167
|
if (!project || typeof project !== "object")
|
|
163
168
|
return project;
|
|
164
|
-
|
|
169
|
+
// `covers` changes no command's result, and `coverage` always reads the current config.
|
|
170
|
+
const { build: _b, test: _t, lint: _l, testResults: _r, timeout: _o, covers: _c, ...rest } = project;
|
|
165
171
|
return rest;
|
|
166
172
|
};
|
|
167
173
|
if (parsed && typeof parsed === "object") {
|
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
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import fg from "fast-glob";
|
|
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. */
|
|
@@ -35,8 +36,7 @@ export function detectProjects(cwd) {
|
|
|
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) {
|