vitest-auto-spy 5.26.0 → 5.28.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/AGENTS.md CHANGED
@@ -4421,7 +4421,9 @@ Full reference: <https://asdalexey.github.io/vitest-auto-spy/utilities/cli>.
4421
4421
  - **The scan does not descend into a nested repository or a git worktree.** A tree carrying
4422
4422
  worktrees under it listed every file twice, so `doctor` reported each import graph in duplicate and
4423
4423
  `codemod --write` would have rewritten specs on another branch. A `.git` entry is a stop, whether
4424
- it is a directory (a nested clone) or a file (a worktree). Past 50 000 files the scan still
4424
+ it is a directory (a nested clone) or a file (a worktree). Directories git ignores are skipped too
4425
+ — every `.gitignore` from the scan root down, `.git/info/exclude` and the per-user
4426
+ `core.excludesFile`, directories only, so an ignored file is still listed. Past 50 000 files the scan still
4425
4427
  truncates, and `doctor` reports that as a `scan-cap-reached` warning (exit 1) rather than a clean
4426
4428
  result; `VITEST_AUTO_SPY_SCAN_CAP` raises the cap.
4427
4429
  - **A path that matches no file is an error, exit 2.** _Nothing left to migrate_ off a path nobody
package/CHANGELOG.md CHANGED
@@ -10,6 +10,64 @@ The latest released version here must always match the one published on
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [5.28.0] - 2026-09-23
14
+
15
+ ### Changed
16
+
17
+ - **The `doctor` and `codemod` repository scan honours git's exclude rules below the root, not only
18
+ the root `.gitignore`.** A nested `.gitignore` is read relative to its own directory and overrides a
19
+ shallower one; below every `.gitignore` sit the root's `.git/info/exclude` (followed through a
20
+ worktree's `.git` file) and the per-user `core.excludesFile` (else `$XDG_CONFIG_HOME/git/ignore`),
21
+ as in git. A monorepo keeping `/generated` in `libs/app/.gitignore`, or a checkout excluding local
22
+ directories through `info/exclude` or a global ignore, had them counted, walked and offered to
23
+ `codemod --write` — so a local run reported more files than CI did. Still no `git` process and no
24
+ dependency, and still directories only: an ignored file is listed. A `.gitignore` above the scan
25
+ root is not read, and `[include]` in git config is not followed.
26
+ - **`doctor --json` and `doctor --markdown` name the flag they meant.** Both are still refused, exit 2,
27
+ like any unknown flag; the error now ends with ``Did you mean `--format json`?``.
28
+
29
+ ### Fixed
30
+
31
+ - **`init --check` and `init --dry-run` no longer seem to disagree over a version-only change.** A
32
+ block whose marker differs only in `v=` is `unchanged` under `--check` (by design since 5.19.0: CI
33
+ must not go red on a release that changed no advice) and `updated` under `--dry-run`, which read as
34
+ a contradiction. Both now say _only the version stamp differs_ in the file's note.
35
+ - **The `tsconfig` glob check exempts a pattern into an ignored directory that does not exist yet.**
36
+ `src/generated/**/*.ts` in `include`, or a `files` entry there, was reported as matching nothing or
37
+ missing whenever `doctor` ran before the code generator — in CI, typically — although
38
+ `src/generated/` is in `.gitignore`: the exemption only knew directories the scan had pruned.
39
+ - **`prefer-create-spy-from-class` at `{ minRunnerFns: 1 }` leaves a one-member literal passed to a
40
+ typed parameter of a helper the same file declares alone.** `createDefaultOptions({ changeOptionsCallback:
41
+ callback })` over `const createDefaultOptions = (overrides?: Partial<InfoboxOptions>) => …` is
42
+ checked against that type already. An untyped parameter, a rest parameter, an inline object type
43
+ of mocks and an imported helper still report — the last is out of reach of a syntax-only rule.
44
+
45
+ ## [5.27.0] - 2026-09-23
46
+
47
+ ### Changed
48
+
49
+ - **The `doctor` and `codemod` repository scan honours the root `.gitignore` for directories.** The
50
+ hardcoded skip list only knew the usual build and store names, so a repository's own generated or
51
+ local output (`src/generated/`, `/reports`, `tmp-*/`) was read as source: counted in the report,
52
+ walked by the import graph and offered to `codemod --write`. The file is parsed directly, with no
53
+ `git` process and no dependency: comments, `!` negations with the last match winning, anchored and
54
+ unanchored patterns, `*`, `?`, `**` and bracket classes. Only the root file and only directories
55
+ are honoured — an ignored file is still listed. A pattern with a `\` escape or a POSIX class is
56
+ dropped, which only means scanning more, and a `!` rule that cannot be read disables the whole
57
+ file rather than prune a directory git keeps. `perf-merge`'s report listing is unaffected.
58
+
59
+ ### Fixed
60
+
61
+ - **The `tsconfig` glob check no longer reports a pattern it could never see matched.** A pattern
62
+ inside a skipped directory below the root (`libs/app/out-tsc/**/*.ts`) or inside a directory the
63
+ root `.gitignore` excludes is exempt, and so is a `files` entry there — both were reported as
64
+ matching nothing or missing, although the scan had simply not descended into them.
65
+ - **The `prefer-create-spy-from-class` reference lists every factory whose arguments are exempt.**
66
+ The built-in doubles and stubs (`createComponentStub`, `createDirectiveHost`, `createWindowDouble`,
67
+ `provideRouterDouble` and the rest) were exempt in the rule but missing from the docs, which also
68
+ still said "four shapes" over a list of eleven. The Russian page gained the four exemptions it was
69
+ missing (`vi.hoisted`, the options bag, `createFixture`, an RxJS observer).
70
+
13
71
  ## [5.26.0] - 2026-09-23
14
72
 
15
73
  ### Changed
@@ -7770,7 +7828,9 @@ by hand there, in more than one place, by more than one person.
7770
7828
  `mockAccessorsProp`.
7771
7829
  - Dual ESM + CJS build with type declarations; 100% test coverage.
7772
7830
 
7773
- [Unreleased]: https://github.com/ASDAlexey/vitest-auto-spy/compare/v5.26.0...HEAD
7831
+ [Unreleased]: https://github.com/ASDAlexey/vitest-auto-spy/compare/v5.28.0...HEAD
7832
+ [5.28.0]: https://github.com/ASDAlexey/vitest-auto-spy/compare/v5.27.0...v5.28.0
7833
+ [5.27.0]: https://github.com/ASDAlexey/vitest-auto-spy/compare/v5.26.0...v5.27.0
7774
7834
  [5.26.0]: https://github.com/ASDAlexey/vitest-auto-spy/compare/v5.25.0...v5.26.0
7775
7835
  [5.25.0]: https://github.com/ASDAlexey/vitest-auto-spy/compare/v5.24.0...v5.25.0
7776
7836
  [5.24.0]: https://github.com/ASDAlexey/vitest-auto-spy/compare/v5.23.0...v5.24.0
package/README.md CHANGED
@@ -139,7 +139,7 @@ includes — that one import is what keeps `returnSubject()` typed as rxjs's own
139
139
  - [Which file your agent reads](#which-file-your-agent-reads)
140
140
  - [Install it in your agent](#install-it-in-your-agent)
141
141
  - [OpenAI Codex](#openai-codex)
142
- - [GLM (z.ai), Kimi K2 and other Claude-compatible models](#glm-zai-kimi-k2-and-other-claude-compatible-models)
142
+ - [GLM (z.ai), Kimi K3 and other Claude-compatible models](#glm-zai-kimi-k3-and-other-claude-compatible-models)
143
143
  - [Gemini CLI](#gemini-cli)
144
144
  - [Claude Code plugin](#claude-code-plugin)
145
145
  - [Availability](#availability)
@@ -372,8 +372,10 @@ that matches no file is the same kind of error, because _Nothing left to migrate
372
372
  read is not a result. A red suite under `perf --gate` lands there too: a failing test is measured
373
373
  until its timeout, and 30 s of timeout looks exactly like 30 s of slow code.
374
374
 
375
- The repository scan behind `doctor` and `codemod` stops at a nested repository or a git worktree — a
376
- `.git` entry, whether it is a directory or a file. A tree carrying worktrees under it used to be
375
+ The repository scan behind `doctor` and `codemod` skips the directories git ignores — every
376
+ `.gitignore` from the root down, `.git/info/exclude` and the per-user `core.excludesFile`, read as
377
+ files with no `git` process — and stops at a nested repository or a git worktree — a `.git` entry,
378
+ whether it is a directory or a file. A tree carrying worktrees under it used to be
377
379
  listed twice, so `doctor` reported every import graph in duplicate and `codemod --write` could have
378
380
  rewritten specs on someone else's branch.
379
381
 
@@ -653,7 +655,7 @@ served — including the ones your teammates use and you do not.
653
655
  | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
654
656
  | **Claude Code** | `CLAUDE.md` — project, `.claude/CLAUDE.md` and `~/.claude/CLAUDE.md`, all concatenated | **No.** Bridge with an `@AGENTS.md` import line, or a symlink |
655
657
  | **OpenAI Codex** — the `codex` CLI, the IDE extension, Codex cloud | `AGENTS.md`, one per directory from the git root down to the cwd ([below](#openai-codex)) | native |
656
- | **GLM (z.ai coding plan)**, **Kimi K2** | whatever their client reads — inside Claude Code that is `CLAUDE.md` ([below](#glm-zai-kimi-k2-and-other-claude-compatible-models)) | through the client |
658
+ | **GLM (z.ai coding plan)**, **Kimi K3** | whatever their client reads — inside Claude Code that is `CLAUDE.md` ([below](#glm-zai-kimi-k3-and-other-claude-compatible-models)) | through the client |
657
659
  | **Cursor** | root `AGENTS.md`; `.cursor/rules/*.mdc` for glob-scoped rules | native — and it applies a root `CLAUDE.md` the same always-on way |
658
660
  | **GitHub Copilot** | root `AGENTS.md`; `.github/copilot-instructions.md` | native, coding agent included |
659
661
  | **OpenCode** | `AGENTS.md`, then `CLAUDE.md`, per directory upwards | native |
@@ -708,7 +710,7 @@ Then, per tool — everything in the right-hand column is optional on top of tho
708
710
  | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
709
711
  | **Claude Code** | `/plugin marketplace add ASDAlexey/vitest-auto-spy`, then `/plugin install vitest-auto-spy@vitest-auto-spy` — [the skill](#claude-code-plugin), no project files touched |
710
712
  | **OpenAI Codex** | nothing more; optionally `~/.codex/config.toml` from [below](#openai-codex) |
711
- | **GLM (z.ai)**, **Kimi K2** | identical to Claude Code — same client, same plugin command |
713
+ | **GLM (z.ai)**, **Kimi K3** | identical to Claude Code — same client, same plugin command |
712
714
  | **Cursor** | `.cursor/rules/vitest-auto-spy.mdc` to load it only for spec files (see below) |
713
715
  | **GitHub Copilot** | `.github/instructions/vitest-auto-spy.instructions.md` (see below) |
714
716
  | **Cline** | `.clinerules/vitest-auto-spy.md` — the same three lines, plus `paths: ["**/*.spec.ts","**/*.test.ts"]` |
@@ -788,14 +790,14 @@ which is exactly why this reference ships inside the tarball rather than only on
788
790
  `node_modules/vitest-auto-spy/AGENTS.md` is on disk the moment the setup script has installed
789
791
  dependencies, so nothing has to be fetched.
790
792
 
791
- ### GLM (z.ai), Kimi K2 and other Claude-compatible models
793
+ ### GLM (z.ai), Kimi K3 and other Claude-compatible models
792
794
 
793
795
  GLM is a **model**, not an agent — the thing that reads files is the client you run it in.
794
796
 
795
797
  The z.ai coding plan runs GLM **inside Claude Code**, by pointing `ANTHROPIC_BASE_URL` (with
796
798
  `ANTHROPIC_AUTH_TOKEN`) at z.ai's Anthropic-compatible endpoint. File discovery is untouched by
797
799
  that: `CLAUDE.md`, `.claude/skills/` and the [plugin](#claude-code-plugin) below behave exactly as
798
- they do on Claude, because it is the same client. Kimi K2 driven through Claude Code is the same
800
+ they do on Claude, because it is the same client. Kimi K3 driven through Claude Code is the same
799
801
  story — and there the skill and the plugin are worth more than a pasted snippet, because they load
800
802
  only when a spec actually mentions the library and cost no context the rest of the time.
801
803
 
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { resolve, join, dirname, relative, posix, isAbsolute, sep } from 'node:path';
3
- import { readFileSync, statSync, mkdirSync, writeFileSync, readdirSync, existsSync, lstatSync, rmSync } from 'node:fs';
2
+ import { resolve, join, dirname, posix, isAbsolute, relative, sep } from 'node:path';
3
+ import { readFileSync, statSync, mkdirSync, writeFileSync, existsSync, lstatSync, rmSync, readdirSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
4
5
  import { createHash } from 'node:crypto';
5
6
  import { fileURLToPath } from 'node:url';
6
7
  import { createRequire } from 'node:module';
@@ -98,6 +99,109 @@ function flagEnabled(args, name) {
98
99
  }
99
100
  return value !== "false";
100
101
  }
102
+
103
+ // src/cli/gitignore.ts
104
+ function parseGitignore(text) {
105
+ const rules = [];
106
+ for (const raw of text.split("\n")) {
107
+ const line = raw.trimEnd();
108
+ if (line === "" || line.startsWith("#")) {
109
+ continue;
110
+ }
111
+ const negated = line.startsWith("!");
112
+ const pattern = toRegExp(negated ? line.slice(1) : line);
113
+ if (pattern !== void 0) {
114
+ rules.push({ negated, pattern });
115
+ } else if (negated) {
116
+ return void 0;
117
+ }
118
+ }
119
+ return rules;
120
+ }
121
+ function isIgnoredBy(sources, path) {
122
+ let ignored = false;
123
+ for (const { base, rules } of sources) {
124
+ if (base === "" || path.startsWith(`${base}/`)) {
125
+ const local = base === "" ? path : path.slice(base.length + 1);
126
+ for (const rule of rules) {
127
+ if (rule.pattern.test(local)) {
128
+ ignored = !rule.negated;
129
+ }
130
+ }
131
+ }
132
+ }
133
+ return ignored;
134
+ }
135
+ function excludesFileSetting(config) {
136
+ let section2 = "";
137
+ let found;
138
+ for (const line of config.split("\n")) {
139
+ const header2 = /^\s*\[\s*([\w.-]+)/.exec(line)?.[1];
140
+ if (header2 !== void 0) {
141
+ section2 = header2.toLowerCase();
142
+ continue;
143
+ }
144
+ const value = /^\s*excludesfile\s*=\s*(.*)$/i.exec(line)?.[1];
145
+ if (section2 === "core" && value !== void 0) {
146
+ found = configValue(value);
147
+ }
148
+ }
149
+ return found;
150
+ }
151
+ function configValue(raw) {
152
+ const quoted = /^"((?:[^"\\]|\\.)*)"/.exec(raw)?.[1];
153
+ return quoted === void 0 ? raw.replace(/\s*[#;].*$/, "").trim() : quoted.replace(/\\(.)/g, "$1");
154
+ }
155
+ function toRegExp(line) {
156
+ const body = line.replace(/\/+$/, "");
157
+ if (body === "" || line.includes("\\")) {
158
+ return void 0;
159
+ }
160
+ const anchored = body.includes("/");
161
+ const segments = (body.startsWith("/") ? body.slice(1) : body).split("/");
162
+ let source = "";
163
+ for (const [index, segment] of segments.entries()) {
164
+ const last = index === segments.length - 1;
165
+ if (segment === "**") {
166
+ source += last ? ".*" : "(?:[^/]+/)*";
167
+ continue;
168
+ }
169
+ const converted = segmentSource(segment);
170
+ if (converted === void 0) {
171
+ return void 0;
172
+ }
173
+ source += last ? converted : `${converted}/`;
174
+ }
175
+ try {
176
+ return new RegExp(`^${anchored ? "" : "(?:.*/)?"}${source}$`);
177
+ } catch {
178
+ return void 0;
179
+ }
180
+ }
181
+ function segmentSource(segment) {
182
+ let source = "";
183
+ let index = 0;
184
+ while (index < segment.length) {
185
+ const char = segment.charAt(index);
186
+ if (char === "[") {
187
+ const negated = /^[!^]/.test(segment.slice(index + 1));
188
+ const start = index + (negated ? 2 : 1);
189
+ const end = segment.indexOf("]", start + 1);
190
+ const members = segment.slice(start, end);
191
+ if (end === -1 || members.includes("[")) {
192
+ return void 0;
193
+ }
194
+ source += `[${negated ? "^" : ""}${members.replace(/[\]^]/g, "\\$&")}]`;
195
+ index = end + 1;
196
+ continue;
197
+ }
198
+ source += char === "*" ? "[^/]*" : char === "?" ? "[^/]" : char.replace(/[$()+.^{|}]/, "\\$&");
199
+ index += 1;
200
+ }
201
+ return source;
202
+ }
203
+
204
+ // src/cli/fs-scan.ts
101
205
  var SKIPPED_DIRECTORIES = /* @__PURE__ */ new Set([
102
206
  ".angular",
103
207
  ".bun",
@@ -174,18 +278,81 @@ function removeFile(path) {
174
278
  rmSync(path, { force: true });
175
279
  }
176
280
  function listRepositoryFiles(root, limit = scanCap()) {
177
- return scanRepository(root, limit).files;
281
+ return scan(root, limit, () => false).files;
178
282
  }
179
- function scanRepository(root, limit = scanCap()) {
180
- const found = [];
181
- const truncated = walk(root, root, found, limit);
182
- return { files: found.sort(), truncated };
283
+ function scanRepository(root, limit = scanCap(), filter = gitignoreFilter(root)) {
284
+ return scan(root, limit, filter);
285
+ }
286
+ function scan(root, limit, filter) {
287
+ const walker = { root, filter, found: [], ignored: [], limit };
288
+ const truncated = walk(walker, root);
289
+ return { files: walker.found.sort(), ignored: walker.ignored.sort(), truncated };
290
+ }
291
+ function gitignoreFilter(root) {
292
+ const gitDirectory = commonGitDirectory(root);
293
+ const excludes = [
294
+ { base: "", rules: rulesOf(readTextFile(resolve(root, globalExcludesFile(gitDirectory)))) },
295
+ { base: "", rules: rulesOf(gitDirectory === void 0 ? void 0 : readTextFile(join(gitDirectory, "info", "exclude"))) }
296
+ ];
297
+ const files = /* @__PURE__ */ new Map();
298
+ const verdicts = /* @__PURE__ */ new Map();
299
+ const sourceIn = (directory) => {
300
+ const cached = files.get(directory) ?? { base: directory, rules: rulesOf(readTextFile(join(root, directory, ".gitignore"))) };
301
+ files.set(directory, cached);
302
+ return cached;
303
+ };
304
+ const isIgnored = (path) => {
305
+ if (path === "" || path === ".." || path.startsWith("../")) {
306
+ return false;
307
+ }
308
+ const cached = verdicts.get(path);
309
+ if (cached !== void 0) {
310
+ return cached;
311
+ }
312
+ const segments = path.split("/");
313
+ const parents = segments.slice(0, -1).map((_, index) => segments.slice(0, index + 1).join("/"));
314
+ const verdict = isIgnored(parents.at(-1) ?? "") || isIgnoredBy([...excludes, sourceIn(""), ...parents.map(sourceIn)], path);
315
+ verdicts.set(path, verdict);
316
+ return verdict;
317
+ };
318
+ return isIgnored;
319
+ }
320
+ function rulesOf(text) {
321
+ return (text === void 0 ? void 0 : parseGitignore(text)) ?? [];
322
+ }
323
+ function commonGitDirectory(root) {
324
+ const dotGit = join(root, ".git");
325
+ const gitDirectory = isDirectory(dotGit) ? dotGit : linkedGitDirectory(root, dotGit);
326
+ if (gitDirectory === void 0) {
327
+ return void 0;
328
+ }
329
+ const common = readTextFile(join(gitDirectory, "commondir"))?.trim();
330
+ return common ? resolve(gitDirectory, common) : gitDirectory;
331
+ }
332
+ function linkedGitDirectory(root, dotGit) {
333
+ const [target] = captures(readTextFile(dotGit) ?? "", /^gitdir:\s*(.+?)\s*$/m);
334
+ return target === void 0 ? void 0 : resolve(root, target);
335
+ }
336
+ function setting(name) {
337
+ const value = process.env[name];
338
+ return value === "" ? void 0 : value;
339
+ }
340
+ function globalExcludesFile(gitDirectory) {
341
+ const home = setting("HOME") ?? homedir();
342
+ const xdg = setting("XDG_CONFIG_HOME") ?? join(home, ".config");
343
+ const override = process.env["GIT_CONFIG_GLOBAL"];
344
+ const configs = [
345
+ ...override === void 0 ? [join(xdg, "git", "config"), join(home, ".gitconfig")] : [override],
346
+ ...gitDirectory === void 0 ? [] : [join(gitDirectory, "config")]
347
+ ];
348
+ const configured = configs.flatMap((config) => excludesFileSetting(readTextFile(config) ?? "") ?? []).at(-1);
349
+ return configured === void 0 ? join(xdg, "git", "ignore") : configured.replace(/^~(?=\/|$)/, home);
183
350
  }
184
351
  function isRepositoryRoot(directory) {
185
352
  return existsSync(join(directory, ".git"));
186
353
  }
187
- function walk(root, directory, found, limit) {
188
- if (found.length >= limit) {
354
+ function walk(walker, directory) {
355
+ if (walker.found.length >= walker.limit) {
189
356
  return true;
190
357
  }
191
358
  let entries;
@@ -195,22 +362,30 @@ function walk(root, directory, found, limit) {
195
362
  return false;
196
363
  }
197
364
  for (const entry of entries) {
198
- if (found.length >= limit) {
365
+ if (walker.found.length >= walker.limit) {
199
366
  return true;
200
367
  }
201
368
  const full = join(directory, entry.name);
202
369
  if (entry.isDirectory()) {
203
- if (!isSkippedDirectory(entry.name) && !isRepositoryRoot(full) && walk(root, full, found, limit)) {
370
+ if (!isSkippedDirectory(entry.name) && !isRepositoryRoot(full) && !isGitignored(walker, full) && walk(walker, full)) {
204
371
  return true;
205
372
  }
206
373
  continue;
207
374
  }
208
375
  if (entry.isFile()) {
209
- found.push(toPosix(relative(root, full)));
376
+ walker.found.push(toPosix(relative(walker.root, full)));
210
377
  }
211
378
  }
212
379
  return false;
213
380
  }
381
+ function isGitignored(walker, directory) {
382
+ const path = toPosix(relative(walker.root, directory));
383
+ if (!walker.filter(path)) {
384
+ return false;
385
+ }
386
+ walker.ignored.push(path);
387
+ return true;
388
+ }
214
389
  function parseJsonc(text) {
215
390
  try {
216
391
  return JSON.parse(stripJsonComments(text));
@@ -640,7 +815,8 @@ function readProfile(cwd) {
640
815
  const scripts = stringMap(packageJson["scripts"]);
641
816
  const runner = detectRunner(dependencies, scripts);
642
817
  const framework = detectFramework(dependencies);
643
- const scan2 = scanRepository(cwd);
818
+ const isIgnoredDirectory = gitignoreFilter(cwd);
819
+ const scan3 = scanRepository(cwd, scanCap(), isIgnoredDirectory);
644
820
  return {
645
821
  cwd,
646
822
  runner,
@@ -651,8 +827,9 @@ function readProfile(cwd) {
651
827
  setupFiles: detectSetupFiles(cwd),
652
828
  dependencies,
653
829
  scripts,
654
- files: scan2.files,
655
- filesTruncated: scan2.truncated
830
+ files: scan3.files,
831
+ filesTruncated: scan3.truncated,
832
+ isIgnoredDirectory
656
833
  };
657
834
  }
658
835
  var PACKAGE_NAME = "vitest-auto-spy";
@@ -1059,7 +1236,7 @@ function textOf(context, range) {
1059
1236
  function group(groups, position) {
1060
1237
  return groups.slice(position, position + 1).join("");
1061
1238
  }
1062
- function scan(masked, pattern) {
1239
+ function scan2(masked, pattern) {
1063
1240
  return [...masked.matchAll(pattern)].map((match) => ({ index: match.index, whole: group(match, 0), groups: [...match] }));
1064
1241
  }
1065
1242
 
@@ -1139,7 +1316,7 @@ function replacement(start, end, text) {
1139
1316
  function replacements(context, pattern, text) {
1140
1317
  return {
1141
1318
  ...EMPTY_OUTPUT,
1142
- edits: scan(context.masked, pattern).map((match) => ({
1319
+ edits: scan2(context.masked, pattern).map((match) => ({
1143
1320
  start: match.index,
1144
1321
  end: match.index + match.whole.length,
1145
1322
  text: text(match)
@@ -1182,7 +1359,7 @@ var jasmineStrategies = {
1182
1359
  function withArgsOutput(context) {
1183
1360
  const closes = spyOnCloses(context);
1184
1361
  return mergeOutputs(
1185
- scan(context.masked, WITH_ARGS).map(
1362
+ scan2(context.masked, WITH_ARGS).map(
1186
1363
  (match) => closes.has(lastCodeBefore(context.masked, match.index)) ? { ...EMPTY_OUTPUT, notes: [withArgsOnSpyOnNote(context, match.index)] } : replacement(match.index, match.index + match.whole.length, ".calledWith(")
1187
1364
  )
1188
1365
  );
@@ -1205,7 +1382,7 @@ function lastCodeBefore(masked, index) {
1205
1382
  }
1206
1383
  function strategyOutputs(context) {
1207
1384
  return mergeOutputs(
1208
- scan(context.masked, AND_STRATEGY).flatMap((match) => {
1385
+ scan2(context.masked, AND_STRATEGY).flatMap((match) => {
1209
1386
  const name = group(match.groups, 1);
1210
1387
  return HELPER_NAMES.has(name) ? [] : [strategyOutput(context, match, name)];
1211
1388
  })
@@ -1279,12 +1456,12 @@ var jasmineSpyOn = {
1279
1456
  residue: /(?:^|[^\w$.])spyOn(?:Property)?\s*\(/,
1280
1457
  run: (context) => ({
1281
1458
  ...EMPTY_OUTPUT,
1282
- edits: scan(context.masked, SPY_ON).flatMap((match) => spyOnEdits(context, match))
1459
+ edits: scan2(context.masked, SPY_ON).flatMap((match) => spyOnEdits(context, match))
1283
1460
  })
1284
1461
  };
1285
1462
  function spyOnCloses(context) {
1286
1463
  return new Set(
1287
- scan(context.masked, SPY_ON).flatMap((match) => {
1464
+ scan2(context.masked, SPY_ON).flatMap((match) => {
1288
1465
  const range = spyOnRange(context, match);
1289
1466
  return range === void 0 ? [] : [range[1]];
1290
1467
  })
@@ -1517,7 +1694,7 @@ var jasmineGlobals = {
1517
1694
  // leftover `jasmine.SpyObj` reported twice would name a transform that never intended to touch it.
1518
1695
  residue: /\bjasmine\s*\.\s*(?!Spy(?:Obj)?\b)/,
1519
1696
  run: (context) => mergeOutputs(
1520
- scan(context.masked, MEMBER).flatMap((match) => {
1697
+ scan2(context.masked, MEMBER).flatMap((match) => {
1521
1698
  const name = group(match.groups, 1);
1522
1699
  return TYPE_TARGETS[name] === void 0 ? [globalOutput(context, match, name)] : [];
1523
1700
  })
@@ -1592,7 +1769,7 @@ var jasmineTypes = {
1592
1769
  summary: "jasmine.Spy \u2192 Mock from vitest, and jasmine.SpyObj<T> \u2192 Spy<T> from this package.",
1593
1770
  residue: /\bjasmine\s*\.\s*Spy(?:Obj)?\b/,
1594
1771
  run: (context) => mergeOutputs(
1595
- scan(context.masked, MEMBER).flatMap((match) => {
1772
+ scan2(context.masked, MEMBER).flatMap((match) => {
1596
1773
  const target = TYPE_TARGETS[group(match.groups, 1)];
1597
1774
  return target === void 0 ? [] : [typeOutput(context, match, target)];
1598
1775
  })
@@ -1630,11 +1807,11 @@ var jasmineMatchers = {
1630
1807
  ([from, to]) => replacements(context, new RegExp(String.raw`\.\s*${from}\s*\(`, "g"), () => `.${to}(`)
1631
1808
  ),
1632
1809
  { ...EMPTY_OUTPUT, edits: failEdits(context) },
1633
- { ...EMPTY_OUTPUT, edits: scan(context.masked, EXPECT).flatMap((match) => withContextEdits(context, match)) }
1810
+ { ...EMPTY_OUTPUT, edits: scan2(context.masked, EXPECT).flatMap((match) => withContextEdits(context, match)) }
1634
1811
  ])
1635
1812
  };
1636
1813
  function failEdits(context) {
1637
- return scan(context.masked, FAIL).filter((match) => isCallPosition(context.masked, match.index + match.whole.length - 1)).map((match) => ({
1814
+ return scan2(context.masked, FAIL).filter((match) => isCallPosition(context.masked, match.index + match.whole.length - 1)).map((match) => ({
1638
1815
  start: match.index,
1639
1816
  end: match.index + match.whole.length,
1640
1817
  text: `${group(match.groups, 1)}expect.fail(`
@@ -1775,7 +1952,7 @@ var jestNamespace = {
1775
1952
  summary: "jest.<member> \u2192 vi.<member>, reporting the members with no vi twin instead of renaming them.",
1776
1953
  residue: /\bjest\s*\./,
1777
1954
  run: (context) => collect(
1778
- scan(context.masked, MEMBER2).flatMap((match) => {
1955
+ scan2(context.masked, MEMBER2).flatMap((match) => {
1779
1956
  const name = group(match.groups, 1);
1780
1957
  return TYPE_NAMES[name] === void 0 ? [memberOutput(context, match, name)] : [];
1781
1958
  })
@@ -1847,7 +2024,7 @@ var jestTypes = {
1847
2024
  run: (context) => {
1848
2025
  const functionFirst = importsJestGlobals(context);
1849
2026
  return collect([
1850
- ...scan(context.masked, MEMBER2).flatMap((match) => {
2027
+ ...scan2(context.masked, MEMBER2).flatMap((match) => {
1851
2028
  const name = group(match.groups, 1);
1852
2029
  const target = TYPE_NAMES[name];
1853
2030
  return target === void 0 ? [] : [typeOutput2(context, match, name, target, functionFirst)];
@@ -1863,7 +2040,7 @@ var GENERIC_CALL = /\bjest\s*\.\s*(fn|spyOn)\s*</g;
1863
2040
  function genericCallNotes(context) {
1864
2041
  return {
1865
2042
  ...EMPTY_OUTPUT,
1866
- notes: scan(context.masked, GENERIC_CALL).map((match) => unreadableTypeArguments(context, match.index, group(match.groups, 1)))
2043
+ notes: scan2(context.masked, GENERIC_CALL).map((match) => unreadableTypeArguments(context, match.index, group(match.groups, 1)))
1867
2044
  };
1868
2045
  }
1869
2046
  var NO_ARGUMENT = /\.\s*(mockImplementation(?:Once)?)\s*\(\s*\)/g;
@@ -1874,7 +2051,7 @@ var mockImplementationArity = {
1874
2051
  residue: /\.\s*mockImplementation(?:Once)?\s*\(\s*\)/,
1875
2052
  run: (context) => ({
1876
2053
  ...EMPTY_OUTPUT,
1877
- edits: scan(context.masked, NO_ARGUMENT).map((match) => ({
2054
+ edits: scan2(context.masked, NO_ARGUMENT).map((match) => ({
1878
2055
  start: match.index,
1879
2056
  end: match.index + match.whole.length,
1880
2057
  text: `.${group(match.groups, 1)}(() => undefined)`
@@ -1890,7 +2067,7 @@ var jasmineAliases = {
1890
2067
  run: (context) => ({
1891
2068
  ...EMPTY_OUTPUT,
1892
2069
  edits: Object.entries(JASMINE_ALIASES).flatMap(
1893
- ([alias, replacement2]) => scan(context.masked, new RegExp(`(^|[^\\w$.])${alias}\\s*\\(`, "g")).filter((match) => isCallPosition(context.masked, match.index + match.whole.length - 1)).map((match) => {
2070
+ ([alias, replacement2]) => scan2(context.masked, new RegExp(`(^|[^\\w$.])${alias}\\s*\\(`, "g")).filter((match) => isCallPosition(context.masked, match.index + match.whole.length - 1)).map((match) => {
1894
2071
  const start = match.index + group(match.groups, 1).length;
1895
2072
  return { start, end: start + alias.length, text: replacement2 };
1896
2073
  })
@@ -1911,7 +2088,7 @@ var jestGlobalsImport = {
1911
2088
  function globalsEdits(context, statement) {
1912
2089
  const specifierAt = context.source.indexOf(JEST_GLOBALS, statement.start);
1913
2090
  const [open, close] = statement.braces ?? [statement.start, statement.start];
1914
- const renames = scan(context.masked.slice(open, close), /\bjest\b/g).map((match) => ({
2091
+ const renames = scan2(context.masked.slice(open, close), /\bjest\b/g).map((match) => ({
1915
2092
  start: open + match.index,
1916
2093
  end: open + match.index + "jest".length,
1917
2094
  text: "vi"
@@ -2112,7 +2289,7 @@ var injectCast = {
2112
2289
  residue: /\bas\s+Spy\s*</,
2113
2290
  run: (context) => {
2114
2291
  const helper = context.entries === void 0 ? void 0 : entryFor(context.entries, "asSpy", context.preferredEntry);
2115
- const casts = scan(context.masked, INJECT).flatMap((match) => {
2292
+ const casts = scan2(context.masked, INJECT).flatMap((match) => {
2116
2293
  const cast = castAt(context, match.index, match.whole.length);
2117
2294
  return cast === void 0 ? [] : [cast];
2118
2295
  });
@@ -2124,7 +2301,7 @@ var injectCast = {
2124
2301
  };
2125
2302
  function withHelper(context, casts, need) {
2126
2303
  const covered = new Set(casts.map((cast) => cast.angle));
2127
- const strays = scan(context.masked, ANY_SPY_CAST).filter((match) => !covered.has(match.index + match.whole.length - 1)).map((match) => castNote(context, match.index));
2304
+ const strays = scan2(context.masked, ANY_SPY_CAST).filter((match) => !covered.has(match.index + match.whole.length - 1)).map((match) => castNote(context, match.index));
2128
2305
  return {
2129
2306
  edits: casts.map((cast) => cast.edit),
2130
2307
  needs: casts.length === 0 ? [] : [need],
@@ -2152,7 +2329,7 @@ var TRANSFORMS = [
2152
2329
  function residueOf(file, text, transforms) {
2153
2330
  const masked = maskComments(text);
2154
2331
  return transforms.flatMap(
2155
- (transform) => scan(masked, new RegExp(transform.residue.source, `${transform.residue.flags.replace("g", "")}g`)).filter((match) => transform.residueIgnores?.(masked, match) !== true).map((match) => residueNote(file, text, match, transform))
2332
+ (transform) => scan2(masked, new RegExp(transform.residue.source, `${transform.residue.flags.replace("g", "")}g`)).filter((match) => transform.residueIgnores?.(masked, match) !== true).map((match) => residueNote(file, text, match, transform))
2156
2333
  );
2157
2334
  }
2158
2335
  function residueNote(file, text, match, transform) {
@@ -2911,7 +3088,7 @@ function checkForeignPragma(graph) {
2911
3088
  }
2912
3089
 
2913
3090
  // src/cli/checks/export-map.generated.ts
2914
- var EXPORT_MAP_VERSION = "5.25.0";
3091
+ var EXPORT_MAP_VERSION = "5.27.0";
2915
3092
  var ENTRY_SPECIFIERS = "vitest-auto-spy vitest-auto-spy/bun vitest-auto-spy/bun-angular vitest-auto-spy/node vitest-auto-spy/rstest vitest-auto-spy/rxjs vitest-auto-spy/console vitest-auto-spy/dom-stubs vitest-auto-spy/diagnostics vitest-auto-spy/jasmine vitest-auto-spy/jasmine-compat vitest-auto-spy/observer-spy vitest-auto-spy/angular vitest-auto-spy/angular/diagnostics vitest-auto-spy/angular/doubles vitest-auto-spy/angular/matchers vitest-auto-spy/angular-http vitest-auto-spy/angular-router vitest-auto-spy/signal-forms vitest-auto-spy/nestjs vitest-auto-spy/react vitest-auto-spy/vue vitest-auto-spy/svelte vitest-auto-spy/setup vitest-auto-spy/zone vitest-auto-spy/eslint-plugin vitest-auto-spy/perf-reporter";
2916
3093
  var EXPORTED_BY = {
2917
3094
  AccessorImplementations: "0 1 2 3 4 12 20 21 22",
@@ -3787,10 +3964,14 @@ function expandInclude(pattern) {
3787
3964
  const base = last === "*" ? trimmed2 : `${trimmed2}${last === "**" ? "/*" : "/**/*"}`;
3788
3965
  return IMPLIED_EXTENSIONS.map((extension) => `${base}${extension}`);
3789
3966
  }
3790
- function isExemptPattern(pattern) {
3791
- const slash = pattern.indexOf("/");
3792
- const root = slash === -1 ? pattern : pattern.slice(0, slash);
3793
- return /\.d\.[cm]?ts$/.test(pattern) || isSkippedDirectory(root);
3967
+ function isExemptPattern(pattern, isIgnored = () => false) {
3968
+ return /\.d\.[cm]?ts$/.test(pattern) || isOutsideScan(pattern, isIgnored);
3969
+ }
3970
+ function isOutsideScan(path, isIgnored) {
3971
+ const segments = path.split("/");
3972
+ const wildcard = segments.findIndex((segment) => /[*?[]/.test(segment));
3973
+ const literal = wildcard === -1 ? segments : segments.slice(0, wildcard);
3974
+ return literal.some(isSkippedDirectory) || literal.some((_, index) => isIgnored(literal.slice(0, index + 1).join("/")));
3794
3975
  }
3795
3976
  function literalTail(pattern) {
3796
3977
  const last = pattern.slice(pattern.lastIndexOf("/") + 1);
@@ -3816,12 +3997,13 @@ function stringList(value) {
3816
3997
  function resolveFromConfig(configPath, entry) {
3817
3998
  return posix.normalize(posix.join(posix.dirname(configPath), entry)).replace(/^\.\//, "");
3818
3999
  }
3819
- function checkInclude(configPath, entry, files) {
4000
+ function checkInclude(configPath, entry, profile) {
4001
+ const files = profile.files;
3820
4002
  if (entry.includes("${") || entry.startsWith("/")) {
3821
4003
  return void 0;
3822
4004
  }
3823
4005
  const resolved = resolveFromConfig(configPath, entry);
3824
- if (isExemptPattern(resolved) || matchesAnyFile(resolved, files)) {
4006
+ if (isExemptPattern(resolved, profile.isIgnoredDirectory) || matchesAnyFile(resolved, files)) {
3825
4007
  return void 0;
3826
4008
  }
3827
4009
  const tail = literalTail(resolved);
@@ -3842,16 +4024,16 @@ function checkInclude(configPath, entry, files) {
3842
4024
  fix: "A pattern that matches nothing type-checks nothing, and `tsc --noEmit` still reports zero errors. Fix the glob or delete the entry."
3843
4025
  };
3844
4026
  }
3845
- function checkOne(cwd, configPath, files) {
3846
- const text = readTextFile(join(cwd, configPath));
4027
+ function checkOne(profile, configPath) {
4028
+ const text = readTextFile(join(profile.cwd, configPath));
3847
4029
  const parsed = text === void 0 ? void 0 : parseJsonc(text);
3848
4030
  if (!isRecord(parsed)) {
3849
4031
  return [];
3850
4032
  }
3851
- const findings = stringList(parsed["include"]).flatMap((entry) => checkInclude(configPath, entry, files) ?? []);
4033
+ const findings = stringList(parsed["include"]).flatMap((entry) => checkInclude(configPath, entry, profile) ?? []);
3852
4034
  for (const entry of stringList(parsed["files"])) {
3853
4035
  const resolved = resolveFromConfig(configPath, entry);
3854
- if (!files.includes(resolved)) {
4036
+ if (!profile.files.includes(resolved) && !isOutsideScan(resolved, profile.isIgnoredDirectory)) {
3855
4037
  findings.push({
3856
4038
  check: "tsconfig-file-missing",
3857
4039
  severity: "error",
@@ -3865,7 +4047,7 @@ function checkOne(cwd, configPath, files) {
3865
4047
  }
3866
4048
  function checkTsconfigGlobs(profile) {
3867
4049
  const configs = profile.files.filter((file) => TSCONFIG_NAME2.test(file));
3868
- return configs.flatMap((config) => checkOne(profile.cwd, config, profile.files));
4050
+ return configs.flatMap((config) => checkOne(profile, config));
3869
4051
  }
3870
4052
 
3871
4053
  // src/cli/checks/unawaited-helper.ts
@@ -4375,6 +4557,10 @@ function planFor(target, content, profile, version, facts) {
4375
4557
  }
4376
4558
  return { target, existing, desired: applyManaged(existing ?? "", managedBlock(profile, version, facts)), note: note2 };
4377
4559
  }
4560
+ function isStampOnly(plan) {
4561
+ return plan.existing !== void 0 && plan.desired !== void 0 && plan.existing !== plan.desired && withoutVersion(plan.existing) === withoutVersion(plan.desired);
4562
+ }
4563
+ var STAMP_ONLY_NOTE = "only the version stamp differs, which `--check` does not count; a plain `init` refreshes it";
4378
4564
  function statusOf(plan, check) {
4379
4565
  if (plan.desired === void 0) {
4380
4566
  return "skipped";
@@ -4385,7 +4571,7 @@ function statusOf(plan, check) {
4385
4571
  if (plan.existing === plan.desired) {
4386
4572
  return "unchanged";
4387
4573
  }
4388
- return check && withoutVersion(plan.existing) === withoutVersion(plan.desired) ? "unchanged" : "updated";
4574
+ return check && isStampOnly(plan) ? "unchanged" : "updated";
4389
4575
  }
4390
4576
  function isSelected(path, only) {
4391
4577
  return only === void 0 || only.some((entry) => {
@@ -4437,15 +4623,16 @@ function uninstallPlan(plan) {
4437
4623
  function applyPlan(cwd, plan, options) {
4438
4624
  const status = options.uninstall ? uninstallStatus(plan) : statusOf(plan, options.check);
4439
4625
  const path = plan.target.path;
4626
+ const note2 = !options.uninstall && isStampOnly(plan) ? `${plan.note} \u2014 ${STAMP_ONLY_NOTE}` : plan.note;
4440
4627
  if (options.check || options.dryRun || plan.desired === void 0 || status === "unchanged") {
4441
- return { path, status, note: plan.note };
4628
+ return { path, status, note: note2 };
4442
4629
  }
4443
4630
  if (plan.desired === "") {
4444
4631
  removeFile(join(cwd, path));
4445
4632
  } else {
4446
4633
  writeTextFile(join(cwd, path), plan.desired);
4447
4634
  }
4448
- return { path, status, note: plan.note };
4635
+ return { path, status, note: note2 };
4449
4636
  }
4450
4637
  function uninstallStatus(plan) {
4451
4638
  if (plan.desired === void 0 || plan.desired === plan.existing) {
@@ -6087,8 +6274,8 @@ function configCode(graph) {
6087
6274
  }
6088
6275
  return configs;
6089
6276
  }
6090
- function configDeclares(graph, setting) {
6091
- return configCode(graph).some((code) => setting.test(code));
6277
+ function configDeclares(graph, setting2) {
6278
+ return configCode(graph).some((code) => setting2.test(code));
6092
6279
  }
6093
6280
  function declaresNoIsolation(graph) {
6094
6281
  return configDeclares(graph, NO_ISOLATION);
@@ -6948,6 +7135,10 @@ function rejectFlags(args, command, io) {
6948
7135
  io.err(
6949
7136
  `\`${command}\` accepts ${[...accepted, ...COMMON_FLAGS].sort((a, b) => a.localeCompare(b)).map((name) => `--${name}`).join(", ")}.`
6950
7137
  );
7138
+ const formats = unknown.filter((name) => accepted.includes("format") && (name === "json" || name === "markdown"));
7139
+ if (formats.length > 0) {
7140
+ io.err(`Did you mean ${formats.map((name) => `\`--format ${name}\``).join(", ")}?`);
7141
+ }
6951
7142
  return true;
6952
7143
  }
6953
7144
  function guardBrokenPipe(stream, quit) {
@@ -1406,6 +1406,30 @@ function declaredTypeOf(context, node) {
1406
1406
  const declared = definition && isVariableDeclarator(definition) ? definition.id : void 0;
1407
1407
  return declared && isIdentifier(declared) ? declared.typeAnnotation?.typeAnnotation : void 0;
1408
1408
  }
1409
+ function localFunction2(context, callee) {
1410
+ const scope = context.sourceCode.getScope(callee);
1411
+ const declared = findBinding(scope, callee.name)?.defs.map((definition) => definition.node).find(isFunctionNode);
1412
+ const bound = declared ?? boundValueOf(scope, callee);
1413
+ return bound && isFunctionNode(bound) ? bound : void 0;
1414
+ }
1415
+ function isAssignmentPattern(node) {
1416
+ return node.type === "AssignmentPattern";
1417
+ }
1418
+ function isAnnotatedParameter(node) {
1419
+ return node.type === "Identifier" || node.type === "ObjectPattern";
1420
+ }
1421
+ function parameterType(parameter) {
1422
+ const target = parameter && isAssignmentPattern(parameter) ? parameter.left : parameter;
1423
+ return target && isAnnotatedParameter(target) ? target.typeAnnotation?.typeAnnotation : void 0;
1424
+ }
1425
+ function declaredParameterTypeOf(context, node) {
1426
+ const call = node.parent;
1427
+ if (!isCallExpression(call) || !isIdentifier(call.callee)) {
1428
+ return void 0;
1429
+ }
1430
+ const helper = localFunction2(context, call.callee);
1431
+ return parameterType(helper?.params[call.arguments.indexOf(node)]);
1432
+ }
1409
1433
  function outermostData(node) {
1410
1434
  let current = node;
1411
1435
  for (; ; ) {
@@ -1426,7 +1450,7 @@ function isMemberStubValue(node) {
1426
1450
  }
1427
1451
  function isCheckedByDeclaration(context, node) {
1428
1452
  const data = outermostData(node);
1429
- const declared = declaredTypeOf(context, data);
1453
+ const declared = declaredTypeOf(context, data) ?? declaredParameterTypeOf(context, data);
1430
1454
  return isMemberStubValue(data) || declared !== void 0 && declared.type !== "TSTypeLiteral";
1431
1455
  }
1432
1456
  var SIGNAL_WRITERS = /* @__PURE__ */ new Set(["set", "update"]);
@@ -19,8 +19,8 @@ function isRecord(value) {
19
19
  var PERF_PROFILE_ENV = "VITEST_AUTO_SPY_PERF_PROFILE";
20
20
  var SAMPLING_MICROSECONDS = 500;
21
21
  function send(session, method, params = {}) {
22
- return new Promise((resolve, reject) => {
23
- session.post(method, params, (error, result) => error === null ? resolve(result) : reject(error));
22
+ return new Promise((resolve2, reject) => {
23
+ session.post(method, params, (error, result) => error === null ? resolve2(result) : reject(error));
24
24
  });
25
25
  }
26
26
  var written = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vitest-auto-spy",
3
- "version": "5.26.0",
3
+ "version": "5.28.0",
4
4
  "description": "Auto-generate fully-typed test spies from a class — across Vitest, Bun, node:test and Rstest, with Angular/NestJS/React/Vue/Svelte recipes. Drop-in replacement for jest-auto-spies and jasmine-auto-spies.",
5
5
  "keywords": [
6
6
  "auto-mock",