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::path_util::display_relative;
22use crate::serde_path;
23
24/// Why a workspace-discovery candidate was rejected, or why a sibling
25/// directory looked workspace-like but was not declared.
26///
27/// Wire-format names are kebab-case so JSON consumers (CI integrations, MCP
28/// agents, LSP clients) get a stable, language-neutral identifier.
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
30#[cfg_attr(feature = "schema", derive(JsonSchema))]
31#[serde(tag = "kind", rename_all = "kebab-case")]
32pub enum WorkspaceDiagnosticKind {
33 /// A directory contains `package.json` but is not declared as a workspace
34 /// in `package.json` `workspaces`, `pnpm-workspace.yaml`, or
35 /// `tsconfig.json` `references`. Surfaced by
36 /// `find_undeclared_workspaces`.
37 UndeclaredWorkspace,
38 /// A declared workspace's `package.json` failed to parse. The directory is
39 /// dropped from discovery, but analysis still proceeds (degraded).
40 MalformedPackageJson {
41 /// `serde_json` parse error text.
42 error: String,
43 },
44 /// A workspace glob pattern matched a directory that contains no
45 /// `package.json`. Honors the extended skip list and `ignorePatterns`
46 /// before emitting.
47 GlobMatchedNoPackageJson {
48 /// The glob pattern that matched the directory.
49 pattern: String,
50 },
51 /// `tsconfig.json` exists at the root but failed to parse. Project
52 /// references cannot be discovered.
53 MalformedTsconfig {
54 /// JSONC parse error text.
55 error: String,
56 },
57 /// `tsconfig.json` lists a `references[].path` that does not point to an
58 /// existing directory.
59 TsconfigReferenceDirMissing,
60 /// `pnpm-workspace.yaml` exists but failed to parse as YAML. Catalog and
61 /// dependency-override analysis proceeds with no entries (degraded), so
62 /// `catalog:`-referenced dependencies may be misclassified until the
63 /// syntax is fixed.
64 MalformedPnpmWorkspaceYaml {
65 /// `serde_yaml_ng` parse error text.
66 error: String,
67 },
68 /// A source file was skipped at discovery because it exceeds the configured
69 /// per-file size limit (`--max-file-size` / `FALLOW_MAX_FILE_SIZE`, default
70 /// 5 MB). The file is never read, parsed, or analyzed, guarding against the
71 /// out-of-memory blowup a single multi-MB generated/vendored/bundled file
72 /// causes (issue #1086). Surfaced by source discovery, not workspace
73 /// discovery, but shares this channel so the skip is visible in
74 /// `workspace_diagnostics[]` on `fallow dead-code / dupes / health` JSON.
75 SkippedLargeFile {
76 /// On-disk size of the skipped file in bytes.
77 size_bytes: u64,
78 },
79 /// A large JavaScript bundle was skipped at discovery because it appears to
80 /// be minified generated output. The file is never parsed or analyzed,
81 /// guarding against sub-limit bundles that can still create very large ASTs
82 /// and extraction payloads (issue #1086). Use `--max-file-size 0` when the
83 /// bundled file really should be analyzed.
84 SkippedMinifiedFile {
85 /// On-disk size of the skipped file in bytes.
86 size_bytes: u64,
87 },
88 /// A dot-prefixed directory was not traversed by source discovery even
89 /// though it contains at least one source file the project has not
90 /// excluded. Hidden directories are skipped by default apart from a small
91 /// convention allowlist (`.storybook`, `.vitepress`, `.well-known`,
92 /// `.changeset`, `.github`) and the directories an active framework plugin
93 /// or a `package.json` script reference contributes, so files inside are
94 /// never parsed and their imports and exports are invisible to every
95 /// analysis. A file, export or dependency that only the directory uses can
96 /// be reported as unused. No config field adds a directory to traversal:
97 /// add the file to `entry`, the export to `ignoreExports` or the dependency
98 /// to `ignoreDependencies` to stop that false positive, or add the
99 /// directory to `ignorePatterns` to silence this (issue #461). Running
100 /// fallow with `--root` against the directory analyzes it on its own and
101 /// does not fix the main run (issue #2797).
102 ///
103 /// "Not excluded" is measured the way the run measures it: a directory
104 /// whose contents are gitignored, or excluded by `ignorePatterns`, or (on
105 /// a `--production` run) excluded as test or story files, never earns this
106 /// diagnostic, because the advertised remedies would find nothing there
107 /// either. Generated tool output and non-git VCS metadata are excluded by
108 /// name.
109 ///
110 /// The advisory is best-effort and bounded: one run inspects a fixed
111 /// number of skipped directories with a fixed I/O budget, in sorted path
112 /// order, so a pathological tree yields a deterministic prefix rather than
113 /// an unbounded array or an unbounded scan. The stderr note says "at
114 /// least" when a ceiling bound the run.
115 ///
116 /// Surfaced by source discovery, not workspace discovery, but shares this
117 /// channel so the skip is visible in `workspace_diagnostics[]` on
118 /// `fallow dead-code / dupes / health` JSON.
119 ///
120 /// Unlike the two skipped-file kinds beside it, this one is CAPPED. To
121 /// bound the directory reads the check costs, a run classifies at most 64
122 /// candidate directories and spends at most 1024 directory entries across
123 /// all of them, so on a project that exceeds either ceiling the array is a
124 /// prefix of the skipped directories rather than all of them, and the
125 /// stderr note says "at least N". No measured repository comes close to
126 /// either ceiling. A consumer needing an exact total should run fallow
127 /// with `--root` against the tree rather than infer one from this array.
128 SkippedSourceDotdir,
129 /// A source discovered with a stable [`FileId`](crate::discover::FileId)
130 /// could not be read before parsing. Analysis continues with the remaining
131 /// sparse module IDs and reports the underlying filesystem or UTF-8 error.
132 SourceReadFailure {
133 /// Filesystem or UTF-8 decoding error from `read_to_string`.
134 error: String,
135 },
136 /// A source file was read but parsed with diagnostics, so the module
137 /// extracted from it may be missing imports, exports, or references after
138 /// the first error. Analysis proceeds with the partial module, which is why
139 /// this is reported: an import the parser never saw credits nothing, and its
140 /// target can surface as a confident `unused-file` or `unused-export`
141 /// finding with a `delete-file` or `remove-export` action on it.
142 ///
143 /// Recorded by the parse stage, alongside `source-read-failure`, and never
144 /// used to withhold a finding. oxc reports recoverable errors for valid
145 /// syntax newer than the parser as well as for genuinely broken files, so
146 /// gating findings on this would mute real results project-wide instead of
147 /// just the affected file.
148 SourceParseDegraded {
149 /// Number of parser diagnostics reported for the file.
150 error_count: u32,
151 /// `true` when the parser abandoned the file instead of recovering, so
152 /// the extracted module is a fragment at best.
153 panicked: bool,
154 },
155 /// Dependency-override resolution was skipped because bun's legacy binary
156 /// `bun.lockb` sits next to this `package.json`, fallow cannot read the
157 /// binary format, and no parseable text lockfile was found to use
158 /// instead: no `bun.lock` that parses, and no readable `pnpm-lock.yaml`,
159 /// `package-lock.json`, or `npm-shrinkwrap.json`. A `yarn.lock` is never
160 /// consulted (yarn ignores `overrides`), so it does not prevent the skip
161 /// either. The manifest declares overrides, so the
162 /// `unused-dependency-overrides` check would otherwise have run; without
163 /// resolution ground truth it would flag every transitive-only pin, so no
164 /// unused-override findings are reported at all (issue #2358). Surfaced
165 /// by the override analysis, not workspace discovery, but shares this
166 /// channel so the skip is visible in `workspace_diagnostics[]` JSON and
167 /// as a stderr warning.
168 BunLockbOverrideResolutionSkipped,
169 /// Dependency-override resolution was skipped because bun's text
170 /// `bun.lock` exists but could not be parsed and no readable pnpm or npm
171 /// lockfile was available as independent resolution ground truth.
172 BunLockOverrideResolutionSkipped,
173 /// A bun manifest declares both `overrides` and a non-empty `resolutions`
174 /// object. Bun applies `overrides` and ignores `resolutions`, so fallow
175 /// reports the shadowed configuration without offering removal advice.
176 BunResolutionsShadowedByOverrides,
177 /// The project has no `node_modules` directory and is not a Deno project
178 /// that legitimately runs without one. Analysis proceeds, but three things
179 /// degrade silently: package `exports` and conditional exports cannot be
180 /// read, so imports into a dependency's subpaths resolve less precisely;
181 /// framework plugins that activate on an installed package stay inactive,
182 /// so their entry points and path aliases are missing; and a dependency's
183 /// installed shape cannot be inspected, so type-only dependency
184 /// classification falls back to declaration-based heuristics.
185 ///
186 /// Recorded once per run by the source walk, anchored at the missing
187 /// `node_modules` directory so the reported path is a real location rather
188 /// than the empty string a root-anchored diagnostic would render. This used
189 /// to be a bare `tracing::warn!` duplicated in two pipelines, so it never
190 /// reached JSON output and never reached `fallow doctor`, which reported
191 /// `pass` on a tree that had never been installed.
192 NodeModulesMissing,
193 /// `boundaries` is empty while `boundary-violation` is not `off`, so the
194 /// boundary detector never ran. Its summary counters are therefore
195 /// structurally zero and say nothing about the project.
196 ///
197 /// This is the UNCONFIGURED zero, not the user-chosen one: a project that
198 /// sets `boundary-violation: off` asked for silence and can see that
199 /// choice in `fallow config`. A project that left `boundaries` empty
200 /// cannot distinguish "no violations" from "nothing was measured".
201 BoundariesNotConfigured,
202 /// `rulePacks` is empty while `policy-violation` is not `off`, so the
203 /// policy detector never ran and its summary counters are structurally
204 /// zero. The unconfigured counterpart of
205 /// [`Self::BoundariesNotConfigured`].
206 RulePacksNotConfigured,
207 /// One of fallow's built-in discovery ignore patterns (`**/dist/**`,
208 /// `**/build/**`, `**/coverage/**`, and the four minified-bundle globs)
209 /// removed at least one candidate source file from this walk. The files
210 /// are never read, so their imports and exports are invisible to every
211 /// analysis, and until issue #2638 the drop was completely silent:
212 /// pointing fallow at a directory a built-in pattern matches returned a
213 /// clean report with exit 0 and nothing said why.
214 ///
215 /// `**/node_modules/**` is carved out and never appears in `pattern`:
216 /// installed dependencies are not the first-party source this diagnostic
217 /// is about, and a project that does not gitignore them would get a
218 /// five-figure count with no useful remedy. `**/.git/**` cannot fire,
219 /// because hidden directories are not traversed.
220 ///
221 /// One entry per pattern, never per file or per directory, so the array
222 /// grows by at most the number of built-in patterns on a project of any
223 /// size. `path` anchors at the matched directory holding the most excluded
224 /// files for that pattern, ties broken by the lexicographically first
225 /// path, so two runs on one tree report the same location. On a nested
226 /// match it is the DEEPEST segment the pattern matched
227 /// (`build/tools/build`, not `build`), because that is the directory the
228 /// `--root` remedy names and re-rooting at a shallower one would leave a
229 /// matching segment behind. That directory is the
230 /// largest group and not a majority: a flat monorepo can spread ten
231 /// excluded files over ten sibling `dist/` directories and every one of
232 /// them is then "the largest". `file_count` spans all of them, and
233 /// `directory_count` says how many there were, so a reader can tell a
234 /// single tree from a scattered one without a directory list in the
235 /// payload.
236 ///
237 /// Three properties of the population are load-bearing and easy to
238 /// misread:
239 ///
240 /// - **Gitignored trees count zero.** Source discovery honors
241 /// `.gitignore`, `.git/info/exclude`, and the global gitignore, and
242 /// prunes those directories before this check runs. The honest reading
243 /// is "candidate source files git did not already hide and a built-in
244 /// pattern then dropped", which is why a repository that gitignores its
245 /// own `dist/` never sees this diagnostic.
246 /// - **A user `ignorePatterns` entry is not a surprise.** The compiled
247 /// ignore set is the union of `ignorePatterns` and the built-ins, so a
248 /// file both matched was an explicit project choice and is attributed to
249 /// no pattern here. The union also only ever adds: `ignorePatterns`
250 /// cannot negate a built-in, so a config edit is never the remedy.
251 /// - **The remedy depends on the pattern's shape.** A directory-shaped
252 /// built-in (`**/dist/**`) is matched against the path relative to the
253 /// run root, so re-rooting inside the matched directory removes the
254 /// matched segment and the files become visible: the message advertises
255 /// `fallow --root <dir>`. A file-shaped built-in (`**/*.min.js` and the
256 /// three other bundle globs) matches on the file name and keeps matching
257 /// at any root, so the message says so and points at renaming instead of
258 /// handing out a command that provably does nothing.
259 ///
260 /// Deliberately NOT one of the [`Self::source_never_analyzed`] kinds. These
261 /// exclusions are the product's designed behavior on generated output, not
262 /// a degraded run: answering `true` would attach `IncompleteFileAnalysis`
263 /// and `IncompleteImportGraph` caveats to findings on nearly every project
264 /// that keeps a non-gitignored `dist/` or `coverage/`, and make `fallow
265 /// fix` withhold `delete-file` and `remove-export` actions project-wide.
266 ExcludedByDefaultIgnore {
267 /// The built-in glob that matched, verbatim (for example
268 /// `**/build/**`).
269 pattern: String,
270 /// Candidate source files this pattern excluded in this walk, across
271 /// every directory it matched, not just the one `path` anchors at.
272 /// Exact: the walk counts each excluded candidate once.
273 file_count: u32,
274 /// Distinct directories this pattern matched at, `path` included, and
275 /// not the number of directories that held the files. A
276 /// directory-shaped pattern (`**/dist/**`) matches at the directory it
277 /// names, so an excluded subtree counts once however many nested
278 /// directories inside it held source: a `dist/` holding files in three
279 /// sub-directories reports `1`. A file-shaped pattern (`**/*.min.js`)
280 /// has no directory to collapse to and counts each matched file's own
281 /// parent. Exact either way, and anything above `1` says `path` names
282 /// one matched location out of several.
283 directory_count: u32,
284 },
285 /// The walk finished with no source file to analyze at all, so every
286 /// finding count this run reports is zero because nothing was measured
287 /// rather than because the project is clean (issue #2686).
288 ///
289 /// Distinct from [`Self::ExcludedByDefaultIgnore`], which reports one
290 /// pattern's exclusions and is designed behavior on generated output. The
291 /// alarm is not the exclusion, it is having nothing left afterwards, and
292 /// that condition also fires with no exclusion at all: a docs-only
293 /// repository, a workspace member with no TypeScript, or a path filter that
294 /// matched nothing. `excluded_file_count` names the built-in-ignore
295 /// contribution so the common cause is still attributable, and is `0` when
296 /// no built-in pattern took part.
297 ///
298 /// This is the kind a CI consumer reads to tell "measured zero" from
299 /// "measured nothing": the human report has said so since 3.26.0, but only
300 /// in human format and only under the built-in-ignore cause, so `--quiet
301 /// --format json` saw a clean green either way.
302 NoSourceFilesAnalyzed {
303 /// Candidate source files the built-in ignore patterns removed from
304 /// this walk, summed across every pattern. `0` when the walk found no
305 /// candidate to exclude in the first place.
306 excluded_file_count: u32,
307 },
308 /// Per-file health scoring failed, so the score list is empty and the
309 /// scored-file count is `0` because nothing was measured rather than
310 /// because the project has no files worth scoring. Every score-derived
311 /// number (the average maintainability index, the refactoring targets, the
312 /// hotspot complexity half) is then structurally zero (issue #2689).
313 FileScoresUnavailable {
314 /// Scoring error text.
315 error: String,
316 },
317 /// Churn-based hotspot analysis was skipped, so the hotspots, churn and
318 /// ownership sections report nothing at all. The remaining health sections
319 /// are unaffected.
320 HotspotsSkipped {
321 /// Which input stopped it, as a kebab-case token: `not-a-repository`,
322 /// `no-commits`, `invalid-since` or `churn-file-unreadable`. The set is
323 /// open.
324 ///
325 /// The cause decides the remedy, which is why it is on the wire: a run
326 /// outside a repository is fixed by running fallow inside one, a
327 /// branch without a commit by committing, a malformed `--since` by
328 /// respelling the flag, and a churn file that changed under the run by
329 /// rerunning it. A consumer reading only the kind would offer the first
330 /// remedy for all four.
331 cause: String,
332 },
333 /// The repository is a shallow clone, so churn is measured over the fetched
334 /// history only and every hotspot figure is incomplete.
335 ShallowClone {
336 /// `true` when the run also asked for ownership attribution, which a
337 /// shallow clone skews further by inflating single-author dominance.
338 ownership_requested: bool,
339 },
340 /// No commit timestamp was available, so churn recency and ownership
341 /// staleness were measured against the wall clock and drift between two
342 /// runs over the same commit.
343 UnpinnedClock,
344 /// Ownership attribution was requested but its inputs did not load, so
345 /// hotspot entries carry degraded or absent owner signals.
346 OwnershipUnavailable {
347 /// Which input failed, as a kebab-case token: `invalid-bot-pattern` or
348 /// `codeowners-parse-failed`. The set is open.
349 cause: String,
350 /// Underlying error text.
351 error: String,
352 },
353 /// A saved health snapshot could not be read or parsed, so the trend is
354 /// computed over fewer snapshots than the project has on disk and a
355 /// direction can flip on the missing point alone.
356 TrendSnapshotUnreadable {
357 /// Filesystem or JSON error text.
358 error: String,
359 },
360 /// A framework plugin read a build config and could not read one of its
361 /// keys in full, so part of what the key declares never reached the
362 /// analysis. `path` names the config file.
363 ///
364 /// The reader is syntactic, so a key whose value is computed at build time
365 /// is invisible to it: a Module Federation `exposes: makeExposes()` or a
366 /// `remotes` map spread from an environment module declares entries this
367 /// run does not know about. The consequence is a finding, not a missing
368 /// number: an unread `exposes` target is not registered as an entry point
369 /// and its file can surface as `unused-file`, and an unread `remotes` alias
370 /// is not treated as provided by a remote container and its import can
371 /// surface as an unlisted dependency.
372 ///
373 /// Recorded by the plugin stage, which runs before analysis and is not
374 /// cached, so the entry is present on a warm cache too. It used to be a
375 /// bare `tracing::warn!` from inside the plugin, so it reached no envelope
376 /// and no CI consumer (issue #2736).
377 ///
378 /// A source file that calls the Module Federation runtime API gets the same
379 /// entry: `path` names the source file, `key` names the runtime function
380 /// (`registerRemotes`, `loadRemote`, `init`, `createInstance`) and
381 /// `reason` is `dynamic-argument` when the call receives a value that is
382 /// not a static literal. The analysis records it from the facts of the
383 /// parse, which a warm cache restores (issue #2795). A `.vue` or `.svelte`
384 /// file gets it for a call in its `<script>` blocks (issue #2876).
385 PluginConfigUnreadable {
386 /// The plugin that read the config, as it labels itself:
387 /// `module-federation` for a standalone `module-federation.config.*`,
388 /// or the bundler plugin (`webpack`, `rspack`, `rsbuild`, `vite`) that
389 /// read the same options inline from its own config.
390 plugin: String,
391 /// The config key that was present and not fully readable (`exposes`,
392 /// `remotes`), or the Module Federation runtime function whose
393 /// argument was not readable (`registerRemotes`, `loadRemote`, `init`,
394 /// `createInstance`). The set is open.
395 key: String,
396 /// Why it could not be read, as a kebab-case token:
397 /// `not-object-literal`, `array-form`, `spread`,
398 /// `unreadable-entries`, `unrecognized-call`,
399 /// `import-target-unreadable` or `dynamic-argument`. The set is open.
400 ///
401 /// The reason decides the remedy, which is why it is on the wire: a
402 /// value that is not an object literal is fixed by writing one, while
403 /// unreadable entries are fixed by naming those entries in the config
404 /// option the message points at.
405 reason: String,
406 },
407 /// A framework plugin read a config key it understands, does not model
408 /// that key's effect, and therefore stood a modeled default down. `path`
409 /// names the config file.
410 ///
411 /// A file can also be the `path`: a Nuxt file that reads `#components` or
412 /// `#imports` in a way fallow cannot narrow to names, such as a spread of
413 /// a namespace import, has `key` set to that module and `reason` set to
414 /// `key-effect-not-modeled`. Every name of the module then counts as used.
415 ///
416 /// The Nuxt auto-import gate is the case this exists for. With
417 /// `autoImports` enabled fallow drops the Nuxt convention entry patterns
418 /// so a genuinely unreferenced convention file is reported, and a
419 /// `components:` or `imports:` block whose effect it cannot model keeps
420 /// them, which silently costs the user the findings they opted in for.
421 ///
422 /// Deliberately NOT one of the [`Self::warns_on_stderr`] kinds. Nothing
423 /// was lost that the run could have measured: the patterns stayed, so
424 /// findings are suppressed rather than invented, and a project in this
425 /// state would otherwise warn on every run forever with "write different
426 /// config" as the only remedy, which is the reason
427 /// `boundaries-not-configured` is off stderr as well.
428 PluginEffectNotModeled {
429 /// The plugin that read the config, as it labels itself (`nuxt`).
430 plugin: String,
431 /// The config key whose effect is not modeled (`components`,
432 /// `imports`), or the virtual module a file reads (`#components`,
433 /// `#imports`). The set is open.
434 key: String,
435 /// Why the effect is not modeled, as a kebab-case token:
436 /// `key-effect-not-modeled` when the key's own value is the reason,
437 /// `config-property-unreadable` when a top-level property of the same
438 /// config file could not be read statically, so no surface in it can
439 /// be classified at all. The set is open.
440 reason: String,
441 },
442 /// Test coverage was auto-detected on disk rather than passed with
443 /// `--coverage`, and `path` names the file that fed the CRAP scores.
444 ///
445 /// Deliberately NOT one of the [`Self::warns_on_stderr`] kinds: nothing
446 /// degraded, the run measured exactly what it found. It is provenance, and
447 /// it is on the wire because a score computed against a file the user did
448 /// not name is not reproducible and nothing else says which file it was.
449 CoverageAutoDetected,
450 /// `fallow flags --retirement` asked for flag age, but the repository is
451 /// a shallow clone. Blame and pickaxe see the fetched history only, so
452 /// every age would be too young. The report gives no age.
453 FlagAgeShallowClone,
454 /// `fallow flags --retirement` asked for flag age, but git history is
455 /// not available. The report gives no age.
456 FlagAgeUnavailable {
457 /// Why no history is available, as a kebab-case token:
458 /// `not-a-repository` or `no-commits`. The set is open.
459 cause: String,
460 },
461}
462
463impl WorkspaceDiagnosticKind {
464 /// Stable kebab-case identifier used in dedupe keys and tracing payloads.
465 #[must_use]
466 pub const fn id(&self) -> &'static str {
467 match self {
468 Self::UndeclaredWorkspace => "undeclared-workspace",
469 Self::MalformedPackageJson { .. } => "malformed-package-json",
470 Self::GlobMatchedNoPackageJson { .. } => "glob-matched-no-package-json",
471 Self::MalformedTsconfig { .. } => "malformed-tsconfig",
472 Self::TsconfigReferenceDirMissing => "tsconfig-reference-dir-missing",
473 Self::MalformedPnpmWorkspaceYaml { .. } => "malformed-pnpm-workspace-yaml",
474 Self::SkippedLargeFile { .. } => "skipped-large-file",
475 Self::SkippedMinifiedFile { .. } => "skipped-minified-file",
476 Self::SkippedSourceDotdir => "skipped-source-dotdir",
477 Self::SourceReadFailure { .. } => "source-read-failure",
478 Self::SourceParseDegraded { .. } => "source-parse-degraded",
479 Self::BunLockbOverrideResolutionSkipped => "bun-lockb-override-resolution-skipped",
480 Self::BunLockOverrideResolutionSkipped => "bun-lock-override-resolution-skipped",
481 Self::BunResolutionsShadowedByOverrides => "bun-resolutions-shadowed-by-overrides",
482 Self::NodeModulesMissing => "node-modules-missing",
483 Self::BoundariesNotConfigured => "boundaries-not-configured",
484 Self::RulePacksNotConfigured => "rule-packs-not-configured",
485 Self::ExcludedByDefaultIgnore { .. } => "excluded-by-default-ignore",
486 Self::NoSourceFilesAnalyzed { .. } => "no-source-files-analyzed",
487 Self::FileScoresUnavailable { .. } => "file-scores-unavailable",
488 Self::HotspotsSkipped { .. } => "hotspots-skipped",
489 Self::ShallowClone { .. } => "shallow-clone",
490 Self::UnpinnedClock => "unpinned-clock",
491 Self::OwnershipUnavailable { .. } => "ownership-unavailable",
492 Self::TrendSnapshotUnreadable { .. } => "trend-snapshot-unreadable",
493 Self::PluginConfigUnreadable { .. } => "plugin-config-unreadable",
494 Self::PluginEffectNotModeled { .. } => "plugin-effect-not-modeled",
495 Self::CoverageAutoDetected => "coverage-auto-detected",
496 Self::FlagAgeShallowClone => "flag-age-shallow-clone",
497 Self::FlagAgeUnavailable { .. } => "flag-age-unavailable",
498 }
499 }
500
501 /// Whether this diagnostic is worth a `tracing::warn!` line on stderr, on
502 /// top of its permanent entry in `workspace_diagnostics[]`.
503 ///
504 /// A warning is for a run whose RESULTS are degraded: something the user
505 /// installed, wrote, or expected did not reach the analysis. The two
506 /// unconfigured-check kinds are not that. They fire in the product's
507 /// default state, on every project that never opted into boundaries or
508 /// rule packs, and they will keep firing forever, because the remedy they
509 /// offer is to write configuration in order to silence a warning about not
510 /// having written configuration. They stay in the structured array, where a
511 /// consumer that wants to distinguish "measured zero" from "measured
512 /// nothing" can read them, and off the stderr surface that every other
513 /// command shares.
514 ///
515 /// `coverage-auto-detected` answers false for a third reason: it reports
516 /// the provenance of an input that DID load, so a consumer sentence about a
517 /// degraded run would state something untrue about it. Its own note is
518 /// printed by the health pipeline.
519 ///
520 /// `plugin-effect-not-modeled` answers false for the first reason: the
521 /// config was readable and nothing the run could have measured was lost,
522 /// so it would warn forever on a project whose `nuxt.config` fallow does
523 /// not model. Its sibling `plugin-config-unreadable` answers true, because
524 /// there a declaration the user wrote did not reach the analysis and
525 /// findings can be wrong in either direction.
526 #[must_use]
527 pub const fn warns_on_stderr(&self) -> bool {
528 match self {
529 Self::BoundariesNotConfigured
530 | Self::RulePacksNotConfigured
531 | Self::ExcludedByDefaultIgnore { .. }
532 | Self::PluginEffectNotModeled { .. }
533 | Self::CoverageAutoDetected => false,
534 Self::UndeclaredWorkspace
535 | Self::MalformedPackageJson { .. }
536 | Self::GlobMatchedNoPackageJson { .. }
537 | Self::MalformedTsconfig { .. }
538 | Self::TsconfigReferenceDirMissing
539 | Self::MalformedPnpmWorkspaceYaml { .. }
540 | Self::SkippedLargeFile { .. }
541 | Self::SkippedMinifiedFile { .. }
542 | Self::SkippedSourceDotdir
543 | Self::SourceReadFailure { .. }
544 | Self::SourceParseDegraded { .. }
545 | Self::BunLockbOverrideResolutionSkipped
546 | Self::BunLockOverrideResolutionSkipped
547 | Self::BunResolutionsShadowedByOverrides
548 | Self::NodeModulesMissing
549 | Self::NoSourceFilesAnalyzed { .. }
550 | Self::FileScoresUnavailable { .. }
551 | Self::HotspotsSkipped { .. }
552 | Self::ShallowClone { .. }
553 | Self::UnpinnedClock
554 | Self::OwnershipUnavailable { .. }
555 | Self::TrendSnapshotUnreadable { .. }
556 | Self::PluginConfigUnreadable { .. }
557 | Self::FlagAgeShallowClone
558 | Self::FlagAgeUnavailable { .. } => true,
559 }
560 }
561
562 /// Whether this diagnostic is produced by SOURCE discovery (the file walk in
563 /// `discover_files`) rather than WORKSPACE discovery (config load). Source-
564 /// discovery diagnostics are APPENDED to the registry after config load, so
565 /// `stash_workspace_diagnostics` must preserve them when it replaces the
566 /// workspace-discovery set, otherwise the per-analysis config re-loads in
567 /// combined-mode (`fallow` with no subcommand re-loads config for check,
568 /// dupes, and health) wipe them before the JSON envelope is built (issue
569 /// #1086).
570 #[must_use]
571 pub const fn is_source_discovery(&self) -> bool {
572 matches!(
573 self,
574 Self::SkippedLargeFile { .. }
575 | Self::SkippedMinifiedFile { .. }
576 | Self::SkippedSourceDotdir
577 | Self::SourceReadFailure { .. }
578 | Self::SourceParseDegraded { .. }
579 | Self::NodeModulesMissing
580 | Self::ExcludedByDefaultIgnore { .. }
581 | Self::NoSourceFilesAnalyzed { .. }
582 )
583 }
584
585 /// Whether this diagnostic is written by the source file WALK
586 /// (`discover_files`), the subset of [`Self::is_source_discovery`] that a
587 /// walk replaces wholesale for its root. `source-read-failure` is the
588 /// other source-discovery kind and is NOT one of these: the parse stage
589 /// records it after the walk, so it has to keep reaching consumers through
590 /// the registry.
591 ///
592 /// A walk-recorded entry must reach an analysis from its OWN walk's return
593 /// value. Combined mode runs the dead-code and duplication walks under
594 /// `rayon::join` whenever a per-analysis `production` split stops them from
595 /// sharing a file list, so a registry read answers "whichever walk wrote
596 /// last" and varies between runs of the same command (issue #2366).
597 #[must_use]
598 pub const fn is_source_walk_recorded(&self) -> bool {
599 matches!(
600 self,
601 Self::SkippedLargeFile { .. }
602 | Self::SkippedMinifiedFile { .. }
603 | Self::SkippedSourceDotdir
604 | Self::NodeModulesMissing
605 | Self::ExcludedByDefaultIgnore { .. }
606 | Self::NoSourceFilesAnalyzed { .. }
607 )
608 }
609
610 /// Whether this diagnostic reports a source file whose contents this run
611 /// never analyzed, so every import and export the file holds is invisible
612 /// to the module graph.
613 ///
614 /// This is the class `reachability_caveats[]` exists for. A file the run
615 /// never read credits nothing, so the modules it imports surface as
616 /// confident `unused-file` and `unused-export` findings carrying
617 /// `delete-file` and `remove-export` actions, and `fallow fix` would
618 /// otherwise apply the removal against source that still imports the
619 /// target.
620 ///
621 /// All four discovery-side kinds qualify, for the same reason and with the
622 /// same consequence:
623 ///
624 /// - `skipped-large-file` and `skipped-minified-file`: the file is in the
625 /// project tree and was never opened, so its import list is unknown.
626 /// - `skipped-source-dotdir`: the directory holds at least one source file
627 /// the project did not exclude, and none of them were traversed. The
628 /// diagnostic is capped, so it under-reports rather than over-reports;
629 /// its presence still proves unseen source exists.
630 /// - `source-read-failure`: the file was discovered and then could not be
631 /// read, so nothing was extracted from it at all.
632 ///
633 /// `source-parse-degraded` is deliberately NOT one of these, though it
634 /// belongs to the same family. Neither is `excluded-by-default-ignore`,
635 /// for a different reason: that one reports designed behavior on generated
636 /// output rather than a degraded run, and its own doc comment carries the
637 /// argument.
638 ///
639 /// `source-parse-degraded`: that file WAS read, so it has a module and
640 /// a graph node and its reachability is observable, which lets the caveat
641 /// pass narrow it: a degraded module that is itself unreachable cannot
642 /// change a reachability verdict. Every kind above has no node to ask (a
643 /// read failure has one with nothing extracted into it), so no narrowing
644 /// is available and the caveat they raise is run-level.
645 ///
646 /// The match is exhaustive on purpose: a new "the run did not see this
647 /// file" kind has to be classified here, and answering `true` is the only
648 /// wiring its findings need in order to inherit both the caveat and the
649 /// `fallow fix` withholding that follows it.
650 #[must_use]
651 pub const fn source_never_analyzed(&self) -> bool {
652 match self {
653 Self::SkippedLargeFile { .. }
654 | Self::SkippedMinifiedFile { .. }
655 | Self::SkippedSourceDotdir
656 | Self::SourceReadFailure { .. } => true,
657 Self::UndeclaredWorkspace
658 | Self::MalformedPackageJson { .. }
659 | Self::GlobMatchedNoPackageJson { .. }
660 | Self::MalformedTsconfig { .. }
661 | Self::TsconfigReferenceDirMissing
662 | Self::MalformedPnpmWorkspaceYaml { .. }
663 | Self::SourceParseDegraded { .. }
664 | Self::BunLockbOverrideResolutionSkipped
665 | Self::BunLockOverrideResolutionSkipped
666 | Self::BunResolutionsShadowedByOverrides
667 | Self::NodeModulesMissing
668 | Self::BoundariesNotConfigured
669 | Self::RulePacksNotConfigured
670 | Self::ExcludedByDefaultIgnore { .. }
671 | Self::NoSourceFilesAnalyzed { .. }
672 | Self::FileScoresUnavailable { .. }
673 | Self::HotspotsSkipped { .. }
674 | Self::ShallowClone { .. }
675 | Self::UnpinnedClock
676 | Self::OwnershipUnavailable { .. }
677 | Self::TrendSnapshotUnreadable { .. }
678 | Self::PluginConfigUnreadable { .. }
679 | Self::PluginEffectNotModeled { .. }
680 | Self::CoverageAutoDetected
681 | Self::FlagAgeShallowClone
682 | Self::FlagAgeUnavailable { .. } => false,
683 }
684 }
685
686 /// Whether this diagnostic is recorded by the ANALYZE stage (the
687 /// dependency-catalog and override detectors) rather than by workspace or
688 /// source discovery. Analysis-stage diagnostics reach the registry through
689 /// `record_workspace_diagnostics` after config load, so
690 /// `stash_workspace_diagnostics` must preserve them across combined-mode's
691 /// per-analysis config re-loads, and every analyze pass clears its previous
692 /// entries before re-recording so a fixed cause drops out on the next run
693 /// (issue #2366). The match is exhaustive on purpose: a new kind must be
694 /// classified here before it compiles.
695 ///
696 /// Classify a kind `true` ONLY when a detector reachable from the dead-code
697 /// analyze pass (`find_dead_code_full`) re-records it, because that pass is
698 /// the single clear site. A kind recorded exclusively by another stage would
699 /// be cleared by the next dead-code pass and never come back.
700 #[must_use]
701 pub const fn is_analysis_stage(&self) -> bool {
702 match self {
703 Self::MalformedPnpmWorkspaceYaml { .. }
704 | Self::BunLockbOverrideResolutionSkipped
705 | Self::BunLockOverrideResolutionSkipped
706 | Self::BunResolutionsShadowedByOverrides
707 | Self::BoundariesNotConfigured
708 | Self::RulePacksNotConfigured => true,
709 Self::UndeclaredWorkspace
710 | Self::MalformedPackageJson { .. }
711 | Self::GlobMatchedNoPackageJson { .. }
712 | Self::MalformedTsconfig { .. }
713 | Self::TsconfigReferenceDirMissing
714 | Self::SkippedLargeFile { .. }
715 | Self::SkippedMinifiedFile { .. }
716 | Self::SkippedSourceDotdir
717 | Self::SourceReadFailure { .. }
718 | Self::SourceParseDegraded { .. }
719 | Self::NodeModulesMissing
720 | Self::ExcludedByDefaultIgnore { .. }
721 | Self::NoSourceFilesAnalyzed { .. }
722 | Self::FileScoresUnavailable { .. }
723 | Self::HotspotsSkipped { .. }
724 | Self::ShallowClone { .. }
725 | Self::UnpinnedClock
726 | Self::OwnershipUnavailable { .. }
727 | Self::TrendSnapshotUnreadable { .. }
728 | Self::PluginConfigUnreadable { .. }
729 | Self::PluginEffectNotModeled { .. }
730 | Self::CoverageAutoDetected
731 | Self::FlagAgeShallowClone
732 | Self::FlagAgeUnavailable { .. } => false,
733 }
734 }
735
736 /// Whether this diagnostic is recorded by the HEALTH pipeline (scoring,
737 /// churn, ownership, trend, coverage input resolution) rather than by
738 /// workspace discovery, source discovery or the analyze stage.
739 ///
740 /// Health-stage diagnostics are appended to the registry after config
741 /// load, so `stash_workspace_diagnostics` must preserve them across
742 /// combined mode's per-analysis config re-loads, and the health run clears
743 /// its previous entries before re-recording so a fixed CODEOWNERS drops out
744 /// on the next run (issue #2689).
745 ///
746 /// They are deliberately NOT [`Self::is_analysis_stage`], although they
747 /// share both of those properties. That predicate additionally means "the
748 /// dead-code analyze pass re-records this", and the pass clears every kind
749 /// answering it on entry. Health computes file scores by running that same
750 /// pass, so a health-stage kind classified there would be wiped mid-run by
751 /// the analysis it is reporting on.
752 #[must_use]
753 pub const fn is_health_stage(&self) -> bool {
754 match self {
755 Self::FileScoresUnavailable { .. }
756 | Self::HotspotsSkipped { .. }
757 | Self::ShallowClone { .. }
758 | Self::UnpinnedClock
759 | Self::OwnershipUnavailable { .. }
760 | Self::TrendSnapshotUnreadable { .. }
761 | Self::CoverageAutoDetected => true,
762 Self::UndeclaredWorkspace
763 | Self::MalformedPackageJson { .. }
764 | Self::GlobMatchedNoPackageJson { .. }
765 | Self::MalformedTsconfig { .. }
766 | Self::TsconfigReferenceDirMissing
767 | Self::MalformedPnpmWorkspaceYaml { .. }
768 | Self::SkippedLargeFile { .. }
769 | Self::SkippedMinifiedFile { .. }
770 | Self::SkippedSourceDotdir
771 | Self::SourceReadFailure { .. }
772 | Self::SourceParseDegraded { .. }
773 | Self::BunLockbOverrideResolutionSkipped
774 | Self::BunLockOverrideResolutionSkipped
775 | Self::BunResolutionsShadowedByOverrides
776 | Self::NodeModulesMissing
777 | Self::BoundariesNotConfigured
778 | Self::RulePacksNotConfigured
779 | Self::ExcludedByDefaultIgnore { .. }
780 | Self::PluginConfigUnreadable { .. }
781 | Self::PluginEffectNotModeled { .. }
782 | Self::NoSourceFilesAnalyzed { .. }
783 | Self::FlagAgeShallowClone
784 | Self::FlagAgeUnavailable { .. } => false,
785 }
786 }
787
788 /// Whether this diagnostic is recorded by the PLUGIN stage (framework
789 /// plugins reading their own build configs) rather than by workspace
790 /// discovery, source discovery, the analyze stage or the health pipeline.
791 ///
792 /// Plugin-stage diagnostics are recorded after config load, so
793 /// `stash_workspace_diagnostics` must preserve them across combined mode's
794 /// per-analysis config re-loads, and each plugin run replaces the previous
795 /// run's set so a fixed config drops out on the next run (issue #2736).
796 ///
797 /// They are deliberately NOT [`Self::is_analysis_stage`], although they
798 /// share both of those properties. That predicate additionally means "the
799 /// dead-code analyze pass re-records this", and the pass clears every kind
800 /// answering it on entry. Plugins run in the prelude of that same pass, so
801 /// a plugin-stage kind classified there would be wiped inside the run that
802 /// produced it.
803 ///
804 /// The match is exhaustive on purpose: a new kind must be classified here
805 /// before it compiles.
806 #[must_use]
807 pub const fn is_plugin_stage(&self) -> bool {
808 match self {
809 Self::PluginConfigUnreadable { .. } | Self::PluginEffectNotModeled { .. } => true,
810 Self::UndeclaredWorkspace
811 | Self::MalformedPackageJson { .. }
812 | Self::GlobMatchedNoPackageJson { .. }
813 | Self::MalformedTsconfig { .. }
814 | Self::TsconfigReferenceDirMissing
815 | Self::MalformedPnpmWorkspaceYaml { .. }
816 | Self::SkippedLargeFile { .. }
817 | Self::SkippedMinifiedFile { .. }
818 | Self::SkippedSourceDotdir
819 | Self::SourceReadFailure { .. }
820 | Self::SourceParseDegraded { .. }
821 | Self::BunLockbOverrideResolutionSkipped
822 | Self::BunLockOverrideResolutionSkipped
823 | Self::BunResolutionsShadowedByOverrides
824 | Self::NodeModulesMissing
825 | Self::BoundariesNotConfigured
826 | Self::RulePacksNotConfigured
827 | Self::ExcludedByDefaultIgnore { .. }
828 | Self::NoSourceFilesAnalyzed { .. }
829 | Self::FileScoresUnavailable { .. }
830 | Self::HotspotsSkipped { .. }
831 | Self::ShallowClone { .. }
832 | Self::UnpinnedClock
833 | Self::OwnershipUnavailable { .. }
834 | Self::TrendSnapshotUnreadable { .. }
835 | Self::CoverageAutoDetected
836 | Self::FlagAgeShallowClone
837 | Self::FlagAgeUnavailable { .. } => false,
838 }
839 }
840}
841
842/// Render a byte count as a megabyte figure with one decimal place for
843/// human-readable diagnostic messages (e.g. `12.3 MB`).
844#[must_use]
845fn format_size_mb(bytes: u64) -> String {
846 #[expect(
847 clippy::cast_precision_loss,
848 reason = "display-only size figure; precision loss past 2^53 bytes is irrelevant"
849 )]
850 let mb = bytes as f64 / (1024.0 * 1024.0);
851 format!("{mb:.1} MB")
852}
853
854/// A diagnostic about a workspace-discovery candidate.
855///
856/// The `message` field is a human-readable rendering derived from `kind`. It
857/// always ends with a concrete next step ("fix the JSON syntax", "remove from
858/// `workspaces`", "add to `ignorePatterns`") so first-time users have a path
859/// forward.
860#[derive(Debug, Clone, Serialize, Deserialize)]
861#[cfg_attr(feature = "schema", derive(JsonSchema))]
862pub struct WorkspaceDiagnostic {
863 /// Path to the directory or file that triggered the diagnostic.
864 #[serde(serialize_with = "serde_path::serialize")]
865 pub path: PathBuf,
866 /// Kind discriminator with the typed payload.
867 #[serde(flatten)]
868 pub kind: WorkspaceDiagnosticKind,
869 /// Human-readable rendering derived from `kind` + `path`. Always ends
870 /// with a next-step hint.
871 pub message: String,
872 /// True when this diagnostic reports a run whose RESULTS are degraded:
873 /// something the user installed, wrote, or expected did not reach the
874 /// analysis. Projected from [`WorkspaceDiagnosticKind::warns_on_stderr`],
875 /// which is the same classification that decides whether the CLI prints a
876 /// stderr line, so a CI log built from this field and a local non-quiet run
877 /// say the same thing.
878 ///
879 /// Omitted when false, which is what keeps every clean run byte-identical.
880 /// The two unconfigured-check kinds answer false on purpose: they fire in
881 /// the product's default state on every project that never opted into
882 /// boundaries or rule packs, so warning on them would warn forever. So does
883 /// `excluded-by-default-ignore`, which is designed behavior on generated
884 /// output; the alarm for that case is `no-source-files-analyzed`.
885 ///
886 /// Read this instead of hardcoding a kind allowlist: a degrading kind added
887 /// in a later release then reaches an unchanged consumer.
888 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
889 pub degrades_analysis: bool,
890}
891
892impl WorkspaceDiagnostic {
893 /// Construct a diagnostic with the message rendered from `kind` + `path`.
894 ///
895 /// `root` is used to produce project-relative paths in the message text
896 /// AND inside the variant payload (e.g. the `error` field of
897 /// `MalformedPackageJson` / `MalformedTsconfig` which embed the absolute
898 /// file path from `PackageJson::load()`'s error text). Without the
899 /// payload-side normalisation the embedded path would survive
900 /// environment-specific differences (CI vs Docker vs local) because the
901 /// post-serialisation `strip_root_prefix` only catches whole-string
902 /// matches, not paths embedded mid-sentence.
903 ///
904 /// If `path` is not under `root` (e.g. canonicalisation crossed a
905 /// symlink), the absolute path is emitted instead.
906 ///
907 /// `path` also loses any no-op `.` component, for the same reason the
908 /// payload loses a glob's `./` prefix: one directory reached through two
909 /// spellings of one glob must be one diagnostic.
910 #[must_use]
911 pub fn new(root: &Path, path: PathBuf, kind: WorkspaceDiagnosticKind) -> Self {
912 let path = normalise_diagnostic_path(path);
913 let kind = normalise_payload_paths(root, kind);
914 let message = render_message(root, &path, &kind);
915 let degrades_analysis = kind.warns_on_stderr();
916 Self {
917 path,
918 kind,
919 message,
920 degrades_analysis,
921 }
922 }
923
924 /// Return this diagnostic with `path` rewritten relative to `root`.
925 ///
926 /// `path` is stored absolute so callers can act on it. Every JSON envelope
927 /// emits it project-relative instead: the analysis envelopes get there
928 /// through the post-serialisation `strip_root_prefix` pass, which the
929 /// `fallow workspaces` / `fallow list --workspaces` envelope and the MCP
930 /// `project_info` tool never run, so those emitted the absolute path while
931 /// the sibling `workspaces[].path` next to it was relative. They normalise
932 /// at the typed layer with this method instead.
933 ///
934 /// Paths outside `root` (canonicalisation crossed a symlink) are left
935 /// absolute, matching how [`Self::new`] renders the message.
936 ///
937 /// A diagnostic anchored at the root itself becomes `.`, not the empty
938 /// path: an empty string is not a location, and the analysis envelopes'
939 /// post-serialisation strip only removes a `root + separator` prefix, so a
940 /// root-anchored path that stays absolute here leaks a host path.
941 #[must_use]
942 pub fn into_root_relative(mut self, root: &Path) -> Self {
943 if let Ok(relative) = self.path.strip_prefix(root) {
944 self.path = if relative.as_os_str().is_empty() {
945 PathBuf::from(".")
946 } else {
947 relative.to_path_buf()
948 };
949 }
950 self
951 }
952}
953
954/// Rebuild `path` from its components so one directory has one spelling.
955///
956/// The dedupe key was never the problem: [`Path`] equality already ignores an
957/// interior `.`, so `<root>/./pkgs/aaa` and `<root>/pkgs/aaa` are one key. The
958/// stored bytes were. A workspace glob spelled `./pkgs/*` in `package.json`
959/// expands to the first spelling and the same glob spelled `pkgs/*` in
960/// `pnpm-workspace.yaml` expands to the second, and the two envelope families
961/// make a project-relative path differently: the analysis envelopes strip the
962/// root as a string (leaving `./pkgs/aaa`) while the workspace listing
963/// envelope uses [`WorkspaceDiagnostic::into_root_relative`] (leaving
964/// `pkgs/aaa`). Whichever
965/// manifest happened to be read first then decided which shape every consumer
966/// saw. Collapsing at construction gives them one answer (issue #2366).
967///
968/// A path that is already component-clean rebuilds to itself. Serialization
969/// normalises separators, so the rebuild is wire-invisible on Windows.
970fn normalise_diagnostic_path(path: PathBuf) -> PathBuf {
971 let rebuilt: PathBuf = path.components().collect();
972 if rebuilt.as_os_str() == path.as_os_str() {
973 path
974 } else {
975 rebuilt
976 }
977}
978
979/// Strip the project root from absolute paths embedded inside variant
980/// payloads (the `error` field of malformed-config and source-read failures),
981/// and drop a glob pattern's no-op `./` prefix.
982///
983/// Mirrors the per-platform `display()` byte sequence so the substring match
984/// works on Windows too.
985///
986/// The pattern prefix matters because the payload is part of the dedupe key in
987/// [`merge_workspace_diagnostics`]. A repository whose `package.json` declares
988/// `"./apps/**"` and whose `pnpm-workspace.yaml` declares `apps/**` names one
989/// glob twice, and without this both spellings would report every package-less
990/// directory under `apps/` a second time (issue #2366).
991fn normalise_payload_paths(root: &Path, kind: WorkspaceDiagnosticKind) -> WorkspaceDiagnosticKind {
992 let root_str = root.display().to_string();
993 let root_alt = root_str.replace('\\', "/");
994 let normalise = |text: String| -> String {
995 let stripped = text
996 .replace(&format!("{root_str}/"), "")
997 .replace(&format!("{root_alt}/"), "");
998 stripped
999 .replace(&format!("{root_str}\\"), "")
1000 .replace(&format!("{root_alt}\\"), "")
1001 };
1002 match kind {
1003 WorkspaceDiagnosticKind::MalformedPackageJson { error } => {
1004 WorkspaceDiagnosticKind::MalformedPackageJson {
1005 error: normalise(error),
1006 }
1007 }
1008 WorkspaceDiagnosticKind::MalformedTsconfig { error } => {
1009 WorkspaceDiagnosticKind::MalformedTsconfig {
1010 error: normalise(error),
1011 }
1012 }
1013 WorkspaceDiagnosticKind::SourceReadFailure { error } => {
1014 WorkspaceDiagnosticKind::SourceReadFailure {
1015 error: normalise(error),
1016 }
1017 }
1018 WorkspaceDiagnosticKind::FileScoresUnavailable { error } => {
1019 WorkspaceDiagnosticKind::FileScoresUnavailable {
1020 error: normalise(error),
1021 }
1022 }
1023 WorkspaceDiagnosticKind::OwnershipUnavailable { cause, error } => {
1024 WorkspaceDiagnosticKind::OwnershipUnavailable {
1025 cause,
1026 error: normalise(error),
1027 }
1028 }
1029 WorkspaceDiagnosticKind::TrendSnapshotUnreadable { error } => {
1030 WorkspaceDiagnosticKind::TrendSnapshotUnreadable {
1031 error: normalise(error),
1032 }
1033 }
1034 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => {
1035 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1036 pattern: canonical_glob_pattern(pattern),
1037 }
1038 }
1039 other => other,
1040 }
1041}
1042
1043/// Drop the leading `./` (or `.\`) a workspace glob may carry, so the same
1044/// pattern declared in two manifests is one payload.
1045///
1046/// A pattern that is nothing BUT the prefix (`"./"`, the root itself) keeps
1047/// its spelling: stripping it would report an empty `pattern` field and an
1048/// empty quoted glob in the warning text, which names no glob at all.
1049fn canonical_glob_pattern(pattern: String) -> String {
1050 for prefix in ["./", ".\\"] {
1051 if let Some(rest) = pattern.strip_prefix(prefix)
1052 && !rest.is_empty()
1053 {
1054 return rest.to_owned();
1055 }
1056 }
1057 pattern
1058}
1059
1060/// Concatenate two diagnostic lists, keeping the first occurrence of each
1061/// `(kind, path)` pair and the order of `primary` followed by the entries only
1062/// `secondary` has.
1063///
1064/// The single place diagnostics from two observation points are folded
1065/// together: an engine session's own capture plus the process registry, and
1066/// the combined run's per-analysis lists (issue #2366). A combined run walks
1067/// the project once per analysis, and per-analysis `production` modes can make
1068/// those walks see different file sets, so no single observation point holds
1069/// everything the run recorded; the union does, and folding it the same way
1070/// everywhere is what keeps the CLI and the programmatic route answering
1071/// identically.
1072///
1073/// The key is the WHOLE kind, payload included, not its
1074/// [`id`](WorkspaceDiagnosticKind::id). Two entries can share a kind id and a
1075/// path and still be two distinct diagnostics: overlapping workspace globs
1076/// (`["packages/*", "packages/*/*"]`) each report the same package-less
1077/// directory with their own `pattern`, and the standalone envelopes report
1078/// both. An id-keyed fold silently dropped the second one.
1079#[must_use]
1080pub fn merge_workspace_diagnostics(
1081 primary: Vec<WorkspaceDiagnostic>,
1082 secondary: Vec<WorkspaceDiagnostic>,
1083) -> Vec<WorkspaceDiagnostic> {
1084 let mut merged = Vec::with_capacity(primary.len() + secondary.len());
1085 let mut seen: FxHashSet<(WorkspaceDiagnosticKind, PathBuf)> = FxHashSet::default();
1086 for diagnostic in primary.into_iter().chain(secondary) {
1087 let key = (diagnostic.kind.clone(), diagnostic.path.clone());
1088 if seen.insert(key) {
1089 merged.push(diagnostic);
1090 }
1091 }
1092 merged
1093}
1094
1095/// Keep the first occurrence of each `(kind, path)` pair in one list.
1096///
1097/// The single-list form of [`merge_workspace_diagnostics`], applied where
1098/// diagnostics are produced rather than where two observation points are
1099/// folded: workspace discovery reads `package.json` `workspaces`,
1100/// `pnpm-workspace.yaml` `packages`, `deno.json` `workspace` and the root
1101/// `tsconfig.json` references additively, so a repository that declares one
1102/// glob in two of them reports every package-less directory under it twice.
1103/// Deduplicating at that source is what keeps the JSON envelopes, the
1104/// aggregated stderr warning and the process registry telling one story
1105/// (issue #2366).
1106#[must_use]
1107pub fn dedupe_workspace_diagnostics(
1108 diagnostics: Vec<WorkspaceDiagnostic>,
1109) -> Vec<WorkspaceDiagnostic> {
1110 merge_workspace_diagnostics(diagnostics, Vec::new())
1111}
1112
1113/// The first segment of a glob that contains no glob metacharacter, so it
1114/// names a real directory rather than a wildcard.
1115///
1116/// Source discovery uses it to decide which directory a built-in ignore
1117/// pattern excluded a file "at"; `render_message` uses it to decide which
1118/// remedy is true for that pattern. The two have to agree, so the function
1119/// lives here rather than once per crate: a pattern with such a segment
1120/// (`**/dist/**`) is lifted by re-rooting inside the matched directory,
1121/// because the glob is matched against the path relative to the run root. A
1122/// pattern without one (`**/*.min.js`) matches on the file name and keeps
1123/// matching at every root.
1124#[must_use]
1125pub fn glob_first_literal_segment(pattern: &str) -> Option<&str> {
1126 pattern.split('/').find(|segment| {
1127 !segment.is_empty()
1128 && !segment.contains(['*', '?', '[', ']', '{', '}'])
1129 && *segment != "."
1130 && *segment != ".."
1131 })
1132}
1133
1134/// The clause naming why a plugin could not read a config key in full, for one
1135/// `plugin-config-unreadable` reason token.
1136///
1137/// The token set is open, so an unrecognised token renders the general claim
1138/// rather than nothing: a diagnostic from a plugin added later still reads as a
1139/// sentence.
1140fn unreadable_situation(reason: &str) -> &'static str {
1141 match reason {
1142 "array-form" => "uses the array form, which is not read yet",
1143 "spread" => "spreads a value that is not statically readable",
1144 "unreadable-entries" => "has entries that hold no statically readable value",
1145 "not-object-literal" => "is not a static object literal",
1146 "unrecognized-call" => "is passed through a call that is not a known config wrapper",
1147 "import-target-unreadable" => "comes from an imported file that is not statically readable",
1148 "dynamic-argument" => "receives an argument that is not a static literal",
1149 _ => "could not be read statically",
1150 }
1151}
1152
1153/// What an unread config key costs, and the configuration option that covers
1154/// the gap.
1155///
1156/// Keyed on the config KEY rather than on the plugin name, because one key is
1157/// read by several plugins: Module Federation `exposes` and `remotes` reach a
1158/// build from a standalone config file and inline from the webpack, rspack,
1159/// rsbuild and vite configs, and both the consequence and the remedy are the
1160/// same in all five. A key this build does not know falls back to the general
1161/// claim rather than borrowing another key's remedy, so a plugin added later
1162/// still renders a sentence that is true.
1163///
1164/// Two reasons change the remedy. An unrecognized call was read as a lower
1165/// bound, so only what the call adds is missing. An unreadable import target
1166/// holds config that is shared across files, so the remedy names the option
1167/// and does not ask for an object literal.
1168fn unreadable_key_consequence(key: &str, reason: &str) -> (&'static str, &'static str) {
1169 match (key, reason) {
1170 ("exposes", "unrecognized-call") => (
1171 "only the targets in the object literal it receives are registered as entry points",
1172 "Name any other exposed files in `dynamicallyLoaded`.",
1173 ),
1174 ("remotes", "unrecognized-call") => (
1175 "only the aliases in the object literal it receives are treated as provided by a \
1176 remote container",
1177 "Name any other aliases in `ignoreDependencies`.",
1178 ),
1179 ("exposes", "import-target-unreadable") => (
1180 "the targets that file declares are not registered as entry points",
1181 "Name the exposed files in `dynamicallyLoaded`.",
1182 ),
1183 ("remotes", "import-target-unreadable") => (
1184 "the aliases that file declares are not treated as provided by a remote container",
1185 "Name the aliases in `ignoreDependencies`.",
1186 ),
1187 ("registerRemotes", _) => (
1188 "the remotes it registers are not treated as provided by a remote container",
1189 "Name the remote aliases in `ignoreDependencies`, or pass the remote names as \
1190 string literals.",
1191 ),
1192 ("init" | "createInstance", _) => (
1193 "the remotes its options declare are not treated as provided by a remote container",
1194 "Name the remote aliases in `ignoreDependencies`, or pass `remotes` as an array of \
1195 objects with literal names.",
1196 ),
1197 ("loadRemote", _) => (
1198 "the remote it loads is not treated as provided by a remote container",
1199 "Name the remote alias in `ignoreDependencies`, or pass the request as a string \
1200 literal.",
1201 ),
1202 ("exposes", _) => (
1203 "the targets are not registered as entry points",
1204 "Name the exposed files in `dynamicallyLoaded`.",
1205 ),
1206 ("remotes", _) => (
1207 "the aliases are not treated as provided by a remote container",
1208 "Name the aliases in `ignoreDependencies`, or declare them as the keys of an object \
1209 literal, whose values may be computed.",
1210 ),
1211 _ => (
1212 "what it declares is not fully registered",
1213 "Declare the value as a static object literal.",
1214 ),
1215 }
1216}
1217
1218fn render_message(root: &Path, path: &Path, kind: &WorkspaceDiagnosticKind) -> String {
1219 let display = display_relative(root, path);
1220 match kind {
1221 WorkspaceDiagnosticKind::UndeclaredWorkspace => format!(
1222 "Directory '{display}' contains package.json but is not declared as a workspace. \
1223 Add it to package.json workspaces or pnpm-workspace.yaml, or add it to ignorePatterns."
1224 ),
1225 WorkspaceDiagnosticKind::MalformedPackageJson { error } => format!(
1226 "Dropped workspace '{display}': package.json is not valid JSON ({error}). \
1227 Fix the JSON syntax or remove '{display}' from the workspaces pattern."
1228 ),
1229 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => format!(
1230 "Glob '{pattern}' matched '{display}' but no package.json is present. \
1231 Add a package.json, narrow the pattern, or add '{display}' to ignorePatterns."
1232 ),
1233 WorkspaceDiagnosticKind::MalformedTsconfig { error } => format!(
1234 "tsconfig.json at '{display}' failed to parse ({error}); \
1235 project references will be ignored. Fix the JSON syntax."
1236 ),
1237 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => format!(
1238 "tsconfig.json references '{display}' but the directory does not exist. \
1239 Update or remove the reference, or restore the missing directory."
1240 ),
1241 WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml { error } => format!(
1242 "'{display}' failed to parse ({error}); catalog and override entries \
1243 will be ignored. Fix the YAML syntax."
1244 ),
1245 WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes } => format!(
1246 "Skipped '{display}' ({size}): exceeds the max file size limit. \
1247 Its imports and exports are not analyzed. Raise the limit with \
1248 --max-file-size <MB> (or FALLOW_MAX_FILE_SIZE), or add '{display}' \
1249 to ignorePatterns.",
1250 size = format_size_mb(*size_bytes)
1251 ),
1252 WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes } => format!(
1253 "Skipped '{display}' ({size}): appears to be minified generated JavaScript. \
1254 Its imports and exports are not analyzed. Add '{display}' to ignorePatterns, \
1255 rename it with a .min.js suffix, or use --max-file-size 0 if this file \
1256 should be analyzed.",
1257 size = format_size_mb(*size_bytes)
1258 ),
1259 WorkspaceDiagnosticKind::SkippedSourceDotdir => format!(
1260 "Skipped hidden directory '{display}': it contains source files but hidden \
1261 directories are not traversed. Its imports and exports are not analyzed. \
1262 A file, export or dependency that only this directory uses can be reported as \
1263 unused. There is no config field that adds a directory to traversal. To stop \
1264 that false positive, add the file to entry, the export to ignoreExports or the \
1265 dependency to ignoreDependencies. To silence this message, \
1266 add '{display}/**' to ignorePatterns. fallow --root {display} analyzes only \
1267 that directory on its own and does not fix this run."
1268 ),
1269 WorkspaceDiagnosticKind::SourceReadFailure { error } => format!(
1270 "Could not read source '{display}' ({error}). Restore the file or its read permissions, \
1271 ensure it contains valid UTF-8 text, or add '{display}' to ignorePatterns."
1272 ),
1273 WorkspaceDiagnosticKind::SourceParseDegraded {
1274 error_count,
1275 panicked,
1276 } => {
1277 let outcome = if *panicked {
1278 "the parser stopped there"
1279 } else {
1280 "the parser recovered and continued"
1281 };
1282 format!(
1283 "Parsed '{display}' with {error_count} error(s); {outcome}. Imports, exports, and \
1284 references it did not reach are missing from this run, so files and symbols it \
1285 uses can be reported as unused. Fix the syntax, or ignore this if the file uses \
1286 syntax newer than fallow's parser."
1287 )
1288 }
1289 WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped => format!(
1290 "Skipped dependency-override resolution for '{display}': bun's legacy binary bun.lockb \
1291 sits next to it, fallow cannot read the binary format, and no parseable text lockfile \
1292 (bun.lock, pnpm-lock.yaml, package-lock.json, or npm-shrinkwrap.json) was found to \
1293 use instead, so unused-dependency-overrides findings are not reported. Run bun install \
1294 --save-text-lockfile (bun 1.2 or newer) to write a text bun.lock, or delete the stale \
1295 bun.lockb if this repository no longer uses bun."
1296 ),
1297 WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped => format!(
1298 "Skipped dependency-override resolution because '{display}' could not be parsed and \
1299 no readable pnpm or npm lockfile was available, so unused-dependency-overrides \
1300 findings are not reported. Run bun install to regenerate the text lockfile, then \
1301 rerun fallow."
1302 ),
1303 WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides => format!(
1304 "'{display}' declares both `overrides` and non-empty `resolutions`; bun applies \
1305 `overrides` and ignores `resolutions`. Move the intended pins into `overrides` or \
1306 remove the shadowed `resolutions` entries."
1307 ),
1308 WorkspaceDiagnosticKind::NodeModulesMissing => format!(
1309 "'{display}' does not exist. Package exports and conditional exports cannot be read, \
1310 framework plugins that activate on an installed package stay inactive, and \
1311 dependency classification degrades, so imports and dependencies can be \
1312 misreported. Run npm install / pnpm install / yarn / bun install first."
1313 ),
1314 WorkspaceDiagnosticKind::BoundariesNotConfigured => {
1315 "No architecture boundaries are configured, so the boundary detector did not run and \
1316 its violation counts are zero because nothing was measured. Add `boundaries` to the \
1317 config, or set `boundary-violation` to off to state that the check is not wanted."
1318 .to_string()
1319 }
1320 WorkspaceDiagnosticKind::RulePacksNotConfigured => {
1321 "No rule packs are configured, so the policy detector did not run and its violation \
1322 counts are zero because nothing was measured. Add `rulePacks` to the config, or set \
1323 `policy-violation` to off to state that the check is not wanted."
1324 .to_string()
1325 }
1326 WorkspaceDiagnosticKind::NoSourceFilesAnalyzed {
1327 excluded_file_count,
1328 } => {
1329 if *excluded_file_count == 0 {
1330 "No source files were analyzed, so every finding count this run reports is zero \
1331 because nothing was measured. Check the analysis root, ignorePatterns, and any \
1332 path or workspace filter this run applied."
1333 .to_owned()
1334 } else {
1335 format!(
1336 "No source files were analyzed. Fallow's built-in ignore patterns excluded \
1337 {excluded_file_count} candidate files, so every finding count this run \
1338 reports is zero because nothing was measured; run with --explain-skipped \
1339 for the breakdown."
1340 )
1341 }
1342 }
1343 WorkspaceDiagnosticKind::FileScoresUnavailable { error } => format!(
1344 "Could not compute per-file health scores ({error}), so the score list is empty and \
1345 the scored-file count is 0 because nothing was measured rather than because the \
1346 project has nothing to score. Rerun with --no-cache, or scope the run to a \
1347 subdirectory to find the input that fails."
1348 ),
1349 WorkspaceDiagnosticKind::HotspotsSkipped { cause } => match cause.as_str() {
1350 "invalid-since" => "Hotspot analysis was skipped because --since could not be read \
1351 as a time window, so the hotspots, churn and ownership sections report nothing \
1352 rather than zero. Spell it as a duration such as 6m or 90d, or drop it to use \
1353 the default window."
1354 .to_owned(),
1355 "no-commits" => "Hotspot analysis was skipped because the current branch has no \
1356 commits yet, so the hotspots, churn and ownership sections report nothing \
1357 rather than zero. Commit the project to give churn a history, or pass \
1358 --churn-file with exported change history."
1359 .to_owned(),
1360 "churn-file-unreadable" => format!(
1361 "Hotspot analysis was skipped because the churn file '{display}' could no longer \
1362 be read after it was validated, so the hotspots, churn and ownership sections \
1363 report nothing rather than zero. Make sure nothing rewrites the file while \
1364 fallow runs, and rerun."
1365 ),
1366 // The original single cause, whose wording predates the token and
1367 // is kept byte-identical: a consumer matching on this sentence is
1368 // reading the same run it always was.
1369 _ => "Hotspot analysis was skipped because no git repository was found at the \
1370 project root, so the hotspots, churn and ownership sections report nothing \
1371 rather than zero. Run fallow inside the repository, or pass --churn-file with \
1372 exported change history."
1373 .to_owned(),
1374 },
1375 WorkspaceDiagnosticKind::ShallowClone {
1376 ownership_requested,
1377 } => {
1378 let ownership = if *ownership_requested {
1379 " Ownership signals are skewed too, because a shallow clone inflates \
1380 single-author dominance."
1381 } else {
1382 ""
1383 };
1384 format!(
1385 "This is a shallow clone, so churn covers only the fetched history and every \
1386 hotspot figure is incomplete.{ownership} Run git fetch --unshallow for the full \
1387 history."
1388 )
1389 }
1390 WorkspaceDiagnosticKind::UnpinnedClock => {
1391 "No commit timestamp was available, so churn recency and ownership staleness were \
1392 measured against the wall clock and drift between runs over the same commit. Set \
1393 FALLOW_CLOCK_EPOCH to pin the run clock."
1394 .to_owned()
1395 }
1396 WorkspaceDiagnosticKind::OwnershipUnavailable { cause, error } => {
1397 if cause == "codeowners-parse-failed" {
1398 format!(
1399 "Ownership signals are degraded: CODEOWNERS could not be parsed ({error}), \
1400 so hotspot entries carry no declared owner. Fix the CODEOWNERS syntax, or \
1401 drop --ownership for this run."
1402 )
1403 } else {
1404 format!(
1405 "Ownership signals are degraded: health.ownership.botPatterns contains an \
1406 invalid glob ({error}), so no author is classified as a bot and bot commits \
1407 count towards ownership. Fix the pattern, or remove it from the config."
1408 )
1409 }
1410 }
1411 WorkspaceDiagnosticKind::TrendSnapshotUnreadable { error } => format!(
1412 "Skipped health snapshot '{display}' ({error}), so the trend is computed over fewer \
1413 snapshots than this project has on disk. Delete the unreadable file, or rewrite it \
1414 with fallow health --save-snapshot."
1415 ),
1416 WorkspaceDiagnosticKind::CoverageAutoDetected => format!(
1417 "Coverage was auto-detected at '{display}' rather than passed with --coverage, so the \
1418 CRAP scores depend on whichever coverage file is on disk at run time. Pass --coverage \
1419 '{display}' explicitly for reproducible scores."
1420 ),
1421 WorkspaceDiagnosticKind::FlagAgeShallowClone => {
1422 "This is a shallow clone, so the flag retirement report gives no flag age. Run git \
1423 fetch --unshallow for the full history, or pass --flag-age off."
1424 .to_owned()
1425 }
1426 WorkspaceDiagnosticKind::FlagAgeUnavailable { cause } => {
1427 if cause == "no-commits" {
1428 "The flag retirement report gives no flag age, because the current branch has no \
1429 commit. Commit the code, or pass --flag-age off."
1430 .to_owned()
1431 } else {
1432 "The flag retirement report gives no flag age, because no git repository was \
1433 found at the project root. Run fallow inside the repository, or pass --flag-age \
1434 off."
1435 .to_owned()
1436 }
1437 }
1438 WorkspaceDiagnosticKind::PluginConfigUnreadable {
1439 plugin,
1440 key,
1441 reason,
1442 } => {
1443 let (consequence, advice) = unreadable_key_consequence(key, reason);
1444 format!(
1445 "Plugin '{plugin}': `{key}` in '{display}' {situation}, so {consequence}. {advice}",
1446 situation = unreadable_situation(reason)
1447 )
1448 }
1449 WorkspaceDiagnosticKind::PluginEffectNotModeled {
1450 plugin,
1451 key,
1452 reason,
1453 } => {
1454 // Two causes, one effect, one remedy. Each cause gets its own
1455 // sentence: the cause and the effect on the findings are separate
1456 // facts, and one sentence with two `so` clauses states neither fact
1457 // clearly.
1458 let effect = "`autoImports` kept the convention entry patterns for that surface, and \
1459 fallow reports no unused file there. Write the setting as static \
1460 literals, or remove the key to use the framework defaults.";
1461 if key.starts_with('#') {
1462 format!(
1463 "Plugin '{plugin}': fallow cannot read which names '{display}' takes from \
1464 `{key}`, so every name of `{key}` counts as used, and fallow reports no \
1465 unused file for these names. Read each name with a member access such as \
1466 `C.Card`, or import it by name."
1467 )
1468 } else if reason == "config-property-unreadable" {
1469 format!(
1470 "Plugin '{plugin}': fallow cannot read a top-level property in '{display}', so \
1471 it cannot classify the `{key}` surface. {effect}"
1472 )
1473 } else {
1474 format!(
1475 "Plugin '{plugin}': fallow does not model the effect of `{key}` in \
1476 '{display}'. {effect}"
1477 )
1478 }
1479 }
1480 WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
1481 pattern,
1482 file_count,
1483 directory_count,
1484 } => {
1485 // `path` is a location, and an empty string is not one: a built-in
1486 // that matched a file sitting directly at the analysis root
1487 // anchors at the root itself.
1488 let display = if display.is_empty() {
1489 ".".to_owned()
1490 } else {
1491 display
1492 };
1493 // The payload carries no directory list, so the message names the
1494 // one directory `path` anchors at. With several excluded
1495 // directories that is the largest group and NOT a majority, so the
1496 // sentence says which claim it is making and how many directories
1497 // it is leaving unnamed.
1498 let location = if *directory_count > 1 {
1499 format!(
1500 "Skipped {file_count} source files across {directory_count} directories, \
1501 the largest group under '{display}'"
1502 )
1503 } else if *file_count == 1 {
1504 format!("Skipped 1 source file under '{display}'")
1505 } else {
1506 format!("Skipped {file_count} source files under '{display}'")
1507 };
1508 let singular = *file_count == 1 && *directory_count <= 1;
1509 let (subject, effect) = if singular {
1510 ("it matches", "it imports, exports, or defines")
1511 } else {
1512 ("they match", "they import, export, or define")
1513 };
1514 // Only a directory-shaped built-in is lifted by re-rooting. Telling
1515 // a user with a `vendor/lib.min.js` to run `fallow --root vendor`
1516 // hands them a command that excludes the same file again.
1517 let remedy = if glob_first_literal_segment(pattern).is_some() {
1518 format!(
1519 "Move first-party source out of the matched directory, or analyze that \
1520 directory on its own with fallow --root {display}."
1521 )
1522 } else {
1523 "This pattern matches a file name rather than a directory, so re-running under \
1524 a different --root excludes the same files again. Rename first-party source \
1525 that only looks generated, dropping the '.min' or '.bundle' infix."
1526 .to_owned()
1527 };
1528 format!(
1529 "{location}: {subject} fallow's built-in ignore pattern '{pattern}', so nothing \
1530 {effect} is visible to this run. Built-in ignores cannot be switched off \
1531 through ignorePatterns. {remedy}"
1532 )
1533 }
1534 }
1535}
1536
1537#[cfg(test)]
1538mod tests {
1539 use super::*;
1540
1541 #[test]
1542 fn skipped_large_file_diagnostic_id_and_message() {
1543 let root = Path::new("/project");
1544 let diag = WorkspaceDiagnostic::new(
1545 root,
1546 root.join("src/vendor/app.bundle.js"),
1547 WorkspaceDiagnosticKind::SkippedLargeFile {
1548 size_bytes: 6 * 1024 * 1024,
1549 },
1550 );
1551 assert_eq!(diag.kind.id(), "skipped-large-file");
1552 assert!(
1553 diag.message.contains("src/vendor/app.bundle.js"),
1554 "message names the project-relative path: {}",
1555 diag.message
1556 );
1557 assert!(
1558 diag.message.contains("6.0 MB"),
1559 "message reports the size: {}",
1560 diag.message
1561 );
1562 assert!(
1563 diag.message.contains("--max-file-size"),
1564 "message names the override flag: {}",
1565 diag.message
1566 );
1567 }
1568
1569 #[test]
1570 fn skipped_minified_file_diagnostic_id_and_message() {
1571 let root = Path::new("/project");
1572 let diag = WorkspaceDiagnostic::new(
1573 root,
1574 root.join("src/assets/index-abc123.js"),
1575 WorkspaceDiagnosticKind::SkippedMinifiedFile {
1576 size_bytes: 2 * 1024 * 1024,
1577 },
1578 );
1579 assert_eq!(diag.kind.id(), "skipped-minified-file");
1580 assert!(
1581 diag.message.contains("src/assets/index-abc123.js"),
1582 "message names the project-relative path: {}",
1583 diag.message
1584 );
1585 assert!(
1586 diag.message.contains("2.0 MB"),
1587 "message reports the size: {}",
1588 diag.message
1589 );
1590 assert!(
1591 diag.message.contains("--max-file-size 0"),
1592 "message names the opt-out: {}",
1593 diag.message
1594 );
1595 }
1596
1597 #[test]
1598 fn skipped_source_dotdir_diagnostic_id_and_message() {
1599 let root = Path::new("/project");
1600 let diag = WorkspaceDiagnostic::new(
1601 root,
1602 root.join(".claude"),
1603 WorkspaceDiagnosticKind::SkippedSourceDotdir,
1604 );
1605 assert_eq!(diag.kind.id(), "skipped-source-dotdir");
1606 assert!(
1607 diag.message.contains(".claude"),
1608 "message names the project-relative path: {}",
1609 diag.message
1610 );
1611 assert!(
1612 diag.message
1613 .contains("Its imports and exports are not analyzed."),
1614 "message states the consequence: {}",
1615 diag.message
1616 );
1617 for remedy in [
1618 "add the file to entry",
1619 "the export to ignoreExports",
1620 "the dependency to ignoreDependencies",
1621 ] {
1622 assert!(
1623 diag.message.contains(remedy),
1624 "message names the remedy `{remedy}`: {}",
1625 diag.message
1626 );
1627 }
1628 assert!(
1629 diag.message
1630 .contains("fallow --root .claude analyzes only that directory")
1631 && diag.message.contains("does not fix this run"),
1632 "message must not imply that --root fixes this run: {}",
1633 diag.message
1634 );
1635 assert!(
1636 diag.message.contains("ignorePatterns"),
1637 "message names the silencing route: {}",
1638 diag.message
1639 );
1640 assert!(
1641 diag.message.contains("no config field"),
1642 "the message must say plainly that no config field traverses it: {}",
1643 diag.message
1644 );
1645 assert_eq!(
1646 serde_json::to_value(&diag).expect("serializes")["kind"],
1647 "skipped-source-dotdir",
1648 "id() must byte-match the serde kebab-case tag"
1649 );
1650 }
1651
1652 #[cfg(feature = "schema")]
1653 #[test]
1654 fn workspace_diagnostic_schema_includes_skipped_source_dotdir() {
1655 let schema = schemars::schema_for!(WorkspaceDiagnostic);
1656 let json = serde_json::to_string(&schema).expect("schema serializes");
1657 assert!(json.contains("skipped-source-dotdir"));
1658 }
1659
1660 #[test]
1661 fn source_read_failure_serializes_typed_error_payload() {
1662 let root = Path::new("/project");
1663 let diagnostic = WorkspaceDiagnostic::new(
1664 root,
1665 root.join("src/removed.ts"),
1666 WorkspaceDiagnosticKind::SourceReadFailure {
1667 error: "No such file or directory".to_string(),
1668 },
1669 );
1670
1671 let json = serde_json::to_value(&diagnostic).expect("diagnostic serializes");
1672 assert_eq!(json["kind"], "source-read-failure");
1673 assert_eq!(
1674 json["path"],
1675 root.join("src/removed.ts")
1676 .display()
1677 .to_string()
1678 .replace('\\', "/")
1679 );
1680 assert_eq!(json["error"], "No such file or directory");
1681 assert!(
1682 json["message"]
1683 .as_str()
1684 .is_some_and(|message| message.contains("src/removed.ts"))
1685 );
1686 }
1687
1688 #[cfg(feature = "schema")]
1689 #[test]
1690 fn workspace_diagnostic_schema_includes_source_read_failure() {
1691 let schema = schemars::schema_for!(WorkspaceDiagnostic);
1692 let json = serde_json::to_string(&schema).expect("schema serializes");
1693 assert!(json.contains("source-read-failure"));
1694 assert!(json.contains("error"));
1695 }
1696
1697 #[test]
1698 fn bun_lockb_override_resolution_skipped_id_and_message() {
1699 let root = Path::new("/project");
1700 let diag = WorkspaceDiagnostic::new(
1701 root,
1702 root.join("package.json"),
1703 WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
1704 );
1705 assert_eq!(diag.kind.id(), "bun-lockb-override-resolution-skipped");
1706 assert!(
1707 diag.message.contains("'package.json'"),
1708 "message names the project-relative manifest: {}",
1709 diag.message
1710 );
1711 assert!(
1712 diag.message.contains("no parseable text lockfile"),
1713 "message states the cause: {}",
1714 diag.message
1715 );
1716 assert!(
1717 !diag.message.contains("only bun.lockb"),
1718 "message must not claim bun.lockb is the only lockfile; yarn.lock or an unparseable \
1719 bun.lock may sit beside it: {}",
1720 diag.message
1721 );
1722 assert!(
1723 diag.message.contains("bun install --save-text-lockfile")
1724 && diag.message.contains("delete the stale bun.lockb"),
1725 "message ends with the text-lockfile next step and the stale-lockb alternative: {}",
1726 diag.message
1727 );
1728 let json = serde_json::to_value(&diag).expect("diagnostic serializes");
1729 assert_eq!(json["kind"], "bun-lockb-override-resolution-skipped");
1730 }
1731
1732 #[test]
1733 fn bun_override_diagnostic_ids_and_messages_are_actionable() {
1734 let root = Path::new("/project");
1735 let malformed = WorkspaceDiagnostic::new(
1736 root,
1737 root.join("bun.lock"),
1738 WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
1739 );
1740 assert_eq!(malformed.kind.id(), "bun-lock-override-resolution-skipped");
1741 assert!(malformed.message.contains("regenerate"));
1742
1743 let shadowed = WorkspaceDiagnostic::new(
1744 root,
1745 root.join("package.json"),
1746 WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
1747 );
1748 assert_eq!(shadowed.kind.id(), "bun-resolutions-shadowed-by-overrides");
1749 assert!(shadowed.message.contains("ignores `resolutions`"));
1750 }
1751
1752 #[test]
1753 fn into_root_relative_strips_the_root_and_keeps_outside_paths_absolute() {
1754 let root = Path::new("/project");
1755 let inside = WorkspaceDiagnostic::new(
1756 root,
1757 root.join("packages/inner"),
1758 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1759 )
1760 .into_root_relative(root);
1761 assert_eq!(inside.path, Path::new("packages/inner"));
1762
1763 let outside = WorkspaceDiagnostic::new(
1764 root,
1765 PathBuf::from("/elsewhere/packages/inner"),
1766 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1767 )
1768 .into_root_relative(root);
1769 assert_eq!(outside.path, Path::new("/elsewhere/packages/inner"));
1770 }
1771
1772 #[test]
1773 fn analysis_stage_classification_covers_only_analyze_stage_kinds() {
1774 let analysis_stage = [
1775 WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml {
1776 error: "bad yaml".to_owned(),
1777 },
1778 WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
1779 WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
1780 WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
1781 ];
1782 for kind in &analysis_stage {
1783 assert!(
1784 kind.is_analysis_stage() && !kind.is_source_discovery(),
1785 "{} is recorded by the analyze stage only",
1786 kind.id()
1787 );
1788 }
1789
1790 let other = [
1791 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1792 WorkspaceDiagnosticKind::MalformedPackageJson {
1793 error: "trailing comma".to_owned(),
1794 },
1795 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1796 pattern: "packages/*".to_owned(),
1797 },
1798 WorkspaceDiagnosticKind::MalformedTsconfig {
1799 error: "unexpected token".to_owned(),
1800 },
1801 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
1802 WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 1 },
1803 WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes: 1 },
1804 WorkspaceDiagnosticKind::SkippedSourceDotdir,
1805 WorkspaceDiagnosticKind::SourceReadFailure {
1806 error: "permission denied".to_owned(),
1807 },
1808 ];
1809 for kind in &other {
1810 assert!(
1811 !kind.is_analysis_stage(),
1812 "{} is a discovery kind, not an analyze-stage kind",
1813 kind.id()
1814 );
1815 }
1816 }
1817
1818 #[test]
1819 fn merge_keeps_two_diagnostics_that_share_a_kind_id_and_path() {
1820 let root = Path::new("/project");
1821 let first = WorkspaceDiagnostic::new(
1822 root,
1823 root.join("packages/aaa"),
1824 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1825 pattern: "packages/*".to_owned(),
1826 },
1827 );
1828 let second = WorkspaceDiagnostic::new(
1829 root,
1830 root.join("packages/aaa"),
1831 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1832 pattern: "packages/a*".to_owned(),
1833 },
1834 );
1835
1836 let merged =
1837 merge_workspace_diagnostics(vec![first.clone(), second.clone()], vec![first, second]);
1838
1839 let patterns: Vec<String> = merged
1840 .iter()
1841 .map(|diagnostic| match &diagnostic.kind {
1842 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => pattern.clone(),
1843 other => panic!("unexpected kind {}", other.id()),
1844 })
1845 .collect();
1846 assert_eq!(
1847 patterns,
1848 ["packages/*", "packages/a*"],
1849 "two overlapping globs report the same directory twice, with their own pattern; \
1850 the same entry seen from two observation points still folds to one"
1851 );
1852 }
1853
1854 /// Issue #2366: a repository that declares one glob in two manifests
1855 /// (`"./apps/**"` in `package.json`, `apps/**` in `pnpm-workspace.yaml`)
1856 /// must not report every package-less directory under it twice now that the
1857 /// payload is part of the dedupe key.
1858 #[test]
1859 fn merge_folds_two_spellings_of_one_glob_into_one_diagnostic() {
1860 let root = Path::new("/project");
1861 let dotted = WorkspaceDiagnostic::new(
1862 root,
1863 root.join("apps/site/.next/cache"),
1864 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1865 pattern: "./apps/**".to_owned(),
1866 },
1867 );
1868 let bare = WorkspaceDiagnostic::new(
1869 root,
1870 root.join("apps/site/.next/cache"),
1871 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1872 pattern: "apps/**".to_owned(),
1873 },
1874 );
1875 assert_eq!(
1876 dotted.kind, bare.kind,
1877 "the no-op ./ prefix is normalised out of the recorded pattern"
1878 );
1879 assert!(
1880 dotted.message.contains("Glob 'apps/**'"),
1881 "the message renders the normalised pattern: {}",
1882 dotted.message
1883 );
1884
1885 let merged = merge_workspace_diagnostics(vec![dotted], vec![bare]);
1886 assert_eq!(
1887 merged.len(),
1888 1,
1889 "one glob declared twice is one diagnostic: {merged:?}"
1890 );
1891 }
1892
1893 /// A glob spelled exactly `"./"` (the project root itself) is the one
1894 /// pattern the prefix strip must leave alone: an empty `pattern` field
1895 /// names no glob, and the warning would quote nothing.
1896 #[test]
1897 fn new_keeps_a_root_only_glob_spelling_and_still_strips_a_real_prefix() {
1898 let root = Path::new("/project");
1899 let recorded = |pattern: &str| {
1900 let diagnostic = WorkspaceDiagnostic::new(
1901 root,
1902 root.join("pkgs"),
1903 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1904 pattern: pattern.to_owned(),
1905 },
1906 );
1907 let WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } = diagnostic.kind
1908 else {
1909 panic!("constructed a glob-matched-no-package-json diagnostic");
1910 };
1911 (pattern, diagnostic.message)
1912 };
1913
1914 let (root_pattern, root_message) = recorded("./");
1915 assert_eq!(root_pattern, "./", "a root-only glob keeps its spelling");
1916 assert!(
1917 root_message.contains("Glob './'"),
1918 "the warning names the glob the manifest declared: {root_message}"
1919 );
1920 assert_eq!(recorded(".\\").0, ".\\");
1921 assert_eq!(recorded("./pkgs/*").0, "pkgs/*");
1922 assert_eq!(recorded(".\\pkgs\\*").0, "pkgs\\*");
1923 }
1924
1925 /// Issue #2366, the path half of the same repository shape: expanding
1926 /// `./pkgs/*` joins the no-op `.` into every match, so the two manifests
1927 /// hand one directory to the diagnostic under two spellings. Both must
1928 /// store, render and serialise as the bare one, otherwise whichever
1929 /// manifest was read first decides whether the analysis envelopes print
1930 /// `./pkgs/aaa` while the workspace listing envelope prints `pkgs/aaa`.
1931 #[test]
1932 fn new_stores_one_spelling_for_a_directory_reached_through_a_dotted_glob() {
1933 let root = Path::new("/project");
1934 let dotted = WorkspaceDiagnostic::new(
1935 root,
1936 root.join("./pkgs/aaa"),
1937 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1938 pattern: "./pkgs/*".to_owned(),
1939 },
1940 );
1941 let bare = WorkspaceDiagnostic::new(
1942 root,
1943 root.join("pkgs/aaa"),
1944 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1945 pattern: "pkgs/*".to_owned(),
1946 },
1947 );
1948
1949 let spelling = |diagnostic: &WorkspaceDiagnostic| {
1950 diagnostic.path.display().to_string().replace('\\', "/")
1951 };
1952 assert_eq!(
1953 spelling(&dotted),
1954 "/project/pkgs/aaa",
1955 "the stored path drops the no-op . component, which Path equality \
1956 hides but serialization does not"
1957 );
1958 assert_eq!(spelling(&dotted), spelling(&bare));
1959 assert_eq!(
1960 spelling(&dotted.clone().into_root_relative(root)),
1961 "pkgs/aaa"
1962 );
1963
1964 let merged = merge_workspace_diagnostics(vec![dotted], vec![bare]);
1965 assert_eq!(
1966 merged.len(),
1967 1,
1968 "one directory reached through two spellings of one glob: {merged:?}"
1969 );
1970 }
1971
1972 /// The single-list fold applied at workspace discovery keeps one entry per
1973 /// `(kind, path)` and leaves distinct payloads alone.
1974 #[test]
1975 fn dedupe_keeps_first_of_each_pair_and_every_distinct_payload() {
1976 let root = Path::new("/project");
1977 let glob = |pattern: &str, relative: &str| {
1978 WorkspaceDiagnostic::new(
1979 root,
1980 root.join(relative),
1981 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1982 pattern: pattern.to_owned(),
1983 },
1984 )
1985 };
1986
1987 let deduped = dedupe_workspace_diagnostics(vec![
1988 glob("pkgs/*", "pkgs/aaa"),
1989 glob("pkgs/*", "pkgs/bbb"),
1990 glob("./pkgs/*", "./pkgs/aaa"),
1991 glob("pkgs/a*", "pkgs/aaa"),
1992 ]);
1993
1994 let reported: Vec<(String, String)> = deduped
1995 .iter()
1996 .map(|diagnostic| match &diagnostic.kind {
1997 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => (
1998 pattern.clone(),
1999 diagnostic.path.display().to_string().replace('\\', "/"),
2000 ),
2001 other => panic!("unexpected kind {}", other.id()),
2002 })
2003 .collect();
2004
2005 assert_eq!(
2006 reported,
2007 vec![
2008 ("pkgs/*".to_owned(), "/project/pkgs/aaa".to_owned()),
2009 ("pkgs/*".to_owned(), "/project/pkgs/bbb".to_owned()),
2010 ("pkgs/a*".to_owned(), "/project/pkgs/aaa".to_owned()),
2011 ],
2012 "the duplicate spelling folds away and the overlapping glob stays"
2013 );
2014 }
2015
2016 /// The class `reachability_caveats[]` is computed from. Every kind here
2017 /// means the run never read a file that is part of the project, so its
2018 /// imports credit nothing and the modules it imports can be reported
2019 /// unused with a removal action on them. Classifying a kind `true` is the
2020 /// only wiring its findings need to inherit the caveat and the `fallow fix`
2021 /// withholding that follows it.
2022 #[test]
2023 fn source_never_analyzed_covers_every_file_the_run_did_not_read() {
2024 for kind in [
2025 WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 1 },
2026 WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes: 1 },
2027 WorkspaceDiagnosticKind::SkippedSourceDotdir,
2028 WorkspaceDiagnosticKind::SourceReadFailure {
2029 error: "permission denied".to_owned(),
2030 },
2031 ] {
2032 assert!(
2033 kind.source_never_analyzed(),
2034 "{} names a source file this run never read",
2035 kind.id()
2036 );
2037 }
2038
2039 let degraded = WorkspaceDiagnosticKind::SourceParseDegraded {
2040 error_count: 3,
2041 panicked: false,
2042 };
2043 assert!(
2044 !degraded.source_never_analyzed(),
2045 "a degraded parse read the file, so it has a graph node and its reachability is \
2046 observable; the caveat pass narrows it instead of treating it as unread"
2047 );
2048
2049 for kind in [
2050 WorkspaceDiagnosticKind::UndeclaredWorkspace,
2051 WorkspaceDiagnosticKind::MalformedPackageJson {
2052 error: "trailing comma".to_owned(),
2053 },
2054 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
2055 pattern: "packages/*".to_owned(),
2056 },
2057 WorkspaceDiagnosticKind::MalformedTsconfig {
2058 error: "unexpected token".to_owned(),
2059 },
2060 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
2061 WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml {
2062 error: "bad indent".to_owned(),
2063 },
2064 WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
2065 WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
2066 WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
2067 WorkspaceDiagnosticKind::NodeModulesMissing,
2068 WorkspaceDiagnosticKind::BoundariesNotConfigured,
2069 WorkspaceDiagnosticKind::RulePacksNotConfigured,
2070 ] {
2071 assert!(
2072 !kind.source_never_analyzed(),
2073 "{} says nothing about a source file's imports going unseen",
2074 kind.id()
2075 );
2076 }
2077 }
2078
2079 #[test]
2080 fn source_walk_recorded_covers_only_the_kinds_a_walk_replaces() {
2081 for kind in [
2082 WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 1 },
2083 WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes: 1 },
2084 WorkspaceDiagnosticKind::SkippedSourceDotdir,
2085 ] {
2086 assert!(
2087 kind.is_source_walk_recorded() && kind.is_source_discovery(),
2088 "{} is written by the source walk",
2089 kind.id()
2090 );
2091 }
2092
2093 let read_failure = WorkspaceDiagnosticKind::SourceReadFailure {
2094 error: "permission denied".to_owned(),
2095 };
2096 assert!(
2097 read_failure.is_source_discovery() && !read_failure.is_source_walk_recorded(),
2098 "the parse stage records source-read-failure after the walk, so it must keep \
2099 reaching sessions through the registry"
2100 );
2101
2102 for kind in [
2103 WorkspaceDiagnosticKind::UndeclaredWorkspace,
2104 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
2105 WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
2106 WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
2107 WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
2108 ] {
2109 assert!(
2110 !kind.is_source_walk_recorded(),
2111 "{} is not written by the source walk",
2112 kind.id()
2113 );
2114 }
2115 }
2116
2117 /// Issue #2638, the single most load-bearing classification in the new
2118 /// kind. Answering `true` here would attach `IncompleteFileAnalysis` and
2119 /// `IncompleteImportGraph` caveats to findings on nearly every project
2120 /// that keeps a non-gitignored `dist/` or `coverage/`, and make
2121 /// `fallow fix` withhold `delete-file` and `remove-export` project-wide.
2122 /// A built-in exclusion is designed behavior on generated output, not a
2123 /// degraded run.
2124 #[test]
2125 fn a_built_in_ignore_exclusion_is_not_a_file_the_run_failed_to_analyze() {
2126 let kind = WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
2127 pattern: "**/build/**".to_owned(),
2128 file_count: 3,
2129 directory_count: 1,
2130 };
2131 assert!(!kind.source_never_analyzed());
2132 }
2133
2134 /// Issue #2638: these exclusions fire in the product's default state on
2135 /// most monorepos, so a default stderr line would be permanent noise that
2136 /// names no defect. The CLI prints a note under `--explain-skipped`
2137 /// instead.
2138 #[test]
2139 fn a_built_in_ignore_exclusion_does_not_warn_on_stderr_by_default() {
2140 let kind = WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
2141 pattern: "**/build/**".to_owned(),
2142 file_count: 3,
2143 directory_count: 1,
2144 };
2145 assert!(!kind.warns_on_stderr());
2146 }
2147
2148 /// Issue #2638 plus issue #2366: the walk writes it, so it has to be
2149 /// classified as source-discovery (or combined mode's per-analysis config
2150 /// reloads wipe it before serialization) AND as walk-recorded (or a
2151 /// concurrent walk's tally is folded into another analysis's list).
2152 #[test]
2153 fn a_built_in_ignore_exclusion_is_walk_recorded_source_discovery() {
2154 let kind = WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
2155 pattern: "**/build/**".to_owned(),
2156 file_count: 3,
2157 directory_count: 1,
2158 };
2159 assert!(kind.is_source_discovery());
2160 assert!(kind.is_source_walk_recorded());
2161 assert!(!kind.is_analysis_stage());
2162 assert_eq!(kind.id(), "excluded-by-default-ignore");
2163 }
2164
2165 /// Issue #2638: the message has to name the pattern the reader cannot see,
2166 /// the directory, and the only remedy that actually analyzes the tree.
2167 /// `ignorePatterns` is not that remedy: the compiled set unions, so it
2168 /// cannot negate a built-in.
2169 #[test]
2170 fn a_built_in_ignore_exclusion_message_names_the_pattern_and_the_root_remedy() {
2171 let root = Path::new("/project");
2172 let diag = WorkspaceDiagnostic::new(
2173 root,
2174 root.join("packages/web/build"),
2175 WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
2176 pattern: "**/build/**".to_owned(),
2177 file_count: 4,
2178 directory_count: 1,
2179 },
2180 );
2181 assert!(diag.message.contains("**/build/**"), "{}", diag.message);
2182 assert!(
2183 diag.message.contains("packages/web/build"),
2184 "{}",
2185 diag.message
2186 );
2187 assert!(
2188 diag.message.contains("fallow --root packages/web/build"),
2189 "the remedy is copy-pasteable: {}",
2190 diag.message
2191 );
2192 assert!(
2193 diag.message
2194 .contains("cannot be switched off through ignorePatterns"),
2195 "the message must not advertise a negation that does not exist: {}",
2196 diag.message
2197 );
2198 }
2199
2200 /// One excluded file reads as one file, not as "1 source files".
2201 #[test]
2202 fn a_single_excluded_file_message_is_singular() {
2203 let root = Path::new("/project");
2204 let diag = WorkspaceDiagnostic::new(
2205 root,
2206 root.join("dist"),
2207 WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
2208 pattern: "**/dist/**".to_owned(),
2209 file_count: 1,
2210 directory_count: 1,
2211 },
2212 );
2213 assert!(
2214 diag.message
2215 .starts_with("Skipped 1 source file under 'dist'"),
2216 "{}",
2217 diag.message
2218 );
2219 assert!(diag.message.contains("it matches"), "{}", diag.message);
2220 assert!(
2221 diag.message
2222 .contains("nothing it imports, exports, or defines"),
2223 "the whole sentence agrees in number, not just its first clause: {}",
2224 diag.message
2225 );
2226 }
2227
2228 /// The anchor directory is the largest group, never a majority: ten
2229 /// packages each holding one excluded file make every one of them "the
2230 /// largest", and a message claiming otherwise is false on exactly the flat
2231 /// monorepo shape issue #2638 is about.
2232 #[test]
2233 fn a_scattered_exclusion_names_the_largest_group_and_counts_the_directories() {
2234 let root = Path::new("/project");
2235 let diag = WorkspaceDiagnostic::new(
2236 root,
2237 root.join("packages/a/dist"),
2238 WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
2239 pattern: "**/dist/**".to_owned(),
2240 file_count: 10,
2241 directory_count: 10,
2242 },
2243 );
2244 assert!(
2245 diag.message.starts_with(
2246 "Skipped 10 source files across 10 directories, the largest group under \
2247 'packages/a/dist'"
2248 ),
2249 "{}",
2250 diag.message
2251 );
2252 assert!(
2253 !diag.message.contains("the most of them"),
2254 "a max-of-group is not a majority: {}",
2255 diag.message
2256 );
2257 }
2258
2259 /// A file-shaped built-in matches on the file name, so the `--root` remedy
2260 /// the directory-shaped patterns get would re-exclude the same file. The
2261 /// message must not print a command that provably does nothing.
2262 #[test]
2263 fn a_file_shaped_pattern_does_not_advertise_the_root_remedy() {
2264 let root = Path::new("/project");
2265 let diag = WorkspaceDiagnostic::new(
2266 root,
2267 root.join("vendor"),
2268 WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
2269 pattern: "**/*.min.js".to_owned(),
2270 file_count: 2,
2271 directory_count: 1,
2272 },
2273 );
2274 assert!(
2275 !diag.message.contains("fallow --root"),
2276 "the message explains why re-rooting fails, it does not prescribe it: {}",
2277 diag.message
2278 );
2279 assert!(
2280 diag.message.contains("matches a file name"),
2281 "the message says why: {}",
2282 diag.message
2283 );
2284 assert!(
2285 diag.message.contains("Rename"),
2286 "and names the remedy that does work: {}",
2287 diag.message
2288 );
2289 }
2290
2291 /// A built-in that matched a file sitting directly at the analysis root
2292 /// anchors at the root, and an empty string is not a location.
2293 #[test]
2294 fn a_root_anchored_exclusion_renders_its_location_as_dot() {
2295 let root = Path::new("/project");
2296 let diag = WorkspaceDiagnostic::new(
2297 root,
2298 root.to_path_buf(),
2299 WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
2300 pattern: "**/*.min.js".to_owned(),
2301 file_count: 1,
2302 directory_count: 1,
2303 },
2304 );
2305 assert!(
2306 diag.message.starts_with("Skipped 1 source file under '.'"),
2307 "{}",
2308 diag.message
2309 );
2310 }
2311
2312 #[test]
2313 fn glob_first_literal_segment_skips_wildcards_and_dot_components() {
2314 assert_eq!(glob_first_literal_segment("**/build/**"), Some("build"));
2315 assert_eq!(glob_first_literal_segment("./dist/**"), Some("dist"));
2316 assert_eq!(glob_first_literal_segment("**/*.min.js"), None);
2317 assert_eq!(glob_first_literal_segment("**/*.bundle.js"), None);
2318 assert_eq!(glob_first_literal_segment("**/{a,b}/**"), None);
2319 }
2320
2321 #[test]
2322 fn format_size_mb_one_decimal() {
2323 assert_eq!(format_size_mb(0), "0.0 MB");
2324 assert_eq!(format_size_mb(5 * 1024 * 1024), "5.0 MB");
2325 assert_eq!(format_size_mb(1024 * 1024 + 512 * 1024), "1.5 MB");
2326 }
2327
2328 #[test]
2329 fn undeclared_workspace_message_has_next_step() {
2330 let root = Path::new("/project");
2331 let diag = WorkspaceDiagnostic::new(
2332 root,
2333 root.join("packages/legacy"),
2334 WorkspaceDiagnosticKind::UndeclaredWorkspace,
2335 );
2336 assert_eq!(diag.kind.id(), "undeclared-workspace");
2337 assert!(diag.message.contains("packages/legacy"), "{}", diag.message);
2338 assert!(
2339 diag.message.contains("ignorePatterns"),
2340 "next-step hint preserved: {}",
2341 diag.message
2342 );
2343 }
2344 /// The seven health-pipeline kinds (issue #2689). Each is classified in
2345 /// four places, and getting one wrong is silent: an entry that answers
2346 /// `is_analysis_stage` is wiped by the dead-code pass the health run itself
2347 /// invokes, and one that answers `is_source_discovery` is preserved by the
2348 /// wrong mechanism.
2349 #[test]
2350 fn health_stage_kinds_are_classified_as_health_stage_and_nothing_else() {
2351 for kind in [
2352 WorkspaceDiagnosticKind::FileScoresUnavailable {
2353 error: "boom".to_owned(),
2354 },
2355 WorkspaceDiagnosticKind::HotspotsSkipped {
2356 cause: "not-a-repository".to_owned(),
2357 },
2358 WorkspaceDiagnosticKind::ShallowClone {
2359 ownership_requested: true,
2360 },
2361 WorkspaceDiagnosticKind::UnpinnedClock,
2362 WorkspaceDiagnosticKind::OwnershipUnavailable {
2363 cause: "codeowners-parse-failed".to_owned(),
2364 error: "boom".to_owned(),
2365 },
2366 WorkspaceDiagnosticKind::TrendSnapshotUnreadable {
2367 error: "boom".to_owned(),
2368 },
2369 WorkspaceDiagnosticKind::CoverageAutoDetected,
2370 ] {
2371 let id = kind.id();
2372 assert!(kind.is_health_stage(), "{id} must be health-stage");
2373 assert!(!kind.is_analysis_stage(), "{id} must not be analysis-stage");
2374 assert!(
2375 !kind.is_source_discovery(),
2376 "{id} must not be source-discovery"
2377 );
2378 assert!(
2379 !kind.is_source_walk_recorded(),
2380 "{id} must not be walk-recorded"
2381 );
2382 assert!(
2383 !kind.source_never_analyzed(),
2384 "{id} reports an input, not an unread source file"
2385 );
2386 }
2387 }
2388
2389 /// Six of the seven report a result the run could not measure as asked;
2390 /// the coverage provenance entry does not, and a consumer sentence about a
2391 /// degraded run must not fire for it.
2392 #[test]
2393 fn only_the_coverage_provenance_kind_does_not_degrade_the_analysis() {
2394 assert!(
2395 WorkspaceDiagnosticKind::HotspotsSkipped {
2396 cause: "invalid-since".to_owned(),
2397 }
2398 .warns_on_stderr(),
2399 "a skipped hotspot section is a degraded result"
2400 );
2401 assert!(
2402 WorkspaceDiagnosticKind::UnpinnedClock.warns_on_stderr(),
2403 "a drifting measurement is a degraded result"
2404 );
2405 assert!(
2406 !WorkspaceDiagnosticKind::CoverageAutoDetected.warns_on_stderr(),
2407 "auto-detected coverage loaded fine and degraded nothing"
2408 );
2409 }
2410
2411 /// Each skip cause carries its own remedy, and the original cause's wording
2412 /// is frozen: it shipped before the token existed, so a reader who matched
2413 /// on that sentence must still match on it.
2414 #[test]
2415 fn every_hotspot_skip_cause_renders_its_own_remedy() {
2416 let root = Path::new("/project");
2417 let skipped = |cause: &str, path: PathBuf| {
2418 WorkspaceDiagnostic::new(
2419 root,
2420 path,
2421 WorkspaceDiagnosticKind::HotspotsSkipped {
2422 cause: cause.to_owned(),
2423 },
2424 )
2425 };
2426
2427 let no_repo = skipped("not-a-repository", root.to_path_buf());
2428 assert_eq!(
2429 no_repo.message,
2430 "Hotspot analysis was skipped because no git repository was found at the project \
2431 root, so the hotspots, churn and ownership sections report nothing rather than \
2432 zero. Run fallow inside the repository, or pass --churn-file with exported change \
2433 history."
2434 );
2435
2436 let bad_since = skipped("invalid-since", root.to_path_buf());
2437 assert!(
2438 bad_since.message.contains("--since")
2439 && bad_since.message.contains("6m or 90d")
2440 && !bad_since.message.contains("no git repository"),
2441 "a malformed window is respelled, not moved into a repository: {}",
2442 bad_since.message
2443 );
2444
2445 let churn = skipped("churn-file-unreadable", root.join("build/churn.json"));
2446 assert!(
2447 churn.message.contains("'build/churn.json'") && churn.message.contains("rerun"),
2448 "the remedy names the file that changed under the run: {}",
2449 churn.message
2450 );
2451
2452 let unborn = skipped("no-commits", root.to_path_buf());
2453 assert!(
2454 unborn.message.contains("no commits")
2455 && unborn.message.contains("--churn-file")
2456 && !unborn.message.contains("no git repository"),
2457 "a branch without a commit is told to commit, not to move: {}",
2458 unborn.message
2459 );
2460
2461 for diagnostic in [&no_repo, &unborn, &bad_since, &churn] {
2462 assert!(
2463 diagnostic.degrades_analysis,
2464 "every skip leaves the hotspot sections unmeasured: {}",
2465 diagnostic.message
2466 );
2467 }
2468 }
2469
2470 /// The message is the only prose a consumer renders, so each one must name
2471 /// the consequence and a next step rather than restate the kind.
2472 #[test]
2473 fn health_stage_messages_name_a_next_step() {
2474 let root = Path::new("/project");
2475 let shallow = WorkspaceDiagnostic::new(
2476 root,
2477 root.to_path_buf(),
2478 WorkspaceDiagnosticKind::ShallowClone {
2479 ownership_requested: true,
2480 },
2481 );
2482 assert!(
2483 shallow.message.contains("git fetch --unshallow"),
2484 "{}",
2485 shallow.message
2486 );
2487 assert!(
2488 shallow.message.contains("Ownership signals are skewed too"),
2489 "a run that asked for ownership is told what else it costs: {}",
2490 shallow.message
2491 );
2492 let without_ownership = WorkspaceDiagnostic::new(
2493 root,
2494 root.to_path_buf(),
2495 WorkspaceDiagnosticKind::ShallowClone {
2496 ownership_requested: false,
2497 },
2498 );
2499 assert!(
2500 !without_ownership.message.contains("Ownership"),
2501 "a run that did not ask for ownership is not told about it: {}",
2502 without_ownership.message
2503 );
2504
2505 let coverage = WorkspaceDiagnostic::new(
2506 root,
2507 root.join("coverage/coverage-final.json"),
2508 WorkspaceDiagnosticKind::CoverageAutoDetected,
2509 );
2510 assert_eq!(coverage.kind.id(), "coverage-auto-detected");
2511 assert!(
2512 coverage
2513 .message
2514 .contains("--coverage 'coverage/coverage-final.json'"),
2515 "the remedy names the file that fed the score: {}",
2516 coverage.message
2517 );
2518 assert!(
2519 !coverage.degrades_analysis,
2520 "provenance is not a degraded run"
2521 );
2522
2523 let ownership = WorkspaceDiagnostic::new(
2524 root,
2525 root.to_path_buf(),
2526 WorkspaceDiagnosticKind::OwnershipUnavailable {
2527 cause: "invalid-bot-pattern".to_owned(),
2528 error: "unclosed".to_owned(),
2529 },
2530 );
2531 assert!(
2532 ownership.message.contains("botPatterns"),
2533 "the two causes render different remedies: {}",
2534 ownership.message
2535 );
2536 }
2537
2538 fn plugin_unreadable(key: &str, reason: &str) -> WorkspaceDiagnostic {
2539 WorkspaceDiagnostic::new(
2540 Path::new("/project"),
2541 PathBuf::from("/project/module-federation.config.ts"),
2542 WorkspaceDiagnosticKind::PluginConfigUnreadable {
2543 plugin: "module-federation".to_owned(),
2544 key: key.to_owned(),
2545 reason: reason.to_owned(),
2546 },
2547 )
2548 }
2549
2550 fn plugin_not_modeled(key: &str, reason: &str) -> WorkspaceDiagnostic {
2551 WorkspaceDiagnostic::new(
2552 Path::new("/project"),
2553 PathBuf::from("/project/nuxt.config.ts"),
2554 WorkspaceDiagnosticKind::PluginEffectNotModeled {
2555 plugin: "nuxt".to_owned(),
2556 key: key.to_owned(),
2557 reason: reason.to_owned(),
2558 },
2559 )
2560 }
2561
2562 /// The plugin stage is its own stage: classified there and nowhere else, so
2563 /// the stash preserve keeps it and no other stage's clear wipes it.
2564 #[test]
2565 fn plugin_stage_kinds_are_classified_as_plugin_stage_and_nothing_else() {
2566 for kind in [
2567 WorkspaceDiagnosticKind::PluginConfigUnreadable {
2568 plugin: "module-federation".to_owned(),
2569 key: "exposes".to_owned(),
2570 reason: "not-object-literal".to_owned(),
2571 },
2572 WorkspaceDiagnosticKind::PluginEffectNotModeled {
2573 plugin: "nuxt".to_owned(),
2574 key: "components".to_owned(),
2575 reason: "key-effect-not-modeled".to_owned(),
2576 },
2577 ] {
2578 let id = kind.id();
2579 assert!(kind.is_plugin_stage(), "{id} must be plugin-stage");
2580 assert!(!kind.is_analysis_stage(), "{id} must not be analysis-stage");
2581 assert!(!kind.is_health_stage(), "{id} must not be health-stage");
2582 assert!(
2583 !kind.is_source_discovery(),
2584 "{id} must not be source-discovery"
2585 );
2586 assert!(
2587 !kind.is_source_walk_recorded(),
2588 "{id} must not be walk-recorded"
2589 );
2590 assert!(
2591 !kind.source_never_analyzed(),
2592 "{id} reports a config file, not an unread source file"
2593 );
2594 }
2595 assert!(
2596 !WorkspaceDiagnosticKind::UnpinnedClock.is_plugin_stage(),
2597 "another stage's kind must not answer the plugin predicate"
2598 );
2599 }
2600
2601 /// An unread declaration costs findings in both directions, so it degrades
2602 /// the analysis; an effect fallow does not model suppresses findings the
2603 /// user opted into and must not warn on every run forever.
2604 #[test]
2605 fn only_the_unreadable_plugin_config_degrades_the_analysis() {
2606 let unreadable = plugin_unreadable("exposes", "not-object-literal");
2607 assert!(
2608 unreadable.degrades_analysis,
2609 "an unread declaration did not reach the analysis: {}",
2610 unreadable.message
2611 );
2612 let not_modeled = plugin_not_modeled("components", "key-effect-not-modeled");
2613 assert!(
2614 !not_modeled.degrades_analysis,
2615 "the config was readable and the patterns stayed: {}",
2616 not_modeled.message
2617 );
2618 }
2619
2620 /// The reason decides the remedy, so each token renders its own situation,
2621 /// and a token from a later release still renders a sentence.
2622 #[test]
2623 fn every_unreadable_reason_renders_its_own_situation() {
2624 let cases = [
2625 ("not-object-literal", "is not a static object literal"),
2626 ("array-form", "uses the array form"),
2627 ("spread", "spreads a value that is not statically readable"),
2628 (
2629 "unreadable-entries",
2630 "has entries that hold no statically readable value",
2631 ),
2632 (
2633 "unrecognized-call",
2634 "is passed through a call that is not a known config wrapper",
2635 ),
2636 (
2637 "import-target-unreadable",
2638 "comes from an imported file that is not statically readable",
2639 ),
2640 (
2641 "dynamic-argument",
2642 "receives an argument that is not a static literal",
2643 ),
2644 ];
2645 for (reason, expected) in cases {
2646 let diagnostic = plugin_unreadable("exposes", reason);
2647 assert!(
2648 diagnostic.message.contains(expected),
2649 "`{reason}` must render its own situation: {}",
2650 diagnostic.message
2651 );
2652 }
2653 let unknown = plugin_unreadable("exposes", "reason-from-a-later-release");
2654 assert!(
2655 unknown.message.contains("could not be read statically"),
2656 "an unrecognised token still renders a sentence: {}",
2657 unknown.message
2658 );
2659 }
2660
2661 /// The payload carries no prose, so the remedy comes from the key: both
2662 /// Module Federation keys name the option that covers the gap, and the
2663 /// message names the config file the user must edit.
2664 #[test]
2665 fn unreadable_plugin_messages_name_the_config_file_and_the_option() {
2666 let exposes = plugin_unreadable("exposes", "not-object-literal");
2667 assert!(
2668 exposes
2669 .message
2670 .contains("`exposes` in 'module-federation.config.ts'")
2671 && exposes.message.contains("dynamicallyLoaded")
2672 && exposes.message.starts_with("Plugin 'module-federation':"),
2673 "{}",
2674 exposes.message
2675 );
2676 let remotes = plugin_unreadable("remotes", "spread");
2677 assert!(
2678 remotes.message.contains("ignoreDependencies"),
2679 "the two keys have different remedies: {}",
2680 remotes.message
2681 );
2682 let unknown_key = plugin_unreadable("shared", "not-object-literal");
2683 assert!(
2684 !unknown_key.message.contains("dynamicallyLoaded")
2685 && !unknown_key.message.contains("ignoreDependencies")
2686 && unknown_key
2687 .message
2688 .contains("Declare the value as a static object literal."),
2689 "a key with no documented consequence falls back to the general claim: {}",
2690 unknown_key.message
2691 );
2692 assert!(
2693 !exposes.message.contains('\n'),
2694 "the sentence travels into a CI annotation and stays on one line: {}",
2695 exposes.message
2696 );
2697 }
2698
2699 /// An unrecognized call was read as a lower bound, and an unreadable import
2700 /// target holds config that is shared across files. Each renders its own
2701 /// consequence and a remedy that names the option, never an object literal.
2702 #[test]
2703 fn the_call_and_import_reasons_render_their_own_remedy() {
2704 let call = plugin_unreadable("exposes", "unrecognized-call");
2705 assert!(
2706 call.message
2707 .contains("only the targets in the object literal it receives")
2708 && call
2709 .message
2710 .contains("Name any other exposed files in `dynamicallyLoaded`."),
2711 "{}",
2712 call.message
2713 );
2714 let call = plugin_unreadable("remotes", "unrecognized-call");
2715 assert!(
2716 call.message
2717 .contains("Name any other aliases in `ignoreDependencies`."),
2718 "{}",
2719 call.message
2720 );
2721 for key in ["exposes", "remotes"] {
2722 let import = plugin_unreadable(key, "import-target-unreadable");
2723 assert!(
2724 !import.message.contains("object literal"),
2725 "an import target is not fixed by writing an object literal: {}",
2726 import.message
2727 );
2728 }
2729 let import = plugin_unreadable("exposes", "import-target-unreadable");
2730 assert!(
2731 import.message.contains("`dynamicallyLoaded`"),
2732 "{}",
2733 import.message
2734 );
2735 }
2736
2737 /// A runtime call with a dynamic argument names the source file and the
2738 /// function, and its remedy names the option that covers the remote.
2739 #[test]
2740 fn a_dynamic_runtime_call_renders_its_own_remedy() {
2741 for (key, consequence) in [
2742 (
2743 "registerRemotes",
2744 "the remotes it registers are not treated as provided",
2745 ),
2746 (
2747 "loadRemote",
2748 "the remote it loads is not treated as provided",
2749 ),
2750 (
2751 "init",
2752 "the remotes its options declare are not treated as provided",
2753 ),
2754 (
2755 "createInstance",
2756 "the remotes its options declare are not treated as provided",
2757 ),
2758 ] {
2759 let diagnostic = plugin_unreadable(key, "dynamic-argument");
2760 assert!(
2761 diagnostic.message.contains(&format!("`{key}` in"))
2762 && diagnostic.message.contains(consequence)
2763 && diagnostic.message.contains("`ignoreDependencies`")
2764 && !diagnostic.message.contains("object literal"),
2765 "{}",
2766 diagnostic.message
2767 );
2768 }
2769 }
2770
2771 /// One config file can hold two unreadable keys, and the payload is what
2772 /// tells them apart: the fold keys on the whole kind, so both survive.
2773 #[test]
2774 fn two_unreadable_keys_in_one_file_are_two_diagnostics() {
2775 let merged = dedupe_workspace_diagnostics(vec![
2776 plugin_unreadable("exposes", "not-object-literal"),
2777 plugin_unreadable("remotes", "spread"),
2778 ]);
2779 assert_eq!(merged.len(), 2, "{merged:?}");
2780 }
2781
2782 /// A surface whose own key fallow cannot model and a config file whose
2783 /// top-level property it cannot read need different remedies. The two tokens
2784 /// render different causes, and each cause states one fact per sentence.
2785 #[test]
2786 fn the_not_modeled_reasons_render_different_causes() {
2787 let key = plugin_not_modeled("components", "key-effect-not-modeled");
2788 assert!(
2789 key.message
2790 .contains("fallow does not model the effect of `components` in 'nuxt.config.ts'.")
2791 && key
2792 .message
2793 .contains("`autoImports` kept the convention entry patterns"),
2794 "{}",
2795 key.message
2796 );
2797 let property = plugin_not_modeled("imports", "config-property-unreadable");
2798 assert!(
2799 property.message.contains(
2800 "fallow cannot read a top-level property in 'nuxt.config.ts', so it cannot \
2801 classify the `imports` surface. `autoImports` kept the convention entry patterns \
2802 for that surface, and fallow reports no unused file there."
2803 ),
2804 "{}",
2805 property.message
2806 );
2807 assert_eq!(
2808 property.message.matches(", so ").count(),
2809 1,
2810 "one cause per sentence: {}",
2811 property.message
2812 );
2813 }
2814
2815 /// A file that reads a whole virtual module names the file and the module,
2816 /// not a config key, and gives a remedy in the source, not in the config.
2817 #[test]
2818 fn an_unreadable_virtual_module_read_names_the_module_and_a_source_remedy() {
2819 let read = WorkspaceDiagnostic::new(
2820 Path::new("/project"),
2821 PathBuf::from("/project/app/lib/registry.ts"),
2822 WorkspaceDiagnosticKind::PluginEffectNotModeled {
2823 plugin: "nuxt".to_owned(),
2824 key: "#components".to_owned(),
2825 reason: "key-effect-not-modeled".to_owned(),
2826 },
2827 );
2828 assert!(
2829 read.message.contains(
2830 "fallow cannot read which names 'app/lib/registry.ts' takes from `#components`"
2831 ) && read.message.contains("member access")
2832 && !read.message.contains("entry patterns"),
2833 "{}",
2834 read.message
2835 );
2836 }
2837}