fallow_types/workspace.rs
1//! Workspace and source-discovery diagnostic data types.
2//!
3//! The serializable `WorkspaceDiagnostic` / `WorkspaceDiagnosticKind` pair
4//! lives here, upstream of both `fallow-config` (which owns the registry and
5//! emission logic and re-exports these types for back-compat) and
6//! `fallow-output` (which embeds `Vec<WorkspaceDiagnostic>` in its JSON
7//! envelopes). Keeping the data types in `fallow-types` lets the output layer
8//! reference the real, schema-bearing type instead of an opaque
9//! `serde_json::Value` newtype, so `workspace_diagnostics[]` keeps its typed
10//! `kind`/`path`/`message` shape (and the typed `kind` oneOf) in
11//! `docs/output-schema.json` without coupling output contracts to config
12//! loading.
13
14use std::path::{Path, PathBuf};
15
16use rustc_hash::FxHashSet;
17#[cfg(feature = "schema")]
18use schemars::JsonSchema;
19use serde::{Deserialize, Serialize};
20
21use crate::serde_path;
22
23/// Why a workspace-discovery candidate was rejected, or why a sibling
24/// directory looked workspace-like but was not declared.
25///
26/// Wire-format names are kebab-case so JSON consumers (CI integrations, MCP
27/// agents, LSP clients) get a stable, language-neutral identifier.
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
29#[cfg_attr(feature = "schema", derive(JsonSchema))]
30#[serde(tag = "kind", rename_all = "kebab-case")]
31pub enum WorkspaceDiagnosticKind {
32 /// A directory contains `package.json` but is not declared as a workspace
33 /// in `package.json` `workspaces`, `pnpm-workspace.yaml`, or
34 /// `tsconfig.json` `references`. Surfaced by
35 /// `find_undeclared_workspaces`.
36 UndeclaredWorkspace,
37 /// A declared workspace's `package.json` failed to parse. The directory is
38 /// dropped from discovery, but analysis still proceeds (degraded).
39 MalformedPackageJson {
40 /// `serde_json` parse error text.
41 error: String,
42 },
43 /// A workspace glob pattern matched a directory that contains no
44 /// `package.json`. Honors the extended skip list and `ignorePatterns`
45 /// before emitting.
46 GlobMatchedNoPackageJson {
47 /// The glob pattern that matched the directory.
48 pattern: String,
49 },
50 /// `tsconfig.json` exists at the root but failed to parse. Project
51 /// references cannot be discovered.
52 MalformedTsconfig {
53 /// JSONC parse error text.
54 error: String,
55 },
56 /// `tsconfig.json` lists a `references[].path` that does not point to an
57 /// existing directory.
58 TsconfigReferenceDirMissing,
59 /// `pnpm-workspace.yaml` exists but failed to parse as YAML. Catalog and
60 /// dependency-override analysis proceeds with no entries (degraded), so
61 /// `catalog:`-referenced dependencies may be misclassified until the
62 /// syntax is fixed.
63 MalformedPnpmWorkspaceYaml {
64 /// `serde_yaml_ng` parse error text.
65 error: String,
66 },
67 /// A source file was skipped at discovery because it exceeds the configured
68 /// per-file size limit (`--max-file-size` / `FALLOW_MAX_FILE_SIZE`, default
69 /// 5 MB). The file is never read, parsed, or analyzed, guarding against the
70 /// out-of-memory blowup a single multi-MB generated/vendored/bundled file
71 /// causes (issue #1086). Surfaced by source discovery, not workspace
72 /// discovery, but shares this channel so the skip is visible in
73 /// `workspace_diagnostics[]` on `fallow dead-code / dupes / health` JSON.
74 SkippedLargeFile {
75 /// On-disk size of the skipped file in bytes.
76 size_bytes: u64,
77 },
78 /// A large JavaScript bundle was skipped at discovery because it appears to
79 /// be minified generated output. The file is never parsed or analyzed,
80 /// guarding against sub-limit bundles that can still create very large ASTs
81 /// and extraction payloads (issue #1086). Use `--max-file-size 0` when the
82 /// bundled file really should be analyzed.
83 SkippedMinifiedFile {
84 /// On-disk size of the skipped file in bytes.
85 size_bytes: u64,
86 },
87 /// A dot-prefixed directory was not traversed by source discovery even
88 /// though it contains at least one source file the project has not
89 /// excluded. Hidden directories are skipped by default apart from a small
90 /// convention allowlist (`.storybook`, `.vitepress`, `.well-known`,
91 /// `.changeset`, `.github`) and the directories an active framework plugin
92 /// or a `package.json` script reference contributes, so files inside are
93 /// never parsed and their imports and exports are invisible to every
94 /// analysis. No config field adds a directory to traversal: run fallow
95 /// with `--root` against the directory to analyze it on its own, or add it
96 /// to `ignorePatterns` to silence this (issue #461).
97 ///
98 /// "Not excluded" is measured the way the run measures it: a directory
99 /// whose contents are gitignored, or excluded by `ignorePatterns`, or (on
100 /// a `--production` run) excluded as test or story files, never earns this
101 /// diagnostic, because the advertised remedies would find nothing there
102 /// either. Generated tool output and non-git VCS metadata are excluded by
103 /// name.
104 ///
105 /// The advisory is best-effort and bounded: one run inspects a fixed
106 /// number of skipped directories with a fixed I/O budget, in sorted path
107 /// order, so a pathological tree yields a deterministic prefix rather than
108 /// an unbounded array or an unbounded scan. The stderr note says "at
109 /// least" when a ceiling bound the run.
110 ///
111 /// Surfaced by source discovery, not workspace discovery, but shares this
112 /// channel so the skip is visible in `workspace_diagnostics[]` on
113 /// `fallow dead-code / dupes / health` JSON.
114 ///
115 /// Unlike the two skipped-file kinds beside it, this one is CAPPED. To
116 /// bound the directory reads the check costs, a run classifies at most 64
117 /// candidate directories and spends at most 1024 directory entries across
118 /// all of them, so on a project that exceeds either ceiling the array is a
119 /// prefix of the skipped directories rather than all of them, and the
120 /// stderr note says "at least N". No measured repository comes close to
121 /// either ceiling. A consumer needing an exact total should run fallow
122 /// with `--root` against the tree rather than infer one from this array.
123 SkippedSourceDotdir,
124 /// A source discovered with a stable [`FileId`](crate::discover::FileId)
125 /// could not be read before parsing. Analysis continues with the remaining
126 /// sparse module IDs and reports the underlying filesystem or UTF-8 error.
127 SourceReadFailure {
128 /// Filesystem or UTF-8 decoding error from `read_to_string`.
129 error: String,
130 },
131 /// A source file was read but parsed with diagnostics, so the module
132 /// extracted from it may be missing imports, exports, or references after
133 /// the first error. Analysis proceeds with the partial module, which is why
134 /// this is reported: an import the parser never saw credits nothing, and its
135 /// target can surface as a confident `unused-file` or `unused-export`
136 /// finding with a `delete-file` or `remove-export` action on it.
137 ///
138 /// Recorded by the parse stage, alongside `source-read-failure`, and never
139 /// used to withhold a finding. oxc reports recoverable errors for valid
140 /// syntax newer than the parser as well as for genuinely broken files, so
141 /// gating findings on this would mute real results project-wide instead of
142 /// just the affected file.
143 SourceParseDegraded {
144 /// Number of parser diagnostics reported for the file.
145 error_count: u32,
146 /// `true` when the parser abandoned the file instead of recovering, so
147 /// the extracted module is a fragment at best.
148 panicked: bool,
149 },
150 /// Dependency-override resolution was skipped because bun's legacy binary
151 /// `bun.lockb` sits next to this `package.json`, fallow cannot read the
152 /// binary format, and no parseable text lockfile was found to use
153 /// instead: no `bun.lock` that parses, and no readable `pnpm-lock.yaml`,
154 /// `package-lock.json`, or `npm-shrinkwrap.json`. A `yarn.lock` is never
155 /// consulted (yarn ignores `overrides`), so it does not prevent the skip
156 /// either. The manifest declares overrides, so the
157 /// `unused-dependency-overrides` check would otherwise have run; without
158 /// resolution ground truth it would flag every transitive-only pin, so no
159 /// unused-override findings are reported at all (issue #2358). Surfaced
160 /// by the override analysis, not workspace discovery, but shares this
161 /// channel so the skip is visible in `workspace_diagnostics[]` JSON and
162 /// as a stderr warning.
163 BunLockbOverrideResolutionSkipped,
164 /// Dependency-override resolution was skipped because bun's text
165 /// `bun.lock` exists but could not be parsed and no readable pnpm or npm
166 /// lockfile was available as independent resolution ground truth.
167 BunLockOverrideResolutionSkipped,
168 /// A bun manifest declares both `overrides` and a non-empty `resolutions`
169 /// object. Bun applies `overrides` and ignores `resolutions`, so fallow
170 /// reports the shadowed configuration without offering removal advice.
171 BunResolutionsShadowedByOverrides,
172 /// The project has no `node_modules` directory and is not a Deno project
173 /// that legitimately runs without one. Analysis proceeds, but three things
174 /// degrade silently: package `exports` and conditional exports cannot be
175 /// read, so imports into a dependency's subpaths resolve less precisely;
176 /// framework plugins that activate on an installed package stay inactive,
177 /// so their entry points and path aliases are missing; and a dependency's
178 /// installed shape cannot be inspected, so type-only dependency
179 /// classification falls back to declaration-based heuristics.
180 ///
181 /// Recorded once per run by the source walk, anchored at the missing
182 /// `node_modules` directory so the reported path is a real location rather
183 /// than the empty string a root-anchored diagnostic would render. This used
184 /// to be a bare `tracing::warn!` duplicated in two pipelines, so it never
185 /// reached JSON output and never reached `fallow doctor`, which reported
186 /// `pass` on a tree that had never been installed.
187 NodeModulesMissing,
188 /// `boundaries` is empty while `boundary-violation` is not `off`, so the
189 /// boundary detector never ran. Its summary counters are therefore
190 /// structurally zero and say nothing about the project.
191 ///
192 /// This is the UNCONFIGURED zero, not the user-chosen one: a project that
193 /// sets `boundary-violation: off` asked for silence and can see that
194 /// choice in `fallow config`. A project that left `boundaries` empty
195 /// cannot distinguish "no violations" from "nothing was measured".
196 BoundariesNotConfigured,
197 /// `rulePacks` is empty while `policy-violation` is not `off`, so the
198 /// policy detector never ran and its summary counters are structurally
199 /// zero. The unconfigured counterpart of
200 /// [`Self::BoundariesNotConfigured`].
201 RulePacksNotConfigured,
202 /// One of fallow's built-in discovery ignore patterns (`**/dist/**`,
203 /// `**/build/**`, `**/coverage/**`, and the four minified-bundle globs)
204 /// removed at least one candidate source file from this walk. The files
205 /// are never read, so their imports and exports are invisible to every
206 /// analysis, and until issue #2638 the drop was completely silent:
207 /// pointing fallow at a directory a built-in pattern matches returned a
208 /// clean report with exit 0 and nothing said why.
209 ///
210 /// `**/node_modules/**` is carved out and never appears in `pattern`:
211 /// installed dependencies are not the first-party source this diagnostic
212 /// is about, and a project that does not gitignore them would get a
213 /// five-figure count with no useful remedy. `**/.git/**` cannot fire,
214 /// because hidden directories are not traversed.
215 ///
216 /// One entry per pattern, never per file or per directory, so the array
217 /// grows by at most the number of built-in patterns on a project of any
218 /// size. `path` anchors at the matched directory holding the most excluded
219 /// files for that pattern, ties broken by the lexicographically first
220 /// path, so two runs on one tree report the same location. On a nested
221 /// match it is the DEEPEST segment the pattern matched
222 /// (`build/tools/build`, not `build`), because that is the directory the
223 /// `--root` remedy names and re-rooting at a shallower one would leave a
224 /// matching segment behind. That directory is the
225 /// largest group and not a majority: a flat monorepo can spread ten
226 /// excluded files over ten sibling `dist/` directories and every one of
227 /// them is then "the largest". `file_count` spans all of them, and
228 /// `directory_count` says how many there were, so a reader can tell a
229 /// single tree from a scattered one without a directory list in the
230 /// payload.
231 ///
232 /// Three properties of the population are load-bearing and easy to
233 /// misread:
234 ///
235 /// - **Gitignored trees count zero.** Source discovery honors
236 /// `.gitignore`, `.git/info/exclude`, and the global gitignore, and
237 /// prunes those directories before this check runs. The honest reading
238 /// is "candidate source files git did not already hide and a built-in
239 /// pattern then dropped", which is why a repository that gitignores its
240 /// own `dist/` never sees this diagnostic.
241 /// - **A user `ignorePatterns` entry is not a surprise.** The compiled
242 /// ignore set is the union of `ignorePatterns` and the built-ins, so a
243 /// file both matched was an explicit project choice and is attributed to
244 /// no pattern here. The union also only ever adds: `ignorePatterns`
245 /// cannot negate a built-in, so a config edit is never the remedy.
246 /// - **The remedy depends on the pattern's shape.** A directory-shaped
247 /// built-in (`**/dist/**`) is matched against the path relative to the
248 /// run root, so re-rooting inside the matched directory removes the
249 /// matched segment and the files become visible: the message advertises
250 /// `fallow --root <dir>`. A file-shaped built-in (`**/*.min.js` and the
251 /// three other bundle globs) matches on the file name and keeps matching
252 /// at any root, so the message says so and points at renaming instead of
253 /// handing out a command that provably does nothing.
254 ///
255 /// Deliberately NOT one of the [`Self::source_never_analyzed`] kinds. These
256 /// exclusions are the product's designed behavior on generated output, not
257 /// a degraded run: answering `true` would attach `IncompleteFileAnalysis`
258 /// and `IncompleteImportGraph` caveats to findings on nearly every project
259 /// that keeps a non-gitignored `dist/` or `coverage/`, and make `fallow
260 /// fix` withhold `delete-file` and `remove-export` actions project-wide.
261 ExcludedByDefaultIgnore {
262 /// The built-in glob that matched, verbatim (for example
263 /// `**/build/**`).
264 pattern: String,
265 /// Candidate source files this pattern excluded in this walk, across
266 /// every directory it matched, not just the one `path` anchors at.
267 /// Exact: the walk counts each excluded candidate once.
268 file_count: u32,
269 /// Distinct directories this pattern matched at, `path` included, and
270 /// not the number of directories that held the files. A
271 /// directory-shaped pattern (`**/dist/**`) matches at the directory it
272 /// names, so an excluded subtree counts once however many nested
273 /// directories inside it held source: a `dist/` holding files in three
274 /// sub-directories reports `1`. A file-shaped pattern (`**/*.min.js`)
275 /// has no directory to collapse to and counts each matched file's own
276 /// parent. Exact either way, and anything above `1` says `path` names
277 /// one matched location out of several.
278 directory_count: u32,
279 },
280 /// The walk finished with no source file to analyze at all, so every
281 /// finding count this run reports is zero because nothing was measured
282 /// rather than because the project is clean (issue #2686).
283 ///
284 /// Distinct from [`Self::ExcludedByDefaultIgnore`], which reports one
285 /// pattern's exclusions and is designed behavior on generated output. The
286 /// alarm is not the exclusion, it is having nothing left afterwards, and
287 /// that condition also fires with no exclusion at all: a docs-only
288 /// repository, a workspace member with no TypeScript, or a path filter that
289 /// matched nothing. `excluded_file_count` names the built-in-ignore
290 /// contribution so the common cause is still attributable, and is `0` when
291 /// no built-in pattern took part.
292 ///
293 /// This is the kind a CI consumer reads to tell "measured zero" from
294 /// "measured nothing": the human report has said so since 3.26.0, but only
295 /// in human format and only under the built-in-ignore cause, so `--quiet
296 /// --format json` saw a clean green either way.
297 NoSourceFilesAnalyzed {
298 /// Candidate source files the built-in ignore patterns removed from
299 /// this walk, summed across every pattern. `0` when the walk found no
300 /// candidate to exclude in the first place.
301 excluded_file_count: u32,
302 },
303}
304
305impl WorkspaceDiagnosticKind {
306 /// Stable kebab-case identifier used in dedupe keys and tracing payloads.
307 #[must_use]
308 pub const fn id(&self) -> &'static str {
309 match self {
310 Self::UndeclaredWorkspace => "undeclared-workspace",
311 Self::MalformedPackageJson { .. } => "malformed-package-json",
312 Self::GlobMatchedNoPackageJson { .. } => "glob-matched-no-package-json",
313 Self::MalformedTsconfig { .. } => "malformed-tsconfig",
314 Self::TsconfigReferenceDirMissing => "tsconfig-reference-dir-missing",
315 Self::MalformedPnpmWorkspaceYaml { .. } => "malformed-pnpm-workspace-yaml",
316 Self::SkippedLargeFile { .. } => "skipped-large-file",
317 Self::SkippedMinifiedFile { .. } => "skipped-minified-file",
318 Self::SkippedSourceDotdir => "skipped-source-dotdir",
319 Self::SourceReadFailure { .. } => "source-read-failure",
320 Self::SourceParseDegraded { .. } => "source-parse-degraded",
321 Self::BunLockbOverrideResolutionSkipped => "bun-lockb-override-resolution-skipped",
322 Self::BunLockOverrideResolutionSkipped => "bun-lock-override-resolution-skipped",
323 Self::BunResolutionsShadowedByOverrides => "bun-resolutions-shadowed-by-overrides",
324 Self::NodeModulesMissing => "node-modules-missing",
325 Self::BoundariesNotConfigured => "boundaries-not-configured",
326 Self::RulePacksNotConfigured => "rule-packs-not-configured",
327 Self::ExcludedByDefaultIgnore { .. } => "excluded-by-default-ignore",
328 Self::NoSourceFilesAnalyzed { .. } => "no-source-files-analyzed",
329 }
330 }
331
332 /// Whether this diagnostic is worth a `tracing::warn!` line on stderr, on
333 /// top of its permanent entry in `workspace_diagnostics[]`.
334 ///
335 /// A warning is for a run whose RESULTS are degraded: something the user
336 /// installed, wrote, or expected did not reach the analysis. The two
337 /// unconfigured-check kinds are not that. They fire in the product's
338 /// default state, on every project that never opted into boundaries or
339 /// rule packs, and they will keep firing forever, because the remedy they
340 /// offer is to write configuration in order to silence a warning about not
341 /// having written configuration. They stay in the structured array, where a
342 /// consumer that wants to distinguish "measured zero" from "measured
343 /// nothing" can read them, and off the stderr surface that every other
344 /// command shares.
345 #[must_use]
346 pub const fn warns_on_stderr(&self) -> bool {
347 match self {
348 Self::BoundariesNotConfigured
349 | Self::RulePacksNotConfigured
350 | Self::ExcludedByDefaultIgnore { .. } => false,
351 Self::UndeclaredWorkspace
352 | Self::MalformedPackageJson { .. }
353 | Self::GlobMatchedNoPackageJson { .. }
354 | Self::MalformedTsconfig { .. }
355 | Self::TsconfigReferenceDirMissing
356 | Self::MalformedPnpmWorkspaceYaml { .. }
357 | Self::SkippedLargeFile { .. }
358 | Self::SkippedMinifiedFile { .. }
359 | Self::SkippedSourceDotdir
360 | Self::SourceReadFailure { .. }
361 | Self::SourceParseDegraded { .. }
362 | Self::BunLockbOverrideResolutionSkipped
363 | Self::BunLockOverrideResolutionSkipped
364 | Self::BunResolutionsShadowedByOverrides
365 | Self::NodeModulesMissing
366 | Self::NoSourceFilesAnalyzed { .. } => true,
367 }
368 }
369
370 /// Whether this diagnostic is produced by SOURCE discovery (the file walk in
371 /// `discover_files`) rather than WORKSPACE discovery (config load). Source-
372 /// discovery diagnostics are APPENDED to the registry after config load, so
373 /// `stash_workspace_diagnostics` must preserve them when it replaces the
374 /// workspace-discovery set, otherwise the per-analysis config re-loads in
375 /// combined-mode (`fallow` with no subcommand re-loads config for check,
376 /// dupes, and health) wipe them before the JSON envelope is built (issue
377 /// #1086).
378 #[must_use]
379 pub const fn is_source_discovery(&self) -> bool {
380 matches!(
381 self,
382 Self::SkippedLargeFile { .. }
383 | Self::SkippedMinifiedFile { .. }
384 | Self::SkippedSourceDotdir
385 | Self::SourceReadFailure { .. }
386 | Self::SourceParseDegraded { .. }
387 | Self::NodeModulesMissing
388 | Self::ExcludedByDefaultIgnore { .. }
389 | Self::NoSourceFilesAnalyzed { .. }
390 )
391 }
392
393 /// Whether this diagnostic is written by the source file WALK
394 /// (`discover_files`), the subset of [`Self::is_source_discovery`] that a
395 /// walk replaces wholesale for its root. `source-read-failure` is the
396 /// other source-discovery kind and is NOT one of these: the parse stage
397 /// records it after the walk, so it has to keep reaching consumers through
398 /// the registry.
399 ///
400 /// A walk-recorded entry must reach an analysis from its OWN walk's return
401 /// value. Combined mode runs the dead-code and duplication walks under
402 /// `rayon::join` whenever a per-analysis `production` split stops them from
403 /// sharing a file list, so a registry read answers "whichever walk wrote
404 /// last" and varies between runs of the same command (issue #2366).
405 #[must_use]
406 pub const fn is_source_walk_recorded(&self) -> bool {
407 matches!(
408 self,
409 Self::SkippedLargeFile { .. }
410 | Self::SkippedMinifiedFile { .. }
411 | Self::SkippedSourceDotdir
412 | Self::NodeModulesMissing
413 | Self::ExcludedByDefaultIgnore { .. }
414 | Self::NoSourceFilesAnalyzed { .. }
415 )
416 }
417
418 /// Whether this diagnostic reports a source file whose contents this run
419 /// never analyzed, so every import and export the file holds is invisible
420 /// to the module graph.
421 ///
422 /// This is the class `reachability_caveats[]` exists for. A file the run
423 /// never read credits nothing, so the modules it imports surface as
424 /// confident `unused-file` and `unused-export` findings carrying
425 /// `delete-file` and `remove-export` actions, and `fallow fix` would
426 /// otherwise apply the removal against source that still imports the
427 /// target.
428 ///
429 /// All four discovery-side kinds qualify, for the same reason and with the
430 /// same consequence:
431 ///
432 /// - `skipped-large-file` and `skipped-minified-file`: the file is in the
433 /// project tree and was never opened, so its import list is unknown.
434 /// - `skipped-source-dotdir`: the directory holds at least one source file
435 /// the project did not exclude, and none of them were traversed. The
436 /// diagnostic is capped, so it under-reports rather than over-reports;
437 /// its presence still proves unseen source exists.
438 /// - `source-read-failure`: the file was discovered and then could not be
439 /// read, so nothing was extracted from it at all.
440 ///
441 /// `source-parse-degraded` is deliberately NOT one of these, though it
442 /// belongs to the same family. Neither is `excluded-by-default-ignore`,
443 /// for a different reason: that one reports designed behavior on generated
444 /// output rather than a degraded run, and its own doc comment carries the
445 /// argument.
446 ///
447 /// `source-parse-degraded`: that file WAS read, so it has a module and
448 /// a graph node and its reachability is observable, which lets the caveat
449 /// pass narrow it: a degraded module that is itself unreachable cannot
450 /// change a reachability verdict. Every kind above has no node to ask (a
451 /// read failure has one with nothing extracted into it), so no narrowing
452 /// is available and the caveat they raise is run-level.
453 ///
454 /// The match is exhaustive on purpose: a new "the run did not see this
455 /// file" kind has to be classified here, and answering `true` is the only
456 /// wiring its findings need in order to inherit both the caveat and the
457 /// `fallow fix` withholding that follows it.
458 #[must_use]
459 pub const fn source_never_analyzed(&self) -> bool {
460 match self {
461 Self::SkippedLargeFile { .. }
462 | Self::SkippedMinifiedFile { .. }
463 | Self::SkippedSourceDotdir
464 | Self::SourceReadFailure { .. } => true,
465 Self::UndeclaredWorkspace
466 | Self::MalformedPackageJson { .. }
467 | Self::GlobMatchedNoPackageJson { .. }
468 | Self::MalformedTsconfig { .. }
469 | Self::TsconfigReferenceDirMissing
470 | Self::MalformedPnpmWorkspaceYaml { .. }
471 | Self::SourceParseDegraded { .. }
472 | Self::BunLockbOverrideResolutionSkipped
473 | Self::BunLockOverrideResolutionSkipped
474 | Self::BunResolutionsShadowedByOverrides
475 | Self::NodeModulesMissing
476 | Self::BoundariesNotConfigured
477 | Self::RulePacksNotConfigured
478 | Self::ExcludedByDefaultIgnore { .. }
479 | Self::NoSourceFilesAnalyzed { .. } => false,
480 }
481 }
482
483 /// Whether this diagnostic is recorded by the ANALYZE stage (the
484 /// dependency-catalog and override detectors) rather than by workspace or
485 /// source discovery. Analysis-stage diagnostics reach the registry through
486 /// `record_workspace_diagnostics` after config load, so
487 /// `stash_workspace_diagnostics` must preserve them across combined-mode's
488 /// per-analysis config re-loads, and every analyze pass clears its previous
489 /// entries before re-recording so a fixed cause drops out on the next run
490 /// (issue #2366). The match is exhaustive on purpose: a new kind must be
491 /// classified here before it compiles.
492 ///
493 /// Classify a kind `true` ONLY when a detector reachable from the dead-code
494 /// analyze pass (`find_dead_code_full`) re-records it, because that pass is
495 /// the single clear site. A kind recorded exclusively by another stage would
496 /// be cleared by the next dead-code pass and never come back.
497 #[must_use]
498 pub const fn is_analysis_stage(&self) -> bool {
499 match self {
500 Self::MalformedPnpmWorkspaceYaml { .. }
501 | Self::BunLockbOverrideResolutionSkipped
502 | Self::BunLockOverrideResolutionSkipped
503 | Self::BunResolutionsShadowedByOverrides
504 | Self::BoundariesNotConfigured
505 | Self::RulePacksNotConfigured => true,
506 Self::UndeclaredWorkspace
507 | Self::MalformedPackageJson { .. }
508 | Self::GlobMatchedNoPackageJson { .. }
509 | Self::MalformedTsconfig { .. }
510 | Self::TsconfigReferenceDirMissing
511 | Self::SkippedLargeFile { .. }
512 | Self::SkippedMinifiedFile { .. }
513 | Self::SkippedSourceDotdir
514 | Self::SourceReadFailure { .. }
515 | Self::SourceParseDegraded { .. }
516 | Self::NodeModulesMissing
517 | Self::ExcludedByDefaultIgnore { .. }
518 | Self::NoSourceFilesAnalyzed { .. } => false,
519 }
520 }
521}
522
523/// Render a byte count as a megabyte figure with one decimal place for
524/// human-readable diagnostic messages (e.g. `12.3 MB`).
525#[must_use]
526fn format_size_mb(bytes: u64) -> String {
527 #[expect(
528 clippy::cast_precision_loss,
529 reason = "display-only size figure; precision loss past 2^53 bytes is irrelevant"
530 )]
531 let mb = bytes as f64 / (1024.0 * 1024.0);
532 format!("{mb:.1} MB")
533}
534
535/// A diagnostic about a workspace-discovery candidate.
536///
537/// The `message` field is a human-readable rendering derived from `kind`. It
538/// always ends with a concrete next step ("fix the JSON syntax", "remove from
539/// `workspaces`", "add to `ignorePatterns`") so first-time users have a path
540/// forward.
541#[derive(Debug, Clone, Serialize, Deserialize)]
542#[cfg_attr(feature = "schema", derive(JsonSchema))]
543pub struct WorkspaceDiagnostic {
544 /// Path to the directory or file that triggered the diagnostic.
545 #[serde(serialize_with = "serde_path::serialize")]
546 pub path: PathBuf,
547 /// Kind discriminator with the typed payload.
548 #[serde(flatten)]
549 pub kind: WorkspaceDiagnosticKind,
550 /// Human-readable rendering derived from `kind` + `path`. Always ends
551 /// with a next-step hint.
552 pub message: String,
553 /// True when this diagnostic reports a run whose RESULTS are degraded:
554 /// something the user installed, wrote, or expected did not reach the
555 /// analysis. Projected from [`WorkspaceDiagnosticKind::warns_on_stderr`],
556 /// which is the same classification that decides whether the CLI prints a
557 /// stderr line, so a CI log built from this field and a local non-quiet run
558 /// say the same thing.
559 ///
560 /// Omitted when false, which is what keeps every clean run byte-identical.
561 /// The two unconfigured-check kinds answer false on purpose: they fire in
562 /// the product's default state on every project that never opted into
563 /// boundaries or rule packs, so warning on them would warn forever. So does
564 /// `excluded-by-default-ignore`, which is designed behavior on generated
565 /// output; the alarm for that case is `no-source-files-analyzed`.
566 ///
567 /// Read this instead of hardcoding a kind allowlist: a degrading kind added
568 /// in a later release then reaches an unchanged consumer.
569 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
570 pub degrades_analysis: bool,
571}
572
573impl WorkspaceDiagnostic {
574 /// Construct a diagnostic with the message rendered from `kind` + `path`.
575 ///
576 /// `root` is used to produce project-relative paths in the message text
577 /// AND inside the variant payload (e.g. the `error` field of
578 /// `MalformedPackageJson` / `MalformedTsconfig` which embed the absolute
579 /// file path from `PackageJson::load()`'s error text). Without the
580 /// payload-side normalisation the embedded path would survive
581 /// environment-specific differences (CI vs Docker vs local) because the
582 /// post-serialisation `strip_root_prefix` only catches whole-string
583 /// matches, not paths embedded mid-sentence.
584 ///
585 /// If `path` is not under `root` (e.g. canonicalisation crossed a
586 /// symlink), the absolute path is emitted instead.
587 ///
588 /// `path` also loses any no-op `.` component, for the same reason the
589 /// payload loses a glob's `./` prefix: one directory reached through two
590 /// spellings of one glob must be one diagnostic.
591 #[must_use]
592 pub fn new(root: &Path, path: PathBuf, kind: WorkspaceDiagnosticKind) -> Self {
593 let path = normalise_diagnostic_path(path);
594 let kind = normalise_payload_paths(root, kind);
595 let message = render_message(root, &path, &kind);
596 let degrades_analysis = kind.warns_on_stderr();
597 Self {
598 path,
599 kind,
600 message,
601 degrades_analysis,
602 }
603 }
604
605 /// Return this diagnostic with `path` rewritten relative to `root`.
606 ///
607 /// `path` is stored absolute so callers can act on it. Every JSON envelope
608 /// emits it project-relative instead: the analysis envelopes get there
609 /// through the post-serialisation `strip_root_prefix` pass, which the
610 /// `fallow workspaces` / `fallow list --workspaces` envelope and the MCP
611 /// `project_info` tool never run, so those emitted the absolute path while
612 /// the sibling `workspaces[].path` next to it was relative. They normalise
613 /// at the typed layer with this method instead.
614 ///
615 /// Paths outside `root` (canonicalisation crossed a symlink) are left
616 /// absolute, matching how [`Self::new`] renders the message.
617 ///
618 /// A diagnostic anchored at the root itself becomes `.`, not the empty
619 /// path: an empty string is not a location, and the analysis envelopes'
620 /// post-serialisation strip only removes a `root + separator` prefix, so a
621 /// root-anchored path that stays absolute here leaks a host path.
622 #[must_use]
623 pub fn into_root_relative(mut self, root: &Path) -> Self {
624 if let Ok(relative) = self.path.strip_prefix(root) {
625 self.path = if relative.as_os_str().is_empty() {
626 PathBuf::from(".")
627 } else {
628 relative.to_path_buf()
629 };
630 }
631 self
632 }
633}
634
635/// Rebuild `path` from its components so one directory has one spelling.
636///
637/// The dedupe key was never the problem: [`Path`] equality already ignores an
638/// interior `.`, so `<root>/./pkgs/aaa` and `<root>/pkgs/aaa` are one key. The
639/// stored bytes were. A workspace glob spelled `./pkgs/*` in `package.json`
640/// expands to the first spelling and the same glob spelled `pkgs/*` in
641/// `pnpm-workspace.yaml` expands to the second, and the two envelope families
642/// make a project-relative path differently: the analysis envelopes strip the
643/// root as a string (leaving `./pkgs/aaa`) while the workspace listing
644/// envelope uses [`WorkspaceDiagnostic::into_root_relative`] (leaving
645/// `pkgs/aaa`). Whichever
646/// manifest happened to be read first then decided which shape every consumer
647/// saw. Collapsing at construction gives them one answer (issue #2366).
648///
649/// A path that is already component-clean rebuilds to itself. Serialization
650/// normalises separators, so the rebuild is wire-invisible on Windows.
651fn normalise_diagnostic_path(path: PathBuf) -> PathBuf {
652 let rebuilt: PathBuf = path.components().collect();
653 if rebuilt.as_os_str() == path.as_os_str() {
654 path
655 } else {
656 rebuilt
657 }
658}
659
660/// Strip the project root from absolute paths embedded inside variant
661/// payloads (the `error` field of malformed-config and source-read failures),
662/// and drop a glob pattern's no-op `./` prefix.
663///
664/// Mirrors the per-platform `display()` byte sequence so the substring match
665/// works on Windows too.
666///
667/// The pattern prefix matters because the payload is part of the dedupe key in
668/// [`merge_workspace_diagnostics`]. A repository whose `package.json` declares
669/// `"./apps/**"` and whose `pnpm-workspace.yaml` declares `apps/**` names one
670/// glob twice, and without this both spellings would report every package-less
671/// directory under `apps/` a second time (issue #2366).
672fn normalise_payload_paths(root: &Path, kind: WorkspaceDiagnosticKind) -> WorkspaceDiagnosticKind {
673 let root_str = root.display().to_string();
674 let root_alt = root_str.replace('\\', "/");
675 let normalise = |text: String| -> String {
676 let stripped = text
677 .replace(&format!("{root_str}/"), "")
678 .replace(&format!("{root_alt}/"), "");
679 stripped
680 .replace(&format!("{root_str}\\"), "")
681 .replace(&format!("{root_alt}\\"), "")
682 };
683 match kind {
684 WorkspaceDiagnosticKind::MalformedPackageJson { error } => {
685 WorkspaceDiagnosticKind::MalformedPackageJson {
686 error: normalise(error),
687 }
688 }
689 WorkspaceDiagnosticKind::MalformedTsconfig { error } => {
690 WorkspaceDiagnosticKind::MalformedTsconfig {
691 error: normalise(error),
692 }
693 }
694 WorkspaceDiagnosticKind::SourceReadFailure { error } => {
695 WorkspaceDiagnosticKind::SourceReadFailure {
696 error: normalise(error),
697 }
698 }
699 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => {
700 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
701 pattern: canonical_glob_pattern(pattern),
702 }
703 }
704 other => other,
705 }
706}
707
708/// Drop the leading `./` (or `.\`) a workspace glob may carry, so the same
709/// pattern declared in two manifests is one payload.
710///
711/// A pattern that is nothing BUT the prefix (`"./"`, the root itself) keeps
712/// its spelling: stripping it would report an empty `pattern` field and an
713/// empty quoted glob in the warning text, which names no glob at all.
714fn canonical_glob_pattern(pattern: String) -> String {
715 for prefix in ["./", ".\\"] {
716 if let Some(rest) = pattern.strip_prefix(prefix)
717 && !rest.is_empty()
718 {
719 return rest.to_owned();
720 }
721 }
722 pattern
723}
724
725/// Concatenate two diagnostic lists, keeping the first occurrence of each
726/// `(kind, path)` pair and the order of `primary` followed by the entries only
727/// `secondary` has.
728///
729/// The single place diagnostics from two observation points are folded
730/// together: an engine session's own capture plus the process registry, and
731/// the combined run's per-analysis lists (issue #2366). A combined run walks
732/// the project once per analysis, and per-analysis `production` modes can make
733/// those walks see different file sets, so no single observation point holds
734/// everything the run recorded; the union does, and folding it the same way
735/// everywhere is what keeps the CLI and the programmatic route answering
736/// identically.
737///
738/// The key is the WHOLE kind, payload included, not its
739/// [`id`](WorkspaceDiagnosticKind::id). Two entries can share a kind id and a
740/// path and still be two distinct diagnostics: overlapping workspace globs
741/// (`["packages/*", "packages/*/*"]`) each report the same package-less
742/// directory with their own `pattern`, and the standalone envelopes report
743/// both. An id-keyed fold silently dropped the second one.
744#[must_use]
745pub fn merge_workspace_diagnostics(
746 primary: Vec<WorkspaceDiagnostic>,
747 secondary: Vec<WorkspaceDiagnostic>,
748) -> Vec<WorkspaceDiagnostic> {
749 let mut merged = Vec::with_capacity(primary.len() + secondary.len());
750 let mut seen: FxHashSet<(WorkspaceDiagnosticKind, PathBuf)> = FxHashSet::default();
751 for diagnostic in primary.into_iter().chain(secondary) {
752 let key = (diagnostic.kind.clone(), diagnostic.path.clone());
753 if seen.insert(key) {
754 merged.push(diagnostic);
755 }
756 }
757 merged
758}
759
760/// Keep the first occurrence of each `(kind, path)` pair in one list.
761///
762/// The single-list form of [`merge_workspace_diagnostics`], applied where
763/// diagnostics are produced rather than where two observation points are
764/// folded: workspace discovery reads `package.json` `workspaces`,
765/// `pnpm-workspace.yaml` `packages`, `deno.json` `workspace` and the root
766/// `tsconfig.json` references additively, so a repository that declares one
767/// glob in two of them reports every package-less directory under it twice.
768/// Deduplicating at that source is what keeps the JSON envelopes, the
769/// aggregated stderr warning and the process registry telling one story
770/// (issue #2366).
771#[must_use]
772pub fn dedupe_workspace_diagnostics(
773 diagnostics: Vec<WorkspaceDiagnostic>,
774) -> Vec<WorkspaceDiagnostic> {
775 merge_workspace_diagnostics(diagnostics, Vec::new())
776}
777
778/// Render `path` relative to `root` with forward slashes. The forward-slash
779/// normalisation is load-bearing for cross-platform output stability.
780fn display_relative(root: &Path, path: &Path) -> String {
781 path.strip_prefix(root)
782 .unwrap_or(path)
783 .display()
784 .to_string()
785 .replace('\\', "/")
786}
787
788/// The first segment of a glob that contains no glob metacharacter, so it
789/// names a real directory rather than a wildcard.
790///
791/// Source discovery uses it to decide which directory a built-in ignore
792/// pattern excluded a file "at"; `render_message` uses it to decide which
793/// remedy is true for that pattern. The two have to agree, so the function
794/// lives here rather than once per crate: a pattern with such a segment
795/// (`**/dist/**`) is lifted by re-rooting inside the matched directory,
796/// because the glob is matched against the path relative to the run root. A
797/// pattern without one (`**/*.min.js`) matches on the file name and keeps
798/// matching at every root.
799#[must_use]
800pub fn glob_first_literal_segment(pattern: &str) -> Option<&str> {
801 pattern.split('/').find(|segment| {
802 !segment.is_empty()
803 && !segment.contains(['*', '?', '[', ']', '{', '}'])
804 && *segment != "."
805 && *segment != ".."
806 })
807}
808
809fn render_message(root: &Path, path: &Path, kind: &WorkspaceDiagnosticKind) -> String {
810 let display = display_relative(root, path);
811 match kind {
812 WorkspaceDiagnosticKind::UndeclaredWorkspace => format!(
813 "Directory '{display}' contains package.json but is not declared as a workspace. \
814 Add it to package.json workspaces or pnpm-workspace.yaml, or add it to ignorePatterns."
815 ),
816 WorkspaceDiagnosticKind::MalformedPackageJson { error } => format!(
817 "Dropped workspace '{display}': package.json is not valid JSON ({error}). \
818 Fix the JSON syntax or remove '{display}' from the workspaces pattern."
819 ),
820 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => format!(
821 "Glob '{pattern}' matched '{display}' but no package.json is present. \
822 Add a package.json, narrow the pattern, or add '{display}' to ignorePatterns."
823 ),
824 WorkspaceDiagnosticKind::MalformedTsconfig { error } => format!(
825 "tsconfig.json at '{display}' failed to parse ({error}); \
826 project references will be ignored. Fix the JSON syntax."
827 ),
828 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => format!(
829 "tsconfig.json references '{display}' but the directory does not exist. \
830 Update or remove the reference, or restore the missing directory."
831 ),
832 WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml { error } => format!(
833 "'{display}' failed to parse ({error}); catalog and override entries \
834 will be ignored. Fix the YAML syntax."
835 ),
836 WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes } => format!(
837 "Skipped '{display}' ({size}): exceeds the max file size limit. \
838 Its imports and exports are not analyzed. Raise the limit with \
839 --max-file-size <MB> (or FALLOW_MAX_FILE_SIZE), or add '{display}' \
840 to ignorePatterns.",
841 size = format_size_mb(*size_bytes)
842 ),
843 WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes } => format!(
844 "Skipped '{display}' ({size}): appears to be minified generated JavaScript. \
845 Its imports and exports are not analyzed. Add '{display}' to ignorePatterns, \
846 rename it with a .min.js suffix, or use --max-file-size 0 if this file \
847 should be analyzed.",
848 size = format_size_mb(*size_bytes)
849 ),
850 WorkspaceDiagnosticKind::SkippedSourceDotdir => format!(
851 "Skipped hidden directory '{display}': it contains source files but hidden \
852 directories are not traversed. Its imports and exports are not analyzed. \
853 There is no config field that adds a directory to traversal. If it holds \
854 first-party source, analyze it on its own with fallow --root {display}; if it \
855 is tool or agent scratch state, add '{display}/**' to ignorePatterns to \
856 silence this."
857 ),
858 WorkspaceDiagnosticKind::SourceReadFailure { error } => format!(
859 "Could not read source '{display}' ({error}). Restore the file or its read permissions, \
860 ensure it contains valid UTF-8 text, or add '{display}' to ignorePatterns."
861 ),
862 WorkspaceDiagnosticKind::SourceParseDegraded {
863 error_count,
864 panicked,
865 } => {
866 let outcome = if *panicked {
867 "the parser stopped there"
868 } else {
869 "the parser recovered and continued"
870 };
871 format!(
872 "Parsed '{display}' with {error_count} error(s); {outcome}. Imports, exports, and \
873 references it did not reach are missing from this run, so files and symbols it \
874 uses can be reported as unused. Fix the syntax, or ignore this if the file uses \
875 syntax newer than fallow's parser."
876 )
877 }
878 WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped => format!(
879 "Skipped dependency-override resolution for '{display}': bun's legacy binary bun.lockb \
880 sits next to it, fallow cannot read the binary format, and no parseable text lockfile \
881 (bun.lock, pnpm-lock.yaml, package-lock.json, or npm-shrinkwrap.json) was found to \
882 use instead, so unused-dependency-overrides findings are not reported. Run bun install \
883 --save-text-lockfile (bun 1.2 or newer) to write a text bun.lock, or delete the stale \
884 bun.lockb if this repository no longer uses bun."
885 ),
886 WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped => format!(
887 "Skipped dependency-override resolution because '{display}' could not be parsed and \
888 no readable pnpm or npm lockfile was available, so unused-dependency-overrides \
889 findings are not reported. Run bun install to regenerate the text lockfile, then \
890 rerun fallow."
891 ),
892 WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides => format!(
893 "'{display}' declares both `overrides` and non-empty `resolutions`; bun applies \
894 `overrides` and ignores `resolutions`. Move the intended pins into `overrides` or \
895 remove the shadowed `resolutions` entries."
896 ),
897 WorkspaceDiagnosticKind::NodeModulesMissing => format!(
898 "'{display}' does not exist. Package exports and conditional exports cannot be read, \
899 framework plugins that activate on an installed package stay inactive, and \
900 dependency classification degrades, so imports and dependencies can be \
901 misreported. Run npm install / pnpm install / yarn / bun install first."
902 ),
903 WorkspaceDiagnosticKind::BoundariesNotConfigured => {
904 "No architecture boundaries are configured, so the boundary detector did not run and \
905 its violation counts are zero because nothing was measured. Add `boundaries` to the \
906 config, or set `boundary-violation` to off to state that the check is not wanted."
907 .to_string()
908 }
909 WorkspaceDiagnosticKind::RulePacksNotConfigured => {
910 "No rule packs are configured, so the policy detector did not run and its violation \
911 counts are zero because nothing was measured. Add `rulePacks` to the config, or set \
912 `policy-violation` to off to state that the check is not wanted."
913 .to_string()
914 }
915 WorkspaceDiagnosticKind::NoSourceFilesAnalyzed {
916 excluded_file_count,
917 } => {
918 if *excluded_file_count == 0 {
919 "No source files were analyzed, so every finding count this run reports is zero \
920 because nothing was measured. Check the analysis root, ignorePatterns, and any \
921 path or workspace filter this run applied."
922 .to_owned()
923 } else {
924 format!(
925 "No source files were analyzed. Fallow's built-in ignore patterns excluded \
926 {excluded_file_count} candidate files, so every finding count this run \
927 reports is zero because nothing was measured; run with --explain-skipped \
928 for the breakdown."
929 )
930 }
931 }
932 WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
933 pattern,
934 file_count,
935 directory_count,
936 } => {
937 // `path` is a location, and an empty string is not one: a built-in
938 // that matched a file sitting directly at the analysis root
939 // anchors at the root itself.
940 let display = if display.is_empty() {
941 ".".to_owned()
942 } else {
943 display
944 };
945 // The payload carries no directory list, so the message names the
946 // one directory `path` anchors at. With several excluded
947 // directories that is the largest group and NOT a majority, so the
948 // sentence says which claim it is making and how many directories
949 // it is leaving unnamed.
950 let location = if *directory_count > 1 {
951 format!(
952 "Skipped {file_count} source files across {directory_count} directories, \
953 the largest group under '{display}'"
954 )
955 } else if *file_count == 1 {
956 format!("Skipped 1 source file under '{display}'")
957 } else {
958 format!("Skipped {file_count} source files under '{display}'")
959 };
960 let singular = *file_count == 1 && *directory_count <= 1;
961 let (subject, effect) = if singular {
962 ("it matches", "it imports, exports, or defines")
963 } else {
964 ("they match", "they import, export, or define")
965 };
966 // Only a directory-shaped built-in is lifted by re-rooting. Telling
967 // a user with a `vendor/lib.min.js` to run `fallow --root vendor`
968 // hands them a command that excludes the same file again.
969 let remedy = if glob_first_literal_segment(pattern).is_some() {
970 format!(
971 "Move first-party source out of the matched directory, or analyze that \
972 directory on its own with fallow --root {display}."
973 )
974 } else {
975 "This pattern matches a file name rather than a directory, so re-running under \
976 a different --root excludes the same files again. Rename first-party source \
977 that only looks generated, dropping the '.min' or '.bundle' infix."
978 .to_owned()
979 };
980 format!(
981 "{location}: {subject} fallow's built-in ignore pattern '{pattern}', so nothing \
982 {effect} is visible to this run. Built-in ignores cannot be switched off \
983 through ignorePatterns. {remedy}"
984 )
985 }
986 }
987}
988
989#[cfg(test)]
990mod tests {
991 use super::*;
992
993 #[test]
994 fn skipped_large_file_diagnostic_id_and_message() {
995 let root = Path::new("/project");
996 let diag = WorkspaceDiagnostic::new(
997 root,
998 root.join("src/vendor/app.bundle.js"),
999 WorkspaceDiagnosticKind::SkippedLargeFile {
1000 size_bytes: 6 * 1024 * 1024,
1001 },
1002 );
1003 assert_eq!(diag.kind.id(), "skipped-large-file");
1004 assert!(
1005 diag.message.contains("src/vendor/app.bundle.js"),
1006 "message names the project-relative path: {}",
1007 diag.message
1008 );
1009 assert!(
1010 diag.message.contains("6.0 MB"),
1011 "message reports the size: {}",
1012 diag.message
1013 );
1014 assert!(
1015 diag.message.contains("--max-file-size"),
1016 "message names the override flag: {}",
1017 diag.message
1018 );
1019 }
1020
1021 #[test]
1022 fn skipped_minified_file_diagnostic_id_and_message() {
1023 let root = Path::new("/project");
1024 let diag = WorkspaceDiagnostic::new(
1025 root,
1026 root.join("src/assets/index-abc123.js"),
1027 WorkspaceDiagnosticKind::SkippedMinifiedFile {
1028 size_bytes: 2 * 1024 * 1024,
1029 },
1030 );
1031 assert_eq!(diag.kind.id(), "skipped-minified-file");
1032 assert!(
1033 diag.message.contains("src/assets/index-abc123.js"),
1034 "message names the project-relative path: {}",
1035 diag.message
1036 );
1037 assert!(
1038 diag.message.contains("2.0 MB"),
1039 "message reports the size: {}",
1040 diag.message
1041 );
1042 assert!(
1043 diag.message.contains("--max-file-size 0"),
1044 "message names the opt-out: {}",
1045 diag.message
1046 );
1047 }
1048
1049 #[test]
1050 fn skipped_source_dotdir_diagnostic_id_and_message() {
1051 let root = Path::new("/project");
1052 let diag = WorkspaceDiagnostic::new(
1053 root,
1054 root.join(".claude"),
1055 WorkspaceDiagnosticKind::SkippedSourceDotdir,
1056 );
1057 assert_eq!(diag.kind.id(), "skipped-source-dotdir");
1058 assert!(
1059 diag.message.contains(".claude"),
1060 "message names the project-relative path: {}",
1061 diag.message
1062 );
1063 assert!(
1064 diag.message
1065 .contains("Its imports and exports are not analyzed."),
1066 "message states the consequence: {}",
1067 diag.message
1068 );
1069 assert!(
1070 diag.message.contains("--root"),
1071 "message names the real remedy: {}",
1072 diag.message
1073 );
1074 assert!(
1075 diag.message.contains("ignorePatterns"),
1076 "message names the silencing route: {}",
1077 diag.message
1078 );
1079 assert!(
1080 diag.message.contains("no config field"),
1081 "the message must say plainly that no config field traverses it: {}",
1082 diag.message
1083 );
1084 assert_eq!(
1085 serde_json::to_value(&diag).expect("serializes")["kind"],
1086 "skipped-source-dotdir",
1087 "id() must byte-match the serde kebab-case tag"
1088 );
1089 }
1090
1091 #[cfg(feature = "schema")]
1092 #[test]
1093 fn workspace_diagnostic_schema_includes_skipped_source_dotdir() {
1094 let schema = schemars::schema_for!(WorkspaceDiagnostic);
1095 let json = serde_json::to_string(&schema).expect("schema serializes");
1096 assert!(json.contains("skipped-source-dotdir"));
1097 }
1098
1099 #[test]
1100 fn source_read_failure_serializes_typed_error_payload() {
1101 let root = Path::new("/project");
1102 let diagnostic = WorkspaceDiagnostic::new(
1103 root,
1104 root.join("src/removed.ts"),
1105 WorkspaceDiagnosticKind::SourceReadFailure {
1106 error: "No such file or directory".to_string(),
1107 },
1108 );
1109
1110 let json = serde_json::to_value(&diagnostic).expect("diagnostic serializes");
1111 assert_eq!(json["kind"], "source-read-failure");
1112 assert_eq!(
1113 json["path"],
1114 root.join("src/removed.ts")
1115 .display()
1116 .to_string()
1117 .replace('\\', "/")
1118 );
1119 assert_eq!(json["error"], "No such file or directory");
1120 assert!(
1121 json["message"]
1122 .as_str()
1123 .is_some_and(|message| message.contains("src/removed.ts"))
1124 );
1125 }
1126
1127 #[cfg(feature = "schema")]
1128 #[test]
1129 fn workspace_diagnostic_schema_includes_source_read_failure() {
1130 let schema = schemars::schema_for!(WorkspaceDiagnostic);
1131 let json = serde_json::to_string(&schema).expect("schema serializes");
1132 assert!(json.contains("source-read-failure"));
1133 assert!(json.contains("error"));
1134 }
1135
1136 #[test]
1137 fn bun_lockb_override_resolution_skipped_id_and_message() {
1138 let root = Path::new("/project");
1139 let diag = WorkspaceDiagnostic::new(
1140 root,
1141 root.join("package.json"),
1142 WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
1143 );
1144 assert_eq!(diag.kind.id(), "bun-lockb-override-resolution-skipped");
1145 assert!(
1146 diag.message.contains("'package.json'"),
1147 "message names the project-relative manifest: {}",
1148 diag.message
1149 );
1150 assert!(
1151 diag.message.contains("no parseable text lockfile"),
1152 "message states the cause: {}",
1153 diag.message
1154 );
1155 assert!(
1156 !diag.message.contains("only bun.lockb"),
1157 "message must not claim bun.lockb is the only lockfile; yarn.lock or an unparseable \
1158 bun.lock may sit beside it: {}",
1159 diag.message
1160 );
1161 assert!(
1162 diag.message.contains("bun install --save-text-lockfile")
1163 && diag.message.contains("delete the stale bun.lockb"),
1164 "message ends with the text-lockfile next step and the stale-lockb alternative: {}",
1165 diag.message
1166 );
1167 let json = serde_json::to_value(&diag).expect("diagnostic serializes");
1168 assert_eq!(json["kind"], "bun-lockb-override-resolution-skipped");
1169 }
1170
1171 #[test]
1172 fn bun_override_diagnostic_ids_and_messages_are_actionable() {
1173 let root = Path::new("/project");
1174 let malformed = WorkspaceDiagnostic::new(
1175 root,
1176 root.join("bun.lock"),
1177 WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
1178 );
1179 assert_eq!(malformed.kind.id(), "bun-lock-override-resolution-skipped");
1180 assert!(malformed.message.contains("regenerate"));
1181
1182 let shadowed = WorkspaceDiagnostic::new(
1183 root,
1184 root.join("package.json"),
1185 WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
1186 );
1187 assert_eq!(shadowed.kind.id(), "bun-resolutions-shadowed-by-overrides");
1188 assert!(shadowed.message.contains("ignores `resolutions`"));
1189 }
1190
1191 #[test]
1192 fn into_root_relative_strips_the_root_and_keeps_outside_paths_absolute() {
1193 let root = Path::new("/project");
1194 let inside = WorkspaceDiagnostic::new(
1195 root,
1196 root.join("packages/inner"),
1197 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1198 )
1199 .into_root_relative(root);
1200 assert_eq!(inside.path, Path::new("packages/inner"));
1201
1202 let outside = WorkspaceDiagnostic::new(
1203 root,
1204 PathBuf::from("/elsewhere/packages/inner"),
1205 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1206 )
1207 .into_root_relative(root);
1208 assert_eq!(outside.path, Path::new("/elsewhere/packages/inner"));
1209 }
1210
1211 #[test]
1212 fn analysis_stage_classification_covers_only_analyze_stage_kinds() {
1213 let analysis_stage = [
1214 WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml {
1215 error: "bad yaml".to_owned(),
1216 },
1217 WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
1218 WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
1219 WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
1220 ];
1221 for kind in &analysis_stage {
1222 assert!(
1223 kind.is_analysis_stage() && !kind.is_source_discovery(),
1224 "{} is recorded by the analyze stage only",
1225 kind.id()
1226 );
1227 }
1228
1229 let other = [
1230 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1231 WorkspaceDiagnosticKind::MalformedPackageJson {
1232 error: "trailing comma".to_owned(),
1233 },
1234 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1235 pattern: "packages/*".to_owned(),
1236 },
1237 WorkspaceDiagnosticKind::MalformedTsconfig {
1238 error: "unexpected token".to_owned(),
1239 },
1240 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
1241 WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 1 },
1242 WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes: 1 },
1243 WorkspaceDiagnosticKind::SkippedSourceDotdir,
1244 WorkspaceDiagnosticKind::SourceReadFailure {
1245 error: "permission denied".to_owned(),
1246 },
1247 ];
1248 for kind in &other {
1249 assert!(
1250 !kind.is_analysis_stage(),
1251 "{} is a discovery kind, not an analyze-stage kind",
1252 kind.id()
1253 );
1254 }
1255 }
1256
1257 #[test]
1258 fn merge_keeps_two_diagnostics_that_share_a_kind_id_and_path() {
1259 let root = Path::new("/project");
1260 let first = WorkspaceDiagnostic::new(
1261 root,
1262 root.join("packages/aaa"),
1263 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1264 pattern: "packages/*".to_owned(),
1265 },
1266 );
1267 let second = WorkspaceDiagnostic::new(
1268 root,
1269 root.join("packages/aaa"),
1270 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1271 pattern: "packages/a*".to_owned(),
1272 },
1273 );
1274
1275 let merged =
1276 merge_workspace_diagnostics(vec![first.clone(), second.clone()], vec![first, second]);
1277
1278 let patterns: Vec<String> = merged
1279 .iter()
1280 .map(|diagnostic| match &diagnostic.kind {
1281 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => pattern.clone(),
1282 other => panic!("unexpected kind {}", other.id()),
1283 })
1284 .collect();
1285 assert_eq!(
1286 patterns,
1287 ["packages/*", "packages/a*"],
1288 "two overlapping globs report the same directory twice, with their own pattern; \
1289 the same entry seen from two observation points still folds to one"
1290 );
1291 }
1292
1293 /// Issue #2366: a repository that declares one glob in two manifests
1294 /// (`"./apps/**"` in `package.json`, `apps/**` in `pnpm-workspace.yaml`)
1295 /// must not report every package-less directory under it twice now that the
1296 /// payload is part of the dedupe key.
1297 #[test]
1298 fn merge_folds_two_spellings_of_one_glob_into_one_diagnostic() {
1299 let root = Path::new("/project");
1300 let dotted = WorkspaceDiagnostic::new(
1301 root,
1302 root.join("apps/site/.next/cache"),
1303 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1304 pattern: "./apps/**".to_owned(),
1305 },
1306 );
1307 let bare = WorkspaceDiagnostic::new(
1308 root,
1309 root.join("apps/site/.next/cache"),
1310 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1311 pattern: "apps/**".to_owned(),
1312 },
1313 );
1314 assert_eq!(
1315 dotted.kind, bare.kind,
1316 "the no-op ./ prefix is normalised out of the recorded pattern"
1317 );
1318 assert!(
1319 dotted.message.contains("Glob 'apps/**'"),
1320 "the message renders the normalised pattern: {}",
1321 dotted.message
1322 );
1323
1324 let merged = merge_workspace_diagnostics(vec![dotted], vec![bare]);
1325 assert_eq!(
1326 merged.len(),
1327 1,
1328 "one glob declared twice is one diagnostic: {merged:?}"
1329 );
1330 }
1331
1332 /// A glob spelled exactly `"./"` (the project root itself) is the one
1333 /// pattern the prefix strip must leave alone: an empty `pattern` field
1334 /// names no glob, and the warning would quote nothing.
1335 #[test]
1336 fn new_keeps_a_root_only_glob_spelling_and_still_strips_a_real_prefix() {
1337 let root = Path::new("/project");
1338 let recorded = |pattern: &str| {
1339 let diagnostic = WorkspaceDiagnostic::new(
1340 root,
1341 root.join("pkgs"),
1342 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1343 pattern: pattern.to_owned(),
1344 },
1345 );
1346 let WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } = diagnostic.kind
1347 else {
1348 panic!("constructed a glob-matched-no-package-json diagnostic");
1349 };
1350 (pattern, diagnostic.message)
1351 };
1352
1353 let (root_pattern, root_message) = recorded("./");
1354 assert_eq!(root_pattern, "./", "a root-only glob keeps its spelling");
1355 assert!(
1356 root_message.contains("Glob './'"),
1357 "the warning names the glob the manifest declared: {root_message}"
1358 );
1359 assert_eq!(recorded(".\\").0, ".\\");
1360 assert_eq!(recorded("./pkgs/*").0, "pkgs/*");
1361 assert_eq!(recorded(".\\pkgs\\*").0, "pkgs\\*");
1362 }
1363
1364 /// Issue #2366, the path half of the same repository shape: expanding
1365 /// `./pkgs/*` joins the no-op `.` into every match, so the two manifests
1366 /// hand one directory to the diagnostic under two spellings. Both must
1367 /// store, render and serialise as the bare one, otherwise whichever
1368 /// manifest was read first decides whether the analysis envelopes print
1369 /// `./pkgs/aaa` while the workspace listing envelope prints `pkgs/aaa`.
1370 #[test]
1371 fn new_stores_one_spelling_for_a_directory_reached_through_a_dotted_glob() {
1372 let root = Path::new("/project");
1373 let dotted = WorkspaceDiagnostic::new(
1374 root,
1375 root.join("./pkgs/aaa"),
1376 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1377 pattern: "./pkgs/*".to_owned(),
1378 },
1379 );
1380 let bare = WorkspaceDiagnostic::new(
1381 root,
1382 root.join("pkgs/aaa"),
1383 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1384 pattern: "pkgs/*".to_owned(),
1385 },
1386 );
1387
1388 let spelling = |diagnostic: &WorkspaceDiagnostic| {
1389 diagnostic.path.display().to_string().replace('\\', "/")
1390 };
1391 assert_eq!(
1392 spelling(&dotted),
1393 "/project/pkgs/aaa",
1394 "the stored path drops the no-op . component, which Path equality \
1395 hides but serialization does not"
1396 );
1397 assert_eq!(spelling(&dotted), spelling(&bare));
1398 assert_eq!(
1399 spelling(&dotted.clone().into_root_relative(root)),
1400 "pkgs/aaa"
1401 );
1402
1403 let merged = merge_workspace_diagnostics(vec![dotted], vec![bare]);
1404 assert_eq!(
1405 merged.len(),
1406 1,
1407 "one directory reached through two spellings of one glob: {merged:?}"
1408 );
1409 }
1410
1411 /// The single-list fold applied at workspace discovery keeps one entry per
1412 /// `(kind, path)` and leaves distinct payloads alone.
1413 #[test]
1414 fn dedupe_keeps_first_of_each_pair_and_every_distinct_payload() {
1415 let root = Path::new("/project");
1416 let glob = |pattern: &str, relative: &str| {
1417 WorkspaceDiagnostic::new(
1418 root,
1419 root.join(relative),
1420 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1421 pattern: pattern.to_owned(),
1422 },
1423 )
1424 };
1425
1426 let deduped = dedupe_workspace_diagnostics(vec![
1427 glob("pkgs/*", "pkgs/aaa"),
1428 glob("pkgs/*", "pkgs/bbb"),
1429 glob("./pkgs/*", "./pkgs/aaa"),
1430 glob("pkgs/a*", "pkgs/aaa"),
1431 ]);
1432
1433 let reported: Vec<(String, String)> = deduped
1434 .iter()
1435 .map(|diagnostic| match &diagnostic.kind {
1436 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => (
1437 pattern.clone(),
1438 diagnostic.path.display().to_string().replace('\\', "/"),
1439 ),
1440 other => panic!("unexpected kind {}", other.id()),
1441 })
1442 .collect();
1443
1444 assert_eq!(
1445 reported,
1446 vec![
1447 ("pkgs/*".to_owned(), "/project/pkgs/aaa".to_owned()),
1448 ("pkgs/*".to_owned(), "/project/pkgs/bbb".to_owned()),
1449 ("pkgs/a*".to_owned(), "/project/pkgs/aaa".to_owned()),
1450 ],
1451 "the duplicate spelling folds away and the overlapping glob stays"
1452 );
1453 }
1454
1455 /// The class `reachability_caveats[]` is computed from. Every kind here
1456 /// means the run never read a file that is part of the project, so its
1457 /// imports credit nothing and the modules it imports can be reported
1458 /// unused with a removal action on them. Classifying a kind `true` is the
1459 /// only wiring its findings need to inherit the caveat and the `fallow fix`
1460 /// withholding that follows it.
1461 #[test]
1462 fn source_never_analyzed_covers_every_file_the_run_did_not_read() {
1463 for kind in [
1464 WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 1 },
1465 WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes: 1 },
1466 WorkspaceDiagnosticKind::SkippedSourceDotdir,
1467 WorkspaceDiagnosticKind::SourceReadFailure {
1468 error: "permission denied".to_owned(),
1469 },
1470 ] {
1471 assert!(
1472 kind.source_never_analyzed(),
1473 "{} names a source file this run never read",
1474 kind.id()
1475 );
1476 }
1477
1478 let degraded = WorkspaceDiagnosticKind::SourceParseDegraded {
1479 error_count: 3,
1480 panicked: false,
1481 };
1482 assert!(
1483 !degraded.source_never_analyzed(),
1484 "a degraded parse read the file, so it has a graph node and its reachability is \
1485 observable; the caveat pass narrows it instead of treating it as unread"
1486 );
1487
1488 for kind in [
1489 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1490 WorkspaceDiagnosticKind::MalformedPackageJson {
1491 error: "trailing comma".to_owned(),
1492 },
1493 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1494 pattern: "packages/*".to_owned(),
1495 },
1496 WorkspaceDiagnosticKind::MalformedTsconfig {
1497 error: "unexpected token".to_owned(),
1498 },
1499 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
1500 WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml {
1501 error: "bad indent".to_owned(),
1502 },
1503 WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
1504 WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
1505 WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
1506 WorkspaceDiagnosticKind::NodeModulesMissing,
1507 WorkspaceDiagnosticKind::BoundariesNotConfigured,
1508 WorkspaceDiagnosticKind::RulePacksNotConfigured,
1509 ] {
1510 assert!(
1511 !kind.source_never_analyzed(),
1512 "{} says nothing about a source file's imports going unseen",
1513 kind.id()
1514 );
1515 }
1516 }
1517
1518 #[test]
1519 fn source_walk_recorded_covers_only_the_kinds_a_walk_replaces() {
1520 for kind in [
1521 WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 1 },
1522 WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes: 1 },
1523 WorkspaceDiagnosticKind::SkippedSourceDotdir,
1524 ] {
1525 assert!(
1526 kind.is_source_walk_recorded() && kind.is_source_discovery(),
1527 "{} is written by the source walk",
1528 kind.id()
1529 );
1530 }
1531
1532 let read_failure = WorkspaceDiagnosticKind::SourceReadFailure {
1533 error: "permission denied".to_owned(),
1534 };
1535 assert!(
1536 read_failure.is_source_discovery() && !read_failure.is_source_walk_recorded(),
1537 "the parse stage records source-read-failure after the walk, so it must keep \
1538 reaching sessions through the registry"
1539 );
1540
1541 for kind in [
1542 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1543 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
1544 WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
1545 WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
1546 WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
1547 ] {
1548 assert!(
1549 !kind.is_source_walk_recorded(),
1550 "{} is not written by the source walk",
1551 kind.id()
1552 );
1553 }
1554 }
1555
1556 /// Issue #2638, the single most load-bearing classification in the new
1557 /// kind. Answering `true` here would attach `IncompleteFileAnalysis` and
1558 /// `IncompleteImportGraph` caveats to findings on nearly every project
1559 /// that keeps a non-gitignored `dist/` or `coverage/`, and make
1560 /// `fallow fix` withhold `delete-file` and `remove-export` project-wide.
1561 /// A built-in exclusion is designed behavior on generated output, not a
1562 /// degraded run.
1563 #[test]
1564 fn a_built_in_ignore_exclusion_is_not_a_file_the_run_failed_to_analyze() {
1565 let kind = WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
1566 pattern: "**/build/**".to_owned(),
1567 file_count: 3,
1568 directory_count: 1,
1569 };
1570 assert!(!kind.source_never_analyzed());
1571 }
1572
1573 /// Issue #2638: these exclusions fire in the product's default state on
1574 /// most monorepos, so a default stderr line would be permanent noise that
1575 /// names no defect. The CLI prints a note under `--explain-skipped`
1576 /// instead.
1577 #[test]
1578 fn a_built_in_ignore_exclusion_does_not_warn_on_stderr_by_default() {
1579 let kind = WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
1580 pattern: "**/build/**".to_owned(),
1581 file_count: 3,
1582 directory_count: 1,
1583 };
1584 assert!(!kind.warns_on_stderr());
1585 }
1586
1587 /// Issue #2638 plus issue #2366: the walk writes it, so it has to be
1588 /// classified as source-discovery (or combined mode's per-analysis config
1589 /// reloads wipe it before serialization) AND as walk-recorded (or a
1590 /// concurrent walk's tally is folded into another analysis's list).
1591 #[test]
1592 fn a_built_in_ignore_exclusion_is_walk_recorded_source_discovery() {
1593 let kind = WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
1594 pattern: "**/build/**".to_owned(),
1595 file_count: 3,
1596 directory_count: 1,
1597 };
1598 assert!(kind.is_source_discovery());
1599 assert!(kind.is_source_walk_recorded());
1600 assert!(!kind.is_analysis_stage());
1601 assert_eq!(kind.id(), "excluded-by-default-ignore");
1602 }
1603
1604 /// Issue #2638: the message has to name the pattern the reader cannot see,
1605 /// the directory, and the only remedy that actually analyzes the tree.
1606 /// `ignorePatterns` is not that remedy: the compiled set unions, so it
1607 /// cannot negate a built-in.
1608 #[test]
1609 fn a_built_in_ignore_exclusion_message_names_the_pattern_and_the_root_remedy() {
1610 let root = Path::new("/project");
1611 let diag = WorkspaceDiagnostic::new(
1612 root,
1613 root.join("packages/web/build"),
1614 WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
1615 pattern: "**/build/**".to_owned(),
1616 file_count: 4,
1617 directory_count: 1,
1618 },
1619 );
1620 assert!(diag.message.contains("**/build/**"), "{}", diag.message);
1621 assert!(
1622 diag.message.contains("packages/web/build"),
1623 "{}",
1624 diag.message
1625 );
1626 assert!(
1627 diag.message.contains("fallow --root packages/web/build"),
1628 "the remedy is copy-pasteable: {}",
1629 diag.message
1630 );
1631 assert!(
1632 diag.message
1633 .contains("cannot be switched off through ignorePatterns"),
1634 "the message must not advertise a negation that does not exist: {}",
1635 diag.message
1636 );
1637 }
1638
1639 /// One excluded file reads as one file, not as "1 source files".
1640 #[test]
1641 fn a_single_excluded_file_message_is_singular() {
1642 let root = Path::new("/project");
1643 let diag = WorkspaceDiagnostic::new(
1644 root,
1645 root.join("dist"),
1646 WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
1647 pattern: "**/dist/**".to_owned(),
1648 file_count: 1,
1649 directory_count: 1,
1650 },
1651 );
1652 assert!(
1653 diag.message
1654 .starts_with("Skipped 1 source file under 'dist'"),
1655 "{}",
1656 diag.message
1657 );
1658 assert!(diag.message.contains("it matches"), "{}", diag.message);
1659 assert!(
1660 diag.message
1661 .contains("nothing it imports, exports, or defines"),
1662 "the whole sentence agrees in number, not just its first clause: {}",
1663 diag.message
1664 );
1665 }
1666
1667 /// The anchor directory is the largest group, never a majority: ten
1668 /// packages each holding one excluded file make every one of them "the
1669 /// largest", and a message claiming otherwise is false on exactly the flat
1670 /// monorepo shape issue #2638 is about.
1671 #[test]
1672 fn a_scattered_exclusion_names_the_largest_group_and_counts_the_directories() {
1673 let root = Path::new("/project");
1674 let diag = WorkspaceDiagnostic::new(
1675 root,
1676 root.join("packages/a/dist"),
1677 WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
1678 pattern: "**/dist/**".to_owned(),
1679 file_count: 10,
1680 directory_count: 10,
1681 },
1682 );
1683 assert!(
1684 diag.message.starts_with(
1685 "Skipped 10 source files across 10 directories, the largest group under \
1686 'packages/a/dist'"
1687 ),
1688 "{}",
1689 diag.message
1690 );
1691 assert!(
1692 !diag.message.contains("the most of them"),
1693 "a max-of-group is not a majority: {}",
1694 diag.message
1695 );
1696 }
1697
1698 /// A file-shaped built-in matches on the file name, so the `--root` remedy
1699 /// the directory-shaped patterns get would re-exclude the same file. The
1700 /// message must not print a command that provably does nothing.
1701 #[test]
1702 fn a_file_shaped_pattern_does_not_advertise_the_root_remedy() {
1703 let root = Path::new("/project");
1704 let diag = WorkspaceDiagnostic::new(
1705 root,
1706 root.join("vendor"),
1707 WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
1708 pattern: "**/*.min.js".to_owned(),
1709 file_count: 2,
1710 directory_count: 1,
1711 },
1712 );
1713 assert!(
1714 !diag.message.contains("fallow --root"),
1715 "the message explains why re-rooting fails, it does not prescribe it: {}",
1716 diag.message
1717 );
1718 assert!(
1719 diag.message.contains("matches a file name"),
1720 "the message says why: {}",
1721 diag.message
1722 );
1723 assert!(
1724 diag.message.contains("Rename"),
1725 "and names the remedy that does work: {}",
1726 diag.message
1727 );
1728 }
1729
1730 /// A built-in that matched a file sitting directly at the analysis root
1731 /// anchors at the root, and an empty string is not a location.
1732 #[test]
1733 fn a_root_anchored_exclusion_renders_its_location_as_dot() {
1734 let root = Path::new("/project");
1735 let diag = WorkspaceDiagnostic::new(
1736 root,
1737 root.to_path_buf(),
1738 WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
1739 pattern: "**/*.min.js".to_owned(),
1740 file_count: 1,
1741 directory_count: 1,
1742 },
1743 );
1744 assert!(
1745 diag.message.starts_with("Skipped 1 source file under '.'"),
1746 "{}",
1747 diag.message
1748 );
1749 }
1750
1751 #[test]
1752 fn glob_first_literal_segment_skips_wildcards_and_dot_components() {
1753 assert_eq!(glob_first_literal_segment("**/build/**"), Some("build"));
1754 assert_eq!(glob_first_literal_segment("./dist/**"), Some("dist"));
1755 assert_eq!(glob_first_literal_segment("**/*.min.js"), None);
1756 assert_eq!(glob_first_literal_segment("**/*.bundle.js"), None);
1757 assert_eq!(glob_first_literal_segment("**/{a,b}/**"), None);
1758 }
1759
1760 #[test]
1761 fn format_size_mb_one_decimal() {
1762 assert_eq!(format_size_mb(0), "0.0 MB");
1763 assert_eq!(format_size_mb(5 * 1024 * 1024), "5.0 MB");
1764 assert_eq!(format_size_mb(1024 * 1024 + 512 * 1024), "1.5 MB");
1765 }
1766
1767 #[test]
1768 fn undeclared_workspace_message_has_next_step() {
1769 let root = Path::new("/project");
1770 let diag = WorkspaceDiagnostic::new(
1771 root,
1772 root.join("packages/legacy"),
1773 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1774 );
1775 assert_eq!(diag.kind.id(), "undeclared-workspace");
1776 assert!(diag.message.contains("packages/legacy"), "{}", diag.message);
1777 assert!(
1778 diag.message.contains("ignorePatterns"),
1779 "next-step hint preserved: {}",
1780 diag.message
1781 );
1782 }
1783}