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.
Files changed (38) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +56 -29
  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/cli.js +41 -21
  9. package/dist/core/baseline.js +60 -31
  10. package/dist/core/change-filter.js +3 -2
  11. package/dist/core/config-snapshot.js +26 -3
  12. package/dist/core/config.js +5 -1
  13. package/dist/core/detect.js +4 -4
  14. package/dist/core/doctor.js +26 -6
  15. package/dist/core/environment.js +25 -3
  16. package/dist/core/git.js +50 -3
  17. package/dist/core/hook.js +13 -2
  18. package/dist/core/install.js +26 -11
  19. package/dist/core/inventory.js +3 -1
  20. package/dist/core/markdown.js +4 -0
  21. package/dist/core/name-owner.js +179 -0
  22. package/dist/core/report.js +7 -0
  23. package/dist/core/run-cache.js +147 -0
  24. package/dist/core/scan.js +30 -3
  25. package/dist/core/scope-files.js +14 -0
  26. package/dist/core/scope.js +3 -3
  27. package/dist/core/test-runner.js +136 -0
  28. package/dist/core/types.js +9 -4
  29. package/dist/core/verify/commands.js +160 -22
  30. package/dist/core/verify/coverage.js +92 -0
  31. package/dist/core/verify/index.js +32 -5
  32. package/dist/core/verify/log-results.js +127 -0
  33. package/dist/core/verify/removed-tests.js +90 -7
  34. package/dist/core/verify/test-results.js +58 -10
  35. package/package.json +4 -4
  36. package/skills/flagrm-remove/SKILL.md +28 -8
  37. package/skills/flagrm-remove/batch.md +38 -0
  38. package/skills/flagrm-verify/SKILL.md +12 -6
@@ -1,4 +1,5 @@
1
1
  import pc from "picocolors";
2
+ import { reusedNote } from "./run-cache.js";
2
3
  import { plural, relativePath } from "./util.js";
3
4
  /** A path relative to the working directory, with `/` separators on every platform. */
4
5
  function rel(file) {
@@ -70,6 +71,9 @@ export function printBaseline(baseline, file) {
70
71
  if (c.failure)
71
72
  console.log(` ${pc.yellow("setup problem:")} ${c.failure.message}`);
72
73
  }
74
+ const note = reusedNote(checks ?? [], baseline.createdAt);
75
+ if (note)
76
+ console.log(` ${pc.dim(note)}`);
73
77
  if (baseline.names.length) {
74
78
  const names = baseline.names.map((n) => n.kind === "literal" ? n.name : `${n.name} (${n.kind}${n.file ? ` in ${n.file}` : ""})`);
75
79
  console.log(` names: ${pc.dim(names.join(", "))}`);
@@ -122,6 +126,9 @@ export function printVerifyReport(report, file) {
122
126
  }
123
127
  if (report.strict && warned)
124
128
  console.log(pc.dim("\n --strict: warnings count as failures."));
129
+ const note = reusedNote(report.runs, report.createdAt);
130
+ if (note)
131
+ console.log(pc.dim(`\n ${note}`));
125
132
  console.log(pc.dim(`\nResult written to ${rel(file)}.`));
126
133
  }
127
134
  const STEP_STYLE = {
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Build and test runs a passing full verify saves, so the next baseline or
3
+ * verify on the same tree with the same commands reuses them instead of
4
+ * running them again: in a sequential removal, the next flag's baseline
5
+ * starts on the commit the previous flag's verify passed on.
6
+ */
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { globSync } from "tinyglobby";
10
+ import { flagDir, matchingFiles, runChecks } from "./baseline.js";
11
+ import { changeFilter } from "./change-filter.js";
12
+ import { configSnapshot, projectCommands } from "./config-snapshot.js";
13
+ import { treeFingerprint } from "./git.js";
14
+ import { packageVersion } from "./package.js";
15
+ import { JSON_SCHEMA_VERSION, RUN_CHECK_ID } from "./types.js";
16
+ import { readJson } from "./util.js";
17
+ /** `.flagrm/<flag>/verify/runs.json`. */
18
+ export function runsPath(root, flag) {
19
+ return path.join(flagDir(root, flag), "verify", "runs.json");
20
+ }
21
+ /**
22
+ * The tree the commands will run on: its fingerprint before they run, without
23
+ * flagrm's setup files and the test result files the runs themselves rewrite.
24
+ * Commands are compared on their own, so leaving out the config file is safe.
25
+ * Unlike the Stop hook's fingerprint, `exclude`d paths count: a build or test
26
+ * may read them (a lockfile, generated sources), so a change there must rerun.
27
+ */
28
+ export function runsKey(root, projects) {
29
+ const ignore = changeFilter();
30
+ const patterns = projects.flatMap(({ adapter, ctx }) => projectCommands(adapter, ctx).testResults ?? []);
31
+ const results = matchingFiles(root, patterns);
32
+ return treeFingerprint(root, (rel) => ignore(rel) || results.has(rel));
33
+ }
34
+ /** The (project, check) pairs a run of `only` would run: those with a command. */
35
+ export function plannedRuns(projects, only) {
36
+ return projects.flatMap(({ adapter, ctx }) => {
37
+ const commands = projectCommands(adapter, ctx);
38
+ return only.filter((check) => commands[check]).map((check) => ({ project: ctx.name, check }));
39
+ });
40
+ }
41
+ /**
42
+ * Save a passing full verify's runs; never a set with a setup problem, which would come back on every rerun,
43
+ * nor runs that were themselves reused: the set they came from keeps its own flag and time.
44
+ * The logs are copied next to `runs.json`, so a later verify overwriting its own logs leaves the saved set whole.
45
+ */
46
+ export function saveRuns(root, flag, projects, key, runs, createdAt = new Date().toISOString()) {
47
+ if (runs.some((r) => r.failure || r.reused))
48
+ return;
49
+ const file = runsPath(root, flag);
50
+ const logDir = path.join(path.dirname(file), "saved");
51
+ fs.mkdirSync(logDir, { recursive: true });
52
+ const kept = runs.map(({ reused: _reused, ...run }) => {
53
+ if (!run.log)
54
+ return run;
55
+ const log = path.join(logDir, `${run.project}.${run.check}.log`);
56
+ fs.copyFileSync(run.log, log);
57
+ return { ...run, log };
58
+ });
59
+ const saved = {
60
+ schemaVersion: JSON_SCHEMA_VERSION,
61
+ flagrmVersion: packageVersion(),
62
+ flag,
63
+ createdAt,
64
+ key,
65
+ commands: configSnapshot(projects).projects,
66
+ runs: kept,
67
+ };
68
+ fs.writeFileSync(file, `${JSON.stringify(saved, null, 2)}\n`, "utf8");
69
+ }
70
+ /** Forget every saved set, of any flag, for this tree: a fresh run on it failed, so they may hide a flaky test or a changed machine. */
71
+ export function dropRuns(root, key) {
72
+ for (const file of globSync(".flagrm/*/verify/runs.json", {
73
+ expandDirectories: false,
74
+ cwd: root,
75
+ absolute: true,
76
+ dot: true,
77
+ })) {
78
+ if (readJson(file)?.key === key)
79
+ fs.rmSync(file, { force: true });
80
+ }
81
+ }
82
+ /** The newest saved set, of any flag, for this tree and these commands that has every `needed` run and its log. */
83
+ export function findRuns(root, projects, key, needed) {
84
+ if (needed.length === 0)
85
+ return undefined;
86
+ const commands = JSON.stringify(configSnapshot(projects).projects);
87
+ const version = packageVersion();
88
+ let best;
89
+ for (const file of globSync(".flagrm/*/verify/runs.json", {
90
+ expandDirectories: false,
91
+ cwd: root,
92
+ absolute: true,
93
+ dot: true,
94
+ })) {
95
+ const saved = readJson(file);
96
+ if (!saved || !Array.isArray(saved.runs) || typeof saved.createdAt !== "string")
97
+ continue;
98
+ if (saved.key !== key || saved.schemaVersion !== JSON_SCHEMA_VERSION)
99
+ continue;
100
+ if (saved.flagrmVersion !== version || JSON.stringify(saved.commands) !== commands)
101
+ continue;
102
+ const runs = needed.map((n) => saved.runs.find((r) => r.project === n.project && r.check === n.check));
103
+ if (runs.some((r) => !r || (r.log !== undefined && !fs.existsSync(r.log))))
104
+ continue;
105
+ if (!best || saved.createdAt > best.saved.createdAt)
106
+ best = { saved, runs: runs };
107
+ }
108
+ return best;
109
+ }
110
+ /** The found runs as this flag's own: logs copied into `logDir` under their usual names, marked `reused`. */
111
+ export function reuseRuns(found, logDir) {
112
+ fs.mkdirSync(logDir, { recursive: true });
113
+ const reused = { flag: found.saved.flag, createdAt: found.saved.createdAt };
114
+ return found.runs.map((run) => {
115
+ if (!run.log)
116
+ return { ...run, reused };
117
+ const log = path.join(logDir, `${run.project}.${run.check}.log`);
118
+ if (path.resolve(run.log) !== path.resolve(log))
119
+ fs.copyFileSync(run.log, log);
120
+ return { ...run, log, reused };
121
+ });
122
+ }
123
+ /** A baseline's build, test and lint runs: reused from a passing verify on the same tree and commands, else run. */
124
+ export async function baselineRuns(root, flag, projects, reuse = true) {
125
+ const logDir = flagDir(root, flag);
126
+ const key = reuse ? runsKey(root, projects) : undefined;
127
+ const found = key ? findRuns(root, projects, key, plannedRuns(projects, ["build", "test", "lint"])) : undefined;
128
+ return found ? reuseRuns(found, logDir) : runChecks(projects, logDir);
129
+ }
130
+ /** One line saying which runs were reused and from where, or undefined when none were. `at`: when they were reused. */
131
+ export function reusedNote(runs, at) {
132
+ const reused = runs.find((r) => r.reused)?.reused;
133
+ if (!reused)
134
+ return undefined;
135
+ const checks = [...new Set(runs.filter((r) => r.reused).map((r) => RUN_CHECK_ID[r.check]))];
136
+ const what = checks.length > 1 ? `${checks.slice(0, -1).join(", ")} and ${checks.at(-1)}` : checks[0];
137
+ return `${what} reused from ${reused.flag}'s verify (same tree and commands, ${age(Date.parse(at) - Date.parse(reused.createdAt))} ago; --no-reuse to run them)`;
138
+ }
139
+ function age(ms) {
140
+ const min = Math.round(ms / 60_000);
141
+ if (min < 1)
142
+ return "under a minute";
143
+ if (min < 60)
144
+ return `${min} min`;
145
+ const hours = Math.round(min / 60);
146
+ return hours < 48 ? `${hours} h` : `${Math.round(hours / 24)} days`;
147
+ }
package/dist/core/scan.js CHANGED
@@ -1,5 +1,6 @@
1
+ import fs from "node:fs";
1
2
  import path from "node:path";
2
- import fg from "fast-glob";
3
+ import { globSync, isDynamicPattern } from "tinyglobby";
3
4
  export const BACKEND_GLOBS = [
4
5
  "**/*.cs",
5
6
  "**/*.cshtml",
@@ -19,11 +20,37 @@ export const IGNORE_GLOBS = [
19
20
  "**/.angular/**",
20
21
  "**/.git/**",
21
22
  ];
22
- /** Absolute paths with the platform's separators (fast-glob returns `/` on Windows too), so they match `path.join`. */
23
+ /**
24
+ * Files matching an absolute path or glob, such as a `testResults` pattern,
25
+ * as absolute paths with `/`. The leading directories that exist are taken
26
+ * literally and become the glob's `cwd`, so a repository path containing
27
+ * glob characters (`/work/p[x]/TestResults/*.trx`) still matches.
28
+ */
29
+ export function globAbsolute(pattern) {
30
+ const segments = (path.sep === "\\" ? pattern.replace(/\\/g, "/") : pattern).split("/");
31
+ let base = segments[0] === "" ? "/" : `${segments[0]}/`;
32
+ let i = 1;
33
+ for (; i < segments.length - 1; i++) {
34
+ const dir = path.posix.join(base, segments[i]);
35
+ if (isDynamicPattern(segments[i]) && !isDirectory(dir))
36
+ break;
37
+ base = dir;
38
+ }
39
+ return globSync(segments.slice(i).join("/"), { expandDirectories: false, cwd: base, absolute: true, dot: true });
40
+ }
41
+ function isDirectory(dir) {
42
+ try {
43
+ return fs.statSync(dir).isDirectory();
44
+ }
45
+ catch {
46
+ return false;
47
+ }
48
+ }
49
+ /** Absolute paths with the platform's separators (tinyglobby returns `/` on Windows too), so they match `path.join`. */
23
50
  export function listProjectFiles(root, globs, overrides) {
24
51
  const patterns = [...globs, ...(overrides?.include ?? [])];
25
52
  const ignore = [...IGNORE_GLOBS, ...(overrides?.exclude ?? [])];
26
- return fg.sync(patterns, { cwd: root, ignore, absolute: true, dot: false }).map((file) => path.normalize(file));
53
+ return globSync(patterns, { expandDirectories: false, cwd: root, ignore, absolute: true, dot: false }).map((file) => path.normalize(file));
27
54
  }
28
55
  export function escapeRegExp(s) {
29
56
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -0,0 +1,14 @@
1
+ /**
2
+ * A file-scoped name (a local, parameter or field) is also used in the files
3
+ * that share its file's scope, such as an Angular component's template. The
4
+ * adapter whose project contains the file says which (`Adapter.scopeFiles`).
5
+ */
6
+ import path from "node:path";
7
+ import { relativePath } from "./util.js";
8
+ /** `rel` (relative to the config `root`) followed by the files sharing its scope, relative too, each once. */
9
+ export function withScopeFiles(projects, root, rel) {
10
+ const file = path.resolve(root, rel);
11
+ const owner = projects.find(({ ctx }) => !path.relative(ctx.root, file).startsWith(".."));
12
+ const more = owner?.adapter.scopeFiles?.(owner.ctx, file) ?? [];
13
+ return [...new Set([rel, ...more.map((f) => relativePath(root, f))])];
14
+ }
@@ -8,7 +8,7 @@
8
8
  import { spawnSync } from "node:child_process";
9
9
  import fs from "node:fs";
10
10
  import path from "node:path";
11
- import fg from "fast-glob";
11
+ import { globSync } from "tinyglobby";
12
12
  import YAML from "yaml";
13
13
  import { sdkProblem, solutionSetup } from "../adapters/dotnet/setup.js";
14
14
  import { projectCommands } from "./config-snapshot.js";
@@ -81,8 +81,8 @@ function solutionFor(ctx, commands) {
81
81
  return { note: `can't read ${arg}` };
82
82
  }
83
83
  }
84
- const found = fg.sync("*.{sln,slnx}", { cwd: ctx.root, absolute: true });
85
- // fast-glob returns forward slashes, also on Windows.
84
+ const found = globSync("*.{sln,slnx}", { expandDirectories: false, cwd: ctx.root, absolute: true });
85
+ // tinyglobby returns forward slashes, also on Windows.
86
86
  if (found.length === 1)
87
87
  return { solution: path.normalize(found[0]) };
88
88
  return { note: found.length ? "several solutions: name one in build/test" : "no solution file found" };
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Which JavaScript test runner a project uses, read from `angular.json`'s
3
+ * test builder, config files and `package.json`, so the Angular adapter can
4
+ * default to a test command that writes machine-readable results, and
5
+ * doctor can say how to get them. Reads only; anything unreadable is `unknown`.
6
+ */
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { globSync } from "tinyglobby";
10
+ import { readJson, relativePath } from "./util.js";
11
+ /** The test target of the first `angular.json` project that has one. */
12
+ function angularTestTarget(root) {
13
+ const json = readJson(path.join(root, "angular.json"));
14
+ for (const project of Object.values(json?.projects ?? {})) {
15
+ const test = (project?.architect ?? project?.targets)?.test;
16
+ if (test?.builder)
17
+ return test;
18
+ }
19
+ return undefined;
20
+ }
21
+ function dependencies(root) {
22
+ const pkg = readJson(path.join(root, "package.json"));
23
+ return new Set([...Object.keys(pkg?.dependencies ?? {}), ...Object.keys(pkg?.devDependencies ?? {})]);
24
+ }
25
+ const hasFile = (root, pattern) => globSync(pattern, { expandDirectories: false, cwd: root, deep: 1 }).length > 0;
26
+ function karma(root, options) {
27
+ const conf = path.resolve(root, typeof options.karmaConfig === "string" ? options.karmaConfig : "karma.conf.js");
28
+ let text;
29
+ try {
30
+ text = fs.readFileSync(conf, "utf8");
31
+ }
32
+ catch {
33
+ return { kind: "karma" };
34
+ }
35
+ const outputDir = /junitReporter\s*:\s*\{[^}]*?\boutputDir\s*:\s*(['"`])([^'"`]+)\1/.exec(text)?.[2];
36
+ // Without a `plugins` key Karma loads every installed karma-* package.
37
+ const loaded = text.includes("karma-junit-reporter") ||
38
+ (!/\bplugins\s*:/.test(text) && dependencies(root).has("karma-junit-reporter"));
39
+ return loaded && outputDir
40
+ ? { kind: "karma", junit: { outputDir: path.resolve(path.dirname(conf), outputDir) } }
41
+ : { kind: "karma" };
42
+ }
43
+ export function detectTestRunner(root) {
44
+ const target = angularTestTarget(root);
45
+ const builder = target?.builder ?? "";
46
+ const options = target?.options ?? {};
47
+ if (builder === "@angular/build:unit-test")
48
+ return options.runner === "karma" ? karma(root, options) : { kind: "vitest-ng" };
49
+ if (builder.endsWith(":karma"))
50
+ return karma(root, options);
51
+ if (builder.startsWith("@angular-builders/jest"))
52
+ return { kind: "jest", viaNg: true };
53
+ if (hasFile(root, "jest.config.{js,ts,mjs,cjs,json}"))
54
+ return { kind: "jest", viaNg: false };
55
+ if (hasFile(root, "vitest.config.{js,ts,mjs,cjs,mts}"))
56
+ return { kind: "vitest" };
57
+ const deps = dependencies(root);
58
+ if (deps.has("jest"))
59
+ return { kind: "jest", viaNg: false };
60
+ if (deps.has("vitest"))
61
+ return { kind: "vitest" };
62
+ if (hasFile(root, "karma.conf.{js,cjs,ts}"))
63
+ return karma(root, {});
64
+ return { kind: "unknown" };
65
+ }
66
+ /** The flags that make each runner write results to `file` (quoted, relative to the project). */
67
+ function resultFlags(runner, file) {
68
+ switch (runner.kind) {
69
+ case "vitest-ng":
70
+ // The unit-test builder's --output-file applies to the first reporter only.
71
+ return `--reporters=junit --output-file="${file("junit.xml")}"`;
72
+ case "jest":
73
+ return runner.viaNg ? undefined : `--json --outputFile="${file("jest.json")}"`;
74
+ case "vitest":
75
+ return `--reporter=default --reporter=junit --outputFile.junit="${file("junit.xml")}"`;
76
+ default:
77
+ return undefined;
78
+ }
79
+ }
80
+ const RESULT_FILE = {
81
+ "vitest-ng": "junit.xml",
82
+ jest: "jest.json",
83
+ vitest: "junit.xml",
84
+ };
85
+ /**
86
+ * A test command for the runner that writes results into `resultsDir`, and
87
+ * where to read them. Undefined when the runner can't without a setup change
88
+ * (Karma without a JUnit reporter, Jest through an Angular builder, unknown).
89
+ */
90
+ export function runnerTestCommands(runner, projectRoot, resultsDir) {
91
+ if (runner.kind === "karma") {
92
+ return runner.junit
93
+ ? {
94
+ test: "npx ng test --watch=false --reporters=progress --reporters=junit",
95
+ testResults: path.join(runner.junit.outputDir, "**", "*.xml"),
96
+ }
97
+ : undefined;
98
+ }
99
+ const flags = resultFlags(runner, (name) => relativePath(projectRoot, path.join(resultsDir, name)));
100
+ const name = RESULT_FILE[runner.kind];
101
+ if (!flags || !name)
102
+ return undefined;
103
+ const base = runner.kind === "vitest-ng"
104
+ ? "npx ng test --watch=false"
105
+ : runner.kind === "jest"
106
+ ? "npx jest --ci"
107
+ : "npx vitest run";
108
+ return { test: `${base} ${flags}`, testResults: path.join(resultsDir, name) };
109
+ }
110
+ /**
111
+ * How to get test results for a project whose test command has none: one
112
+ * line for doctor and init. `dirFromProject` is a results directory relative
113
+ * to the project (for the command), `dirFromConfig` the same relative to the
114
+ * config file (for `testResults`). `adapterDefault`: the adapter's default
115
+ * test command writes results for this runner (Angular), so removing `test` is a fix too.
116
+ * `configRoot`: the config file's directory, for a results directory karma.conf.js names.
117
+ */
118
+ export function testResultsHint(runner, dirFromProject, dirFromConfig, adapterDefault, configRoot) {
119
+ const flags = resultFlags(runner, (name) => `${dirFromProject}/${name}`);
120
+ const name = RESULT_FILE[runner.kind];
121
+ const fallback = adapterDefault ? " (or remove `test` to use the adapter default)" : "";
122
+ if (flags && name) {
123
+ return `add \`${flags}\` to the test command and set testResults: ${dirFromConfig}/${name}${fallback}`;
124
+ }
125
+ if (runner.kind === "karma" && runner.junit) {
126
+ const results = `./${relativePath(configRoot, runner.junit.outputDir)}/**/*.xml`;
127
+ return `add \`--reporters=progress --reporters=junit\` to the test command and set testResults: ${results}${fallback}`;
128
+ }
129
+ if (runner.kind === "karma") {
130
+ return "npm i -D karma-junit-reporter, add require('karma-junit-reporter') to plugins and junitReporter: { outputDir: 'test-results' } to karma.conf.js; the angular adapter's default test command then writes JUnit";
131
+ }
132
+ if (runner.kind === "jest") {
133
+ return `add \`--json --outputFile="${dirFromProject}/jest.json"\` to the test command if @angular-builders/jest passes them on, and set testResults: ${dirFromConfig}/jest.json`;
134
+ }
135
+ return "make the test runner write JUnit XML, TRX or Jest JSON and point testResults at the file";
136
+ }
@@ -18,11 +18,16 @@
18
18
  * counts and at most 20 short failed test names (`failedTests`), no
19
19
  * `config`. `baseline.json` itself is unchanged, so version 6 files are
20
20
  * still read.
21
+ * - 8: `lint` runs (`check: "lint"`) and the `lint` and `coverage` checks;
22
+ * `baseline.json` gains the config's `lint` commands and `git.dirtyHashes`.
23
+ * Both are optional, so version 6 and 7 files are still read.
21
24
  */
22
- export const JSON_SCHEMA_VERSION = 7;
23
- /** `baseline.json` versions this flagrm reads: 7 only added optional fields to 6. */
24
- export const READABLE_BASELINE_VERSIONS = [6, 7];
25
+ export const JSON_SCHEMA_VERSION = 8;
26
+ /** `baseline.json` versions this flagrm reads: 7 and 8 only added optional fields to 6. */
27
+ export const READABLE_BASELINE_VERSIONS = [6, 7, 8];
25
28
  // ---------------------------------------------------------------------------
26
29
  // verify
27
30
  // ---------------------------------------------------------------------------
28
- export const CHECK_IDS = ["leftovers", "dead-code", "build", "tests"];
31
+ export const CHECK_IDS = ["leftovers", "dead-code", "build", "tests", "lint", "coverage"];
32
+ /** The check that judges each kind of command run. */
33
+ export const RUN_CHECK_ID = { build: "build", test: "tests", lint: "lint" };