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#[path = "workspace_config_entry.rs"]
25mod config_entry;
26
27/// Why the declared pnpm version ignores the `overrides` section of
28/// `pnpm-workspace.yaml`.
29#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
30#[cfg_attr(feature = "schema", derive(JsonSchema))]
31#[serde(rename_all = "kebab-case")]
32pub enum PnpmWorkspaceOverridesIgnoredCause {
33 /// pnpm 10 or earlier, and the root `package.json` declares non-empty
34 /// `pnpm.overrides` or `resolutions`. pnpm uses these entries in place
35 /// of the `pnpm-workspace.yaml` overrides.
36 PackageJsonOverrides,
37 /// pnpm before 10.5.1, which does not read the `overrides` section of
38 /// `pnpm-workspace.yaml`.
39 PnpmVersion,
40}
41
42/// Whether the project had a `node_modules` directory at a point in time.
43///
44/// A dead-code baseline records this when it is saved, so a later run in a
45/// different install state can say that its results can differ.
46#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
47#[cfg_attr(feature = "schema", derive(JsonSchema))]
48#[serde(rename_all = "kebab-case")]
49pub enum NodeModulesState {
50 /// `node_modules` existed, or the project legitimately runs without it.
51 Present,
52 /// `node_modules` did not exist.
53 Missing,
54}
55
56impl NodeModulesState {
57 /// The wire token, as it appears in JSON.
58 #[must_use]
59 pub const fn as_str(self) -> &'static str {
60 match self {
61 Self::Present => "present",
62 Self::Missing => "missing",
63 }
64 }
65}
66
67/// Why a workspace-discovery candidate was rejected, or why a sibling
68/// directory looked workspace-like but was not declared.
69///
70/// Wire-format names are kebab-case so JSON consumers (CI integrations, MCP
71/// agents, LSP clients) get a stable, language-neutral identifier.
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
73#[cfg_attr(feature = "schema", derive(JsonSchema))]
74#[serde(tag = "kind", rename_all = "kebab-case")]
75pub enum WorkspaceDiagnosticKind {
76 /// A directory contains `package.json` but is not declared as a workspace
77 /// in `package.json` `workspaces`, `pnpm-workspace.yaml`, or
78 /// `tsconfig.json` `references`. Surfaced by
79 /// `find_undeclared_workspaces`.
80 UndeclaredWorkspace,
81 /// A declared workspace's `package.json` failed to parse. The directory is
82 /// dropped from discovery, but analysis still proceeds (degraded).
83 MalformedPackageJson {
84 /// `serde_json` parse error text.
85 error: String,
86 },
87 /// A workspace glob pattern matched a directory that contains no
88 /// `package.json`. Honors the extended skip list and `ignorePatterns`
89 /// before emitting.
90 GlobMatchedNoPackageJson {
91 /// The glob pattern that matched the directory.
92 pattern: String,
93 },
94 /// A workspace entry is an absolute path or has `..` segments that lead
95 /// above the project root, so it can name directories outside the root. Fallow skips the entry
96 /// before it reads the disk and analyzes no package from it. `path` is
97 /// the file that declares the entry (`package.json`,
98 /// `pnpm-workspace.yaml`, `deno.json` or `deno.jsonc`), or the project
99 /// root for an entry of the fallow config `workspaces.patterns`. The
100 /// diagnostic never names a directory outside the root.
101 ///
102 /// npm, pnpm and yarn accept such an entry, so fallow does not exit with
103 /// an error on it. The project root is the security boundary of a run.
104 WorkspaceGlobOutsideRoot {
105 /// The workspace entry, as written in the file.
106 pattern: String,
107 },
108 /// A positive workspace pattern names a directory that does not exist.
109 /// The check reads the literal part of the pattern before the first
110 /// segment with a glob character (`vite` for `vite/packages/*`). A pattern
111 /// without a literal part (`**/pkg`) never gets this diagnostic, and a
112 /// literal directory that exists but holds no package does not get it
113 /// either. `path` is the file that declares the pattern, or the project
114 /// root for an entry of the fallow config `workspaces.patterns`.
115 ///
116 /// A repository that clones other repositories into the workspace in CI
117 /// declares such patterns. The packages there can consume catalog
118 /// entries, so the run does not report `unused-catalog-entries` while a
119 /// pattern of this kind exists.
120 WorkspacePatternDirMissing {
121 /// The workspace pattern, as written in the file.
122 pattern: String,
123 },
124 /// A directory has its own `package.json` and `pnpm-workspace.yaml`, and
125 /// no workspace pattern of the root manifests declares it. pnpm installs
126 /// it as an independent project. Fallow analyzes it as a workspace, so its
127 /// `package.json` owns its files and dependencies, and its `catalog:`
128 /// references are not checked against the root catalog.
129 ///
130 /// Information only: nothing is lost from the analysis, so
131 /// `degrades_analysis` is false.
132 NestedProjectRoot,
133 /// The root `tsconfig.json`, or a config in its `extends` chain, exists
134 /// but failed to parse. `path` names the file that failed. Fallow ignores
135 /// the project references, path aliases and compiler options of that file.
136 MalformedTsconfig {
137 /// JSONC parse error text.
138 error: String,
139 },
140 /// `tsconfig.json` lists a `references[].path` that does not point to an
141 /// existing directory.
142 TsconfigReferenceDirMissing,
143 /// `pnpm-workspace.yaml` exists but failed to parse as YAML. The catalog
144 /// checks are skipped and dependency-override analysis proceeds without
145 /// the file's entries (degraded) until the syntax is fixed.
146 MalformedPnpmWorkspaceYaml {
147 /// YAML parse error text.
148 error: String,
149 },
150 /// A source file was skipped at discovery because it exceeds the configured
151 /// per-file size limit (`--max-file-size` / `FALLOW_MAX_FILE_SIZE`, default
152 /// 5 MB). The file is never read, parsed, or analyzed, guarding against the
153 /// out-of-memory blowup a single multi-MB generated/vendored/bundled file
154 /// causes (issue #1086). Surfaced by source discovery, not workspace
155 /// discovery, but shares this channel so the skip is visible in
156 /// `workspace_diagnostics[]` on `fallow dead-code / dupes / health` JSON.
157 SkippedLargeFile {
158 /// On-disk size of the skipped file in bytes.
159 size_bytes: u64,
160 },
161 /// A large JavaScript bundle was skipped at discovery because it appears to
162 /// be minified generated output. The file is never parsed or analyzed,
163 /// guarding against sub-limit bundles that can still create very large ASTs
164 /// and extraction payloads (issue #1086). Use `--max-file-size 0` when the
165 /// bundled file really should be analyzed.
166 SkippedMinifiedFile {
167 /// On-disk size of the skipped file in bytes.
168 size_bytes: u64,
169 },
170 /// A dot-prefixed directory was not traversed by source discovery even
171 /// though it contains at least one source file the project has not
172 /// excluded. Hidden directories are skipped by default apart from a small
173 /// convention allowlist (`.storybook`, `.vitepress`, `.well-known`,
174 /// `.changeset`, `.github`) and the directories an active framework plugin
175 /// or a `package.json` script reference contributes, so files inside are
176 /// never parsed and their imports and exports are invisible to every
177 /// analysis. A file, export or dependency that only the directory uses can
178 /// be reported as unused. A `!<dir>/**` entry in `ignorePatterns` adds the
179 /// directory to traversal (issue #2452). Or add the file to `entry`, the
180 /// export to `ignoreExports` or the dependency to `ignoreDependencies` to
181 /// stop that false positive, or add the directory to `ignorePatterns` to
182 /// silence this (issue #461). Running
183 /// fallow with `--root` against the directory analyzes it on its own and
184 /// does not fix the main run (issue #2797).
185 ///
186 /// "Not excluded" is measured the way the run measures it: a directory
187 /// whose contents are gitignored, or excluded by `ignorePatterns`, or (on
188 /// a `--production` run) excluded as test or story files, never earns this
189 /// diagnostic, because the advertised remedies would find nothing there
190 /// either. Generated tool output and non-git VCS metadata are excluded by
191 /// name.
192 ///
193 /// The advisory is best-effort and bounded: one run inspects a fixed
194 /// number of skipped directories with a fixed I/O budget, in sorted path
195 /// order, so a pathological tree yields a deterministic prefix rather than
196 /// an unbounded array or an unbounded scan. The stderr note says "at
197 /// least" when a ceiling bound the run.
198 ///
199 /// Surfaced by source discovery, not workspace discovery, but shares this
200 /// channel so the skip is visible in `workspace_diagnostics[]` on
201 /// `fallow dead-code / dupes / health` JSON.
202 ///
203 /// Unlike the two skipped-file kinds beside it, this one is CAPPED. To
204 /// bound the directory reads the check costs, a run classifies at most 64
205 /// candidate directories and spends at most 1024 directory entries across
206 /// all of them, so on a project that exceeds either ceiling the array is a
207 /// prefix of the skipped directories rather than all of them, and the
208 /// stderr note says "at least N". No measured repository comes close to
209 /// either ceiling. A consumer needing an exact total should run fallow
210 /// with `--root` against the tree rather than infer one from this array.
211 SkippedSourceDotdir,
212 /// A source discovered with a stable [`FileId`](crate::discover::FileId)
213 /// could not be read before parsing. Analysis continues with the remaining
214 /// sparse module IDs and reports the underlying filesystem or UTF-8 error.
215 SourceReadFailure {
216 /// Filesystem or UTF-8 decoding error from `read_to_string`.
217 error: String,
218 },
219 /// A source file was read but parsed with diagnostics, so the module
220 /// extracted from it may be missing imports, exports, or references after
221 /// the first error. Analysis proceeds with the partial module, which is why
222 /// this is reported: an import the parser never saw credits nothing, and its
223 /// target can surface as a confident `unused-file` or `unused-export`
224 /// finding with a `delete-file` or `remove-export` action on it.
225 ///
226 /// Recorded by the parse stage, alongside `source-read-failure`, and never
227 /// used to withhold a finding. oxc reports recoverable errors for valid
228 /// syntax newer than the parser as well as for genuinely broken files, so
229 /// gating findings on this would mute real results project-wide instead of
230 /// just the affected file.
231 ///
232 /// A file that `ignoreFindings` covers is not reported. The user marked it
233 /// as broken on purpose, so its partial parse also adds no caveat to other
234 /// findings and does not count for the parse-error gate.
235 SourceParseDegraded {
236 /// Number of parser diagnostics reported for the file.
237 error_count: u32,
238 /// `true` when the parser abandoned the file instead of recovering, so
239 /// the extracted module is a fragment at best.
240 panicked: bool,
241 },
242 /// A source file nests brackets deeper than the parser cap, or needs more
243 /// stack than fallow reserves, so it was not parsed at all. The file can be
244 /// valid code, so this is not reported as a syntax error, but it degrades
245 /// the run in the same way: its imports credit nothing. It counts for the
246 /// parse-error gate and adds the same caveats as `source-parse-degraded`.
247 SourceNestingTooDeep {
248 /// The bracket nesting cap of the pre-parse scan.
249 limit: u32,
250 },
251 /// Dependency-override resolution was skipped because bun's legacy binary
252 /// `bun.lockb` sits next to this `package.json`, fallow cannot read the
253 /// binary format, and no parseable text lockfile was found to use
254 /// instead: no `bun.lock` that parses, and no readable `pnpm-lock.yaml`,
255 /// `package-lock.json`, or `npm-shrinkwrap.json`. A `yarn.lock` is never
256 /// consulted (yarn ignores `overrides`), so it does not prevent the skip
257 /// either. The manifest declares overrides, so the
258 /// `unused-dependency-overrides` check would otherwise have run; without
259 /// resolution ground truth it would flag every transitive-only pin, so no
260 /// unused-override findings are reported at all (issue #2358). Surfaced
261 /// by the override analysis, not workspace discovery, but shares this
262 /// channel so the skip is visible in `workspace_diagnostics[]` JSON and
263 /// as a stderr warning.
264 BunLockbOverrideResolutionSkipped,
265 /// Dependency-override resolution was skipped because bun's text
266 /// `bun.lock` exists but could not be parsed and no readable pnpm or npm
267 /// lockfile was available as independent resolution ground truth.
268 BunLockOverrideResolutionSkipped,
269 /// Dependency-override resolution was skipped because `pnpm-lock.yaml`
270 /// exists but could not be parsed, for example because it still holds
271 /// unresolved merge-conflict markers, and no other parseable lockfile was
272 /// available as independent resolution ground truth.
273 PnpmLockOverrideResolutionSkipped,
274 /// Dependency-override resolution was skipped because `package-lock.json`
275 /// or `npm-shrinkwrap.json` exists but could not be parsed, for example
276 /// because it still holds unresolved merge-conflict markers, and no other
277 /// parseable lockfile was available as independent resolution ground
278 /// truth.
279 NpmLockOverrideResolutionSkipped,
280 /// A bun manifest declares both `overrides` and a non-empty `resolutions`
281 /// object. Bun applies `overrides` and ignores `resolutions`, so fallow
282 /// reports the shadowed configuration without offering removal advice.
283 BunResolutionsShadowedByOverrides,
284 /// The declared pnpm version ignores the `overrides` section of
285 /// `pnpm-workspace.yaml`. pnpm does not print a warning. Fallow reports
286 /// no findings for the ignored entries. `cause` tells why pnpm ignores
287 /// the section.
288 PnpmWorkspaceOverridesIgnored {
289 /// Why pnpm ignores the section. The cause decides the remedy.
290 cause: PnpmWorkspaceOverridesIgnoredCause,
291 },
292 /// The project has no `node_modules` directory and is not a Deno project
293 /// that legitimately runs without one. Analysis proceeds, but three things
294 /// degrade silently: package `exports` and conditional exports cannot be
295 /// read, so imports into a dependency's subpaths resolve less precisely;
296 /// framework plugins that activate on an installed package stay inactive,
297 /// so their entry points and path aliases are missing; and a dependency's
298 /// installed shape cannot be inspected, so type-only dependency
299 /// classification falls back to declaration-based heuristics.
300 ///
301 /// Recorded once per run by the source walk, anchored at the missing
302 /// `node_modules` directory so the reported path is a real location rather
303 /// than the empty string a root-anchored diagnostic would render. This used
304 /// to be a bare `tracing::warn!` duplicated in two pipelines, so it never
305 /// reached JSON output and never reached `fallow doctor`, which reported
306 /// `pass` on a tree that had never been installed.
307 NodeModulesMissing,
308 /// `boundaries` is empty while `boundary-violation` is not `off`, so the
309 /// boundary detector never ran. Its summary counters are therefore
310 /// structurally zero and say nothing about the project.
311 ///
312 /// This is the UNCONFIGURED zero, not the user-chosen one: a project that
313 /// sets `boundary-violation: off` asked for silence and can see that
314 /// choice in `fallow config`. A project that left `boundaries` empty
315 /// cannot distinguish "no violations" from "nothing was measured".
316 BoundariesNotConfigured,
317 /// `rulePacks` is empty while `policy-violation` is not `off`, so the
318 /// policy detector never ran and its summary counters are structurally
319 /// zero. The unconfigured counterpart of
320 /// [`Self::BoundariesNotConfigured`].
321 RulePacksNotConfigured,
322 /// One of fallow's built-in discovery ignore patterns (`**/dist/**`,
323 /// `**/build/**`, `**/coverage/**`, and the four minified-bundle globs)
324 /// removed at least one candidate source file from this walk. The files
325 /// are never read, so their imports and exports are invisible to every
326 /// analysis, and until issue #2638 the drop was completely silent:
327 /// pointing fallow at a directory a built-in pattern matches returned a
328 /// clean report with exit 0 and nothing said why.
329 ///
330 /// `**/node_modules/**` is carved out and never appears in `pattern`:
331 /// installed dependencies are not the first-party source this diagnostic
332 /// is about, and a project that does not gitignore them would get a
333 /// five-figure count with no useful remedy. `**/.git/**` cannot fire,
334 /// because hidden directories are not traversed.
335 ///
336 /// One entry per pattern, never per file or per directory, so the array
337 /// grows by at most the number of built-in patterns on a project of any
338 /// size. `path` anchors at the matched directory holding the most excluded
339 /// files for that pattern, ties broken by the lexicographically first
340 /// path, so two runs on one tree report the same location. On a nested
341 /// match it is the DEEPEST segment the pattern matched
342 /// (`build/tools/build`, not `build`), because that is the directory the
343 /// `--root` remedy names and re-rooting at a shallower one would leave a
344 /// matching segment behind. That directory is the
345 /// largest group and not a majority: a flat monorepo can spread ten
346 /// excluded files over ten sibling `dist/` directories and every one of
347 /// them is then "the largest". `file_count` spans all of them, and
348 /// `directory_count` says how many there were, so a reader can tell a
349 /// single tree from a scattered one without a directory list in the
350 /// payload.
351 ///
352 /// Three properties of the population are load-bearing and easy to
353 /// misread:
354 ///
355 /// - **Gitignored trees count zero.** Source discovery honors
356 /// `.gitignore`, `.git/info/exclude`, and the global gitignore, and
357 /// prunes those directories before this check runs. The honest reading
358 /// is "candidate source files git did not already hide and a built-in
359 /// pattern then dropped", which is why a repository that gitignores its
360 /// own `dist/` never sees this diagnostic.
361 /// - **A user `ignorePatterns` entry is not a surprise.** The compiled
362 /// ignore set is the union of `ignorePatterns` and the built-ins, so a
363 /// file both matched was an explicit project choice and is attributed to
364 /// no pattern here. A `!` entry in `ignorePatterns` lifts a built-in for
365 /// the paths it matches (issue #2940), and a lifted file is discovered,
366 /// so it is never counted here.
367 /// - **The remedy depends on the pattern's shape.** A directory-shaped
368 /// built-in (`**/dist/**`) is matched against the path relative to the
369 /// run root, so re-rooting inside the matched directory removes the
370 /// matched segment and the files become visible: the message advertises
371 /// `fallow --root <dir>`. A file-shaped built-in (`**/*.min.js` and the
372 /// three other bundle globs) matches on the file name and keeps matching
373 /// at any root, so the message says so and points at renaming instead of
374 /// handing out a command that provably does nothing.
375 ///
376 /// Deliberately NOT one of the [`Self::source_never_analyzed`] kinds. These
377 /// exclusions are the product's designed behavior on generated output, not
378 /// a degraded run: answering `true` would attach `IncompleteFileAnalysis`
379 /// and `IncompleteImportGraph` caveats to findings on nearly every project
380 /// that keeps a non-gitignored `dist/` or `coverage/`, and make `fallow
381 /// fix` withhold `delete-file` and `remove-export` actions project-wide.
382 ExcludedByDefaultIgnore {
383 /// The built-in glob that matched, verbatim (for example
384 /// `**/build/**`).
385 pattern: String,
386 /// Candidate source files this pattern excluded in this walk, across
387 /// every directory it matched, not just the one `path` anchors at.
388 /// Exact: the walk counts each excluded candidate once.
389 file_count: u32,
390 /// Distinct directories this pattern matched at, `path` included, and
391 /// not the number of directories that held the files. A
392 /// directory-shaped pattern (`**/dist/**`) matches at the directory it
393 /// names, so an excluded subtree counts once however many nested
394 /// directories inside it held source: a `dist/` holding files in three
395 /// sub-directories reports `1`. A file-shaped pattern (`**/*.min.js`)
396 /// has no directory to collapse to and counts each matched file's own
397 /// parent. Exact either way, and anything above `1` says `path` names
398 /// one matched location out of several.
399 directory_count: u32,
400 },
401 /// The walk finished with no source file to analyze at all, so every
402 /// finding count this run reports is zero because nothing was measured
403 /// rather than because the project is clean (issue #2686).
404 ///
405 /// Distinct from [`Self::ExcludedByDefaultIgnore`], which reports one
406 /// pattern's exclusions and is designed behavior on generated output. The
407 /// alarm is not the exclusion, it is having nothing left afterwards, and
408 /// that condition also fires with no exclusion at all: a docs-only
409 /// repository, a workspace member with no TypeScript, or a path filter that
410 /// matched nothing. `excluded_file_count` names the built-in-ignore
411 /// contribution so the common cause is still attributable, and is `0` when
412 /// no built-in pattern took part.
413 ///
414 /// This is the kind a CI consumer reads to tell "measured zero" from
415 /// "measured nothing": the human report has said so since 3.26.0, but only
416 /// in human format and only under the built-in-ignore cause, so `--quiet
417 /// --format json` saw a clean green either way.
418 NoSourceFilesAnalyzed {
419 /// Candidate source files the built-in ignore patterns removed from
420 /// this walk, summed across every pattern. `0` when the walk found no
421 /// candidate to exclude in the first place.
422 excluded_file_count: u32,
423 },
424 /// Per-file health scoring failed, so the score list is empty and the
425 /// scored-file count is `0` because nothing was measured rather than
426 /// because the project has no files worth scoring. Every score-derived
427 /// number (the average maintainability index, the refactoring targets, the
428 /// hotspot complexity half) is then structurally zero (issue #2689).
429 FileScoresUnavailable {
430 /// Scoring error text.
431 error: String,
432 },
433 /// Churn-based hotspot analysis was skipped, so the hotspots, churn and
434 /// ownership sections report nothing at all. The remaining health sections
435 /// are unaffected.
436 HotspotsSkipped {
437 /// Which input stopped it, as a kebab-case token: `not-a-repository`,
438 /// `no-commits`, `invalid-since` or `churn-file-unreadable`. The set is
439 /// open.
440 ///
441 /// The cause decides the remedy, which is why it is on the wire: a run
442 /// outside a repository is fixed by running fallow inside one, a
443 /// branch without a commit by committing, a malformed `--since` by
444 /// respelling the flag, and a churn file that changed under the run by
445 /// rerunning it. A consumer reading only the kind would offer the first
446 /// remedy for all four.
447 cause: String,
448 },
449 /// Change coupling found no pairs because the history cannot show them.
450 /// The `change_coupling` section is present with an empty `pairs` list,
451 /// and this entry tells why the list is empty.
452 ChangeCouplingSkipped {
453 /// Kebab-case token: `shallow-clone` (the fetched history is
454 /// incomplete) or `too-few-commits` (fewer analyzed commits than the
455 /// minimum revisions of one file). The set is open.
456 cause: String,
457 },
458 /// The repository is a shallow clone, so churn is measured over the fetched
459 /// history only and every hotspot figure is incomplete.
460 ShallowClone {
461 /// `true` when the run also asked for ownership attribution, which a
462 /// shallow clone skews further by inflating single-author dominance.
463 ownership_requested: bool,
464 },
465 /// No commit timestamp was available, so churn recency and ownership
466 /// staleness were measured against the wall clock and drift between two
467 /// runs over the same commit.
468 UnpinnedClock,
469 /// Ownership attribution was requested but its inputs did not load, so
470 /// hotspot entries carry degraded or absent owner signals.
471 OwnershipUnavailable {
472 /// Which input failed, as a kebab-case token: `invalid-bot-pattern` or
473 /// `codeowners-parse-failed`. The set is open.
474 cause: String,
475 /// Underlying error text.
476 error: String,
477 },
478 /// A saved health snapshot could not be read or parsed, so the trend is
479 /// computed over fewer snapshots than the project has on disk and a
480 /// direction can flip on the missing point alone.
481 TrendSnapshotUnreadable {
482 /// Filesystem or JSON error text.
483 error: String,
484 },
485 /// A grouped health run asked for a trend, but the baseline snapshot holds
486 /// no group data that matches this run, so the groups carry no trend. The
487 /// project trend is not affected. `path` names the baseline snapshot.
488 TrendGroupBaselineUnavailable {
489 /// Why the groups have no baseline, as a kebab-case token:
490 /// `snapshot-has-no-groups` or `grouped-by-mismatch`. The set is open.
491 cause: String,
492 },
493 /// A framework plugin read a build config and could not read one of its
494 /// keys in full, so part of what the key declares never reached the
495 /// analysis. `path` names the config file.
496 ///
497 /// The reader is syntactic, so a key whose value is computed at build time
498 /// is invisible to it: a Module Federation `exposes: makeExposes()` or a
499 /// `remotes` map spread from an environment module declares entries this
500 /// run does not know about. The consequence is a finding, not a missing
501 /// number: an unread `exposes` target is not registered as an entry point
502 /// and its file can surface as `unused-file`, and an unread `remotes` alias
503 /// is not treated as provided by a remote container and its import can
504 /// surface as an unlisted dependency.
505 ///
506 /// Recorded by the plugin stage, which runs before analysis and is not
507 /// cached, so the entry is present on a warm cache too. It used to be a
508 /// bare `tracing::warn!` from inside the plugin, so it reached no envelope
509 /// and no CI consumer (issue #2736).
510 ///
511 /// A source file that calls the Module Federation runtime API gets the same
512 /// entry: `path` names the source file, `key` names the runtime function
513 /// (`registerRemotes`, `loadRemote`, `init`, `createInstance`) and
514 /// `reason` is `dynamic-argument` when the call receives a value that is
515 /// not a static literal. The analysis records it from the facts of the
516 /// parse, which a warm cache restores (issue #2795). A `.vue` or `.svelte`
517 /// file gets it for a call in its `<script>` blocks (issue #2876).
518 PluginConfigUnreadable {
519 /// The plugin that read the config, as it labels itself:
520 /// `module-federation` for a standalone `module-federation.config.*`,
521 /// or the bundler plugin (`webpack`, `rspack`, `rsbuild`, `vite`) that
522 /// read the same options inline from its own config.
523 plugin: String,
524 /// The config key that was present and not fully readable (`exposes`,
525 /// `remotes`), or the Module Federation runtime function whose
526 /// argument was not readable (`registerRemotes`, `loadRemote`, `init`,
527 /// `createInstance`). The set is open.
528 key: String,
529 /// Why it could not be read, as a kebab-case token:
530 /// `not-object-literal`, `array-form`, `spread`,
531 /// `unreadable-entries`, `unrecognized-call`,
532 /// `import-target-unreadable` or `dynamic-argument`. The set is open.
533 ///
534 /// The reason decides the remedy, which is why it is on the wire: a
535 /// value that is not an object literal is fixed by writing one, while
536 /// unreadable entries are fixed by naming those entries in the config
537 /// option the message points at.
538 reason: String,
539 },
540 /// A framework plugin read a config key it understands, does not model
541 /// that key's effect, and therefore stood a modeled default down. `path`
542 /// names the config file.
543 ///
544 /// A file can also be the `path`: a Nuxt file that reads `#components` or
545 /// `#imports` in a way fallow cannot narrow to names, such as a spread of
546 /// a namespace import, has `key` set to that module and `reason` set to
547 /// `key-effect-not-modeled`. Every name of the module then counts as used.
548 ///
549 /// The Nuxt auto-import gate is the case this exists for. With
550 /// `autoImports` enabled fallow drops the Nuxt convention entry patterns
551 /// so a genuinely unreferenced convention file is reported, and a
552 /// `components:` or `imports:` block whose effect it cannot model keeps
553 /// them, which silently costs the user the findings they opted in for.
554 ///
555 /// Deliberately NOT one of the [`Self::warns_on_stderr`] kinds. Nothing
556 /// was lost that the run could have measured: the patterns stayed, so
557 /// findings are suppressed rather than invented, and a project in this
558 /// state would otherwise warn on every run forever with "write different
559 /// config" as the only remedy, which is the reason
560 /// `boundaries-not-configured` is off stderr as well.
561 PluginEffectNotModeled {
562 /// The plugin that read the config, as it labels itself (`nuxt`).
563 plugin: String,
564 /// The config key whose effect is not modeled (`components`,
565 /// `imports`), or the virtual module a file reads (`#components`,
566 /// `#imports`). The set is open.
567 key: String,
568 /// Why the effect is not modeled, as a kebab-case token:
569 /// `key-effect-not-modeled` when the key's own value is the reason,
570 /// `config-property-unreadable` when a top-level property of the same
571 /// config file could not be read statically, so no surface in it can
572 /// be classified at all. The set is open.
573 reason: String,
574 },
575 /// Test coverage was auto-detected on disk rather than passed with
576 /// `--coverage`, and `path` names the file that fed the CRAP scores.
577 ///
578 /// Deliberately NOT one of the [`Self::warns_on_stderr`] kinds: nothing
579 /// degraded, the run measured exactly what it found. It is provenance, and
580 /// it is on the wire because a score computed against a file the user did
581 /// not name is not reproducible and nothing else says which file it was.
582 CoverageAutoDetected,
583 /// `fallow flags --retirement` asked for flag age, but the repository is
584 /// a shallow clone. Blame and pickaxe see the fetched history only, so
585 /// every age would be too young. The report gives no age.
586 FlagAgeShallowClone,
587 /// `fallow flags --retirement` asked for flag age, but git history is
588 /// not available. The report gives no age.
589 FlagAgeUnavailable {
590 /// Why no history is available, as a kebab-case token:
591 /// `not-a-repository` or `no-commits`. The set is open.
592 cause: String,
593 },
594 /// A glob in `ignoreDependencies` matched no dependency that a
595 /// `package.json` of the project declares, so the entry had no effect in
596 /// this run. The usual cause is a typo in the scope or the name.
597 ///
598 /// The dead-code run records it only when the unused-dependency check ran
599 /// and the run reports dependency findings. A run filtered to other issue
600 /// types (`--unused-files`) or scoped to files (`--file`) does not record
601 /// it. An exact-name entry never produces it. `path` is the project root.
602 ///
603 /// Not a degraded run: the analysis measured what it was asked to, so
604 /// `degrades_analysis` is false and no finding carries a caveat.
605 IgnoreDependenciesGlobUnmatched {
606 /// The `ignoreDependencies` entry, as written in the config.
607 pattern: String,
608 },
609 /// A pattern in `ignoreFindings` matched no finding of the dead-code run,
610 /// so the entry had no effect in this run. The usual cause is a typo in
611 /// the path.
612 ///
613 /// The pattern is compared with every finding the analysis produced,
614 /// before issue-type filters and scope filters, so a filtered run does not
615 /// make a pattern look unused. `path` is the project root.
616 ///
617 /// Not a degraded run, for the same reason as
618 /// [`Self::IgnoreDependenciesGlobUnmatched`].
619 IgnoreFindingsPatternUnmatched {
620 /// The `ignoreFindings` entry, as written in the config.
621 pattern: String,
622 },
623 /// An `entry` pattern matched only files that another source (a
624 /// `package.json` field or script, a plugin, `dynamicallyLoaded`) makes
625 /// runtime entry points (issue #3824). Not for a pattern that matches no
626 /// file, nor a `--production` run. `path` is the root. Not degraded.
627 EntryPatternRedundant {
628 /// The `entry` pattern, as written in the config.
629 pattern: String,
630 },
631 /// An `ignoreDependencies` entry hid no dependency finding (issue #3824).
632 /// A glob that matches no declared dependency is
633 /// [`Self::IgnoreDependenciesGlobUnmatched`] instead. Same conditions as
634 /// that kind, and not a `--production` run. `path` is the root.
635 IgnoreDependenciesEntryRedundant {
636 /// The `ignoreDependencies` entry, as written in the config.
637 pattern: String,
638 },
639 /// An `ignorePatterns` glob excluded no file that the run would otherwise
640 /// analyze (issue #3824). Never a `!` exception, a glob that names a
641 /// hidden directory, or a `--production` run. A glob for build output has
642 /// an effect only after a build. `path` is the root. Not degraded.
643 IgnorePatternsEntryRedundant {
644 /// The `ignorePatterns` glob, as written in the config.
645 pattern: String,
646 },
647 /// The dead-code baseline was saved in a different install state than
648 /// this run: one of the two runs had `node_modules` and the other did not.
649 /// Findings that need installed packages (dependency, unlisted,
650 /// unresolved-import and script-binary findings) can differ, so the run
651 /// can report baseline findings as new. `path` is the baseline file.
652 ///
653 /// Only a baseline that records its install state can report this. A
654 /// legacy baseline has no record, so the state is unknown.
655 BaselineInstallStateMismatch {
656 /// The install state when the baseline was saved.
657 baseline_node_modules: NodeModulesState,
658 /// The install state of this run.
659 run_node_modules: NodeModulesState,
660 },
661}
662
663impl WorkspaceDiagnosticKind {
664 /// Stable kebab-case identifier used in dedupe keys and tracing payloads.
665 #[must_use]
666 pub const fn id(&self) -> &'static str {
667 match self {
668 Self::UndeclaredWorkspace => "undeclared-workspace",
669 Self::MalformedPackageJson { .. } => "malformed-package-json",
670 Self::GlobMatchedNoPackageJson { .. } => "glob-matched-no-package-json",
671 Self::WorkspaceGlobOutsideRoot { .. } => "workspace-glob-outside-root",
672 Self::WorkspacePatternDirMissing { .. } => "workspace-pattern-dir-missing",
673 Self::NestedProjectRoot => "nested-project-root",
674 Self::MalformedTsconfig { .. } => "malformed-tsconfig",
675 Self::TsconfigReferenceDirMissing => "tsconfig-reference-dir-missing",
676 Self::MalformedPnpmWorkspaceYaml { .. } => "malformed-pnpm-workspace-yaml",
677 Self::SkippedLargeFile { .. } => "skipped-large-file",
678 Self::SkippedMinifiedFile { .. } => "skipped-minified-file",
679 Self::SkippedSourceDotdir => "skipped-source-dotdir",
680 Self::SourceReadFailure { .. } => "source-read-failure",
681 Self::SourceParseDegraded { .. } => "source-parse-degraded",
682 Self::SourceNestingTooDeep { .. } => "source-nesting-too-deep",
683 Self::BunLockbOverrideResolutionSkipped => "bun-lockb-override-resolution-skipped",
684 Self::BunLockOverrideResolutionSkipped => "bun-lock-override-resolution-skipped",
685 Self::PnpmLockOverrideResolutionSkipped => "pnpm-lock-override-resolution-skipped",
686 Self::NpmLockOverrideResolutionSkipped => "npm-lock-override-resolution-skipped",
687 Self::BunResolutionsShadowedByOverrides => "bun-resolutions-shadowed-by-overrides",
688 Self::PnpmWorkspaceOverridesIgnored { .. } => "pnpm-workspace-overrides-ignored",
689 Self::NodeModulesMissing => "node-modules-missing",
690 Self::BoundariesNotConfigured => "boundaries-not-configured",
691 Self::RulePacksNotConfigured => "rule-packs-not-configured",
692 Self::ExcludedByDefaultIgnore { .. } => "excluded-by-default-ignore",
693 Self::NoSourceFilesAnalyzed { .. } => "no-source-files-analyzed",
694 Self::FileScoresUnavailable { .. } => "file-scores-unavailable",
695 Self::HotspotsSkipped { .. } => "hotspots-skipped",
696 Self::ChangeCouplingSkipped { .. } => "change-coupling-skipped",
697 Self::ShallowClone { .. } => "shallow-clone",
698 Self::UnpinnedClock => "unpinned-clock",
699 Self::OwnershipUnavailable { .. } => "ownership-unavailable",
700 Self::TrendSnapshotUnreadable { .. } => "trend-snapshot-unreadable",
701 Self::TrendGroupBaselineUnavailable { .. } => "trend-group-baseline-unavailable",
702 Self::PluginConfigUnreadable { .. } => "plugin-config-unreadable",
703 Self::PluginEffectNotModeled { .. } => "plugin-effect-not-modeled",
704 Self::CoverageAutoDetected => "coverage-auto-detected",
705 Self::FlagAgeShallowClone => "flag-age-shallow-clone",
706 Self::FlagAgeUnavailable { .. } => "flag-age-unavailable",
707 Self::IgnoreDependenciesGlobUnmatched { .. } => "ignore-dependencies-glob-unmatched",
708 Self::IgnoreFindingsPatternUnmatched { .. } => "ignore-findings-pattern-unmatched",
709 Self::EntryPatternRedundant { .. } => "entry-pattern-redundant",
710 Self::IgnoreDependenciesEntryRedundant { .. } => "ignore-dependencies-entry-redundant",
711 Self::IgnorePatternsEntryRedundant { .. } => "ignore-patterns-entry-redundant",
712 Self::BaselineInstallStateMismatch { .. } => "baseline-install-state-mismatch",
713 }
714 }
715
716 /// Whether this diagnostic is worth a `tracing::warn!` line on stderr, on
717 /// top of its permanent entry in `workspace_diagnostics[]`.
718 ///
719 /// A warning is for a run whose RESULTS are degraded: something the user
720 /// installed, wrote, or expected did not reach the analysis. The two
721 /// unconfigured-check kinds are not that. They fire in the product's
722 /// default state, on every project that never opted into boundaries or
723 /// rule packs, and they will keep firing forever, because the remedy they
724 /// offer is to write configuration in order to silence a warning about not
725 /// having written configuration. They stay in the structured array, where a
726 /// consumer that wants to distinguish "measured zero" from "measured
727 /// nothing" can read them, and off the stderr surface that every other
728 /// command shares.
729 ///
730 /// `coverage-auto-detected` answers false for a third reason: it reports
731 /// the provenance of an input that DID load, so a consumer sentence about a
732 /// degraded run would state something untrue about it. Its own note is
733 /// printed by the health pipeline.
734 ///
735 /// `plugin-effect-not-modeled` answers false for the first reason: the
736 /// config was readable and nothing the run could have measured was lost,
737 /// so it would warn forever on a project whose `nuxt.config` fallow does
738 /// not model. Its sibling `plugin-config-unreadable` answers true, because
739 /// there a declaration the user wrote did not reach the analysis and
740 /// findings can be wrong in either direction.
741 #[must_use]
742 pub const fn warns_on_stderr(&self) -> bool {
743 match self {
744 Self::BoundariesNotConfigured
745 | Self::RulePacksNotConfigured
746 | Self::ExcludedByDefaultIgnore { .. }
747 | Self::PluginEffectNotModeled { .. }
748 | Self::CoverageAutoDetected
749 | Self::TrendGroupBaselineUnavailable { .. }
750 | Self::ChangeCouplingSkipped { .. }
751 | Self::IgnoreDependenciesGlobUnmatched { .. }
752 | Self::IgnoreFindingsPatternUnmatched { .. }
753 | Self::EntryPatternRedundant { .. }
754 | Self::IgnoreDependenciesEntryRedundant { .. }
755 | Self::IgnorePatternsEntryRedundant { .. }
756 | Self::NestedProjectRoot => false,
757 Self::UndeclaredWorkspace
758 | Self::MalformedPackageJson { .. }
759 | Self::GlobMatchedNoPackageJson { .. }
760 | Self::WorkspaceGlobOutsideRoot { .. }
761 | Self::WorkspacePatternDirMissing { .. }
762 | Self::MalformedTsconfig { .. }
763 | Self::TsconfigReferenceDirMissing
764 | Self::MalformedPnpmWorkspaceYaml { .. }
765 | Self::SkippedLargeFile { .. }
766 | Self::SkippedMinifiedFile { .. }
767 | Self::SkippedSourceDotdir
768 | Self::SourceReadFailure { .. }
769 | Self::SourceParseDegraded { .. }
770 | Self::SourceNestingTooDeep { .. }
771 | Self::BunLockbOverrideResolutionSkipped
772 | Self::BunLockOverrideResolutionSkipped
773 | Self::PnpmLockOverrideResolutionSkipped
774 | Self::NpmLockOverrideResolutionSkipped
775 | Self::BunResolutionsShadowedByOverrides
776 | Self::PnpmWorkspaceOverridesIgnored { .. }
777 | Self::NodeModulesMissing
778 | Self::NoSourceFilesAnalyzed { .. }
779 | Self::FileScoresUnavailable { .. }
780 | Self::HotspotsSkipped { .. }
781 | Self::ShallowClone { .. }
782 | Self::UnpinnedClock
783 | Self::OwnershipUnavailable { .. }
784 | Self::TrendSnapshotUnreadable { .. }
785 | Self::PluginConfigUnreadable { .. }
786 | Self::FlagAgeShallowClone
787 | Self::FlagAgeUnavailable { .. }
788 | Self::BaselineInstallStateMismatch { .. } => true,
789 }
790 }
791
792 /// Whether this diagnostic is produced by SOURCE discovery (the file walk in
793 /// `discover_files`) rather than WORKSPACE discovery (config load). Source-
794 /// discovery diagnostics are APPENDED to the registry after config load, so
795 /// `stash_workspace_diagnostics` must preserve them when it replaces the
796 /// workspace-discovery set, otherwise the per-analysis config re-loads in
797 /// combined-mode (`fallow` with no subcommand re-loads config for check,
798 /// dupes, and health) wipe them before the JSON envelope is built (issue
799 /// #1086).
800 #[must_use]
801 pub const fn is_source_discovery(&self) -> bool {
802 matches!(
803 self,
804 Self::SkippedLargeFile { .. }
805 | Self::SkippedMinifiedFile { .. }
806 | Self::SkippedSourceDotdir
807 | Self::SourceReadFailure { .. }
808 | Self::SourceParseDegraded { .. }
809 | Self::SourceNestingTooDeep { .. }
810 | Self::NodeModulesMissing
811 | Self::ExcludedByDefaultIgnore { .. }
812 | Self::NoSourceFilesAnalyzed { .. }
813 )
814 }
815
816 /// Whether this diagnostic is written by the source file WALK
817 /// (`discover_files`), the subset of [`Self::is_source_discovery`] that a
818 /// walk replaces wholesale for its root. `source-read-failure` is the
819 /// other source-discovery kind and is NOT one of these: the parse stage
820 /// records it after the walk, so it has to keep reaching consumers through
821 /// the registry.
822 ///
823 /// A walk-recorded entry must reach an analysis from its OWN walk's return
824 /// value. Combined mode runs the dead-code and duplication walks under
825 /// `rayon::join` whenever a per-analysis `production` split stops them from
826 /// sharing a file list, so a registry read answers "whichever walk wrote
827 /// last" and varies between runs of the same command (issue #2366).
828 #[must_use]
829 pub const fn is_source_walk_recorded(&self) -> bool {
830 matches!(
831 self,
832 Self::SkippedLargeFile { .. }
833 | Self::SkippedMinifiedFile { .. }
834 | Self::SkippedSourceDotdir
835 | Self::NodeModulesMissing
836 | Self::ExcludedByDefaultIgnore { .. }
837 | Self::NoSourceFilesAnalyzed { .. }
838 )
839 }
840
841 /// Whether this diagnostic reports a source file whose contents this run
842 /// never analyzed, so every import and export the file holds is invisible
843 /// to the module graph.
844 ///
845 /// This is the class `reachability_caveats[]` exists for. A file the run
846 /// never read credits nothing, so the modules it imports surface as
847 /// confident `unused-file` and `unused-export` findings carrying
848 /// `delete-file` and `remove-export` actions, and `fallow fix` would
849 /// otherwise apply the removal against source that still imports the
850 /// target.
851 ///
852 /// All four discovery-side kinds qualify, for the same reason and with the
853 /// same consequence:
854 ///
855 /// - `skipped-large-file` and `skipped-minified-file`: the file is in the
856 /// project tree and was never opened, so its import list is unknown.
857 /// - `skipped-source-dotdir`: the directory holds at least one source file
858 /// the project did not exclude, and none of them were traversed. The
859 /// diagnostic is capped, so it under-reports rather than over-reports;
860 /// its presence still proves unseen source exists.
861 /// - `source-read-failure`: the file was discovered and then could not be
862 /// read, so nothing was extracted from it at all.
863 ///
864 /// `source-parse-degraded` is deliberately NOT one of these, though it
865 /// belongs to the same family. Neither is `excluded-by-default-ignore`,
866 /// for a different reason: that one reports designed behavior on generated
867 /// output rather than a degraded run, and its own doc comment carries the
868 /// argument.
869 ///
870 /// `source-parse-degraded`: that file WAS read, so it has a module and
871 /// a graph node and its reachability is observable, which lets the caveat
872 /// pass narrow it: a degraded module that is itself unreachable cannot
873 /// change a reachability verdict. Every kind above has no node to ask (a
874 /// read failure has one with nothing extracted into it), so no narrowing
875 /// is available and the caveat they raise is run-level.
876 ///
877 /// The match is exhaustive on purpose: a new "the run did not see this
878 /// file" kind has to be classified here, and answering `true` is the only
879 /// wiring its findings need in order to inherit both the caveat and the
880 /// `fallow fix` withholding that follows it.
881 #[must_use]
882 pub const fn source_never_analyzed(&self) -> bool {
883 match self {
884 Self::SkippedLargeFile { .. }
885 | Self::SkippedMinifiedFile { .. }
886 | Self::SkippedSourceDotdir
887 | Self::SourceReadFailure { .. } => true,
888 Self::UndeclaredWorkspace
889 | Self::MalformedPackageJson { .. }
890 | Self::GlobMatchedNoPackageJson { .. }
891 | Self::WorkspaceGlobOutsideRoot { .. }
892 | Self::WorkspacePatternDirMissing { .. }
893 | Self::NestedProjectRoot
894 | Self::MalformedTsconfig { .. }
895 | Self::TsconfigReferenceDirMissing
896 | Self::MalformedPnpmWorkspaceYaml { .. }
897 | Self::SourceParseDegraded { .. }
898 | Self::SourceNestingTooDeep { .. }
899 | Self::BunLockbOverrideResolutionSkipped
900 | Self::BunLockOverrideResolutionSkipped
901 | Self::PnpmLockOverrideResolutionSkipped
902 | Self::NpmLockOverrideResolutionSkipped
903 | Self::BunResolutionsShadowedByOverrides
904 | Self::PnpmWorkspaceOverridesIgnored { .. }
905 | Self::NodeModulesMissing
906 | Self::BoundariesNotConfigured
907 | Self::RulePacksNotConfigured
908 | Self::ExcludedByDefaultIgnore { .. }
909 | Self::NoSourceFilesAnalyzed { .. }
910 | Self::FileScoresUnavailable { .. }
911 | Self::HotspotsSkipped { .. }
912 | Self::ChangeCouplingSkipped { .. }
913 | Self::ShallowClone { .. }
914 | Self::UnpinnedClock
915 | Self::OwnershipUnavailable { .. }
916 | Self::TrendSnapshotUnreadable { .. }
917 | Self::TrendGroupBaselineUnavailable { .. }
918 | Self::PluginConfigUnreadable { .. }
919 | Self::PluginEffectNotModeled { .. }
920 | Self::CoverageAutoDetected
921 | Self::FlagAgeShallowClone
922 | Self::FlagAgeUnavailable { .. }
923 | Self::IgnoreDependenciesGlobUnmatched { .. }
924 | Self::IgnoreFindingsPatternUnmatched { .. }
925 | Self::EntryPatternRedundant { .. }
926 | Self::IgnoreDependenciesEntryRedundant { .. }
927 | Self::IgnorePatternsEntryRedundant { .. }
928 | Self::BaselineInstallStateMismatch { .. } => false,
929 }
930 }
931
932 /// Whether this diagnostic is recorded by the ANALYZE stage (the
933 /// dependency-catalog and override detectors) rather than by workspace or
934 /// source discovery. Analysis-stage diagnostics reach the registry through
935 /// `record_workspace_diagnostics` after config load, so
936 /// `stash_workspace_diagnostics` must preserve them across combined-mode's
937 /// per-analysis config re-loads, and every analyze pass clears its previous
938 /// entries before re-recording so a fixed cause drops out on the next run
939 /// (issue #2366). The match is exhaustive on purpose: a new kind must be
940 /// classified here before it compiles.
941 ///
942 /// Classify a kind `true` ONLY when a detector reachable from the dead-code
943 /// analyze pass (`find_dead_code_full`) re-records it, because that pass is
944 /// the single clear site. A kind recorded exclusively by another stage would
945 /// be cleared by the next dead-code pass and never come back.
946 #[must_use]
947 pub const fn is_analysis_stage(&self) -> bool {
948 match self {
949 Self::MalformedPnpmWorkspaceYaml { .. }
950 | Self::BunLockbOverrideResolutionSkipped
951 | Self::BunLockOverrideResolutionSkipped
952 | Self::PnpmLockOverrideResolutionSkipped
953 | Self::NpmLockOverrideResolutionSkipped
954 | Self::BunResolutionsShadowedByOverrides
955 | Self::PnpmWorkspaceOverridesIgnored { .. }
956 | Self::BoundariesNotConfigured
957 | Self::RulePacksNotConfigured => true,
958 Self::UndeclaredWorkspace
959 | Self::MalformedPackageJson { .. }
960 | Self::GlobMatchedNoPackageJson { .. }
961 | Self::WorkspaceGlobOutsideRoot { .. }
962 | Self::WorkspacePatternDirMissing { .. }
963 | Self::NestedProjectRoot
964 | Self::MalformedTsconfig { .. }
965 | Self::TsconfigReferenceDirMissing
966 | Self::SkippedLargeFile { .. }
967 | Self::SkippedMinifiedFile { .. }
968 | Self::SkippedSourceDotdir
969 | Self::SourceReadFailure { .. }
970 | Self::SourceParseDegraded { .. }
971 | Self::SourceNestingTooDeep { .. }
972 | Self::NodeModulesMissing
973 | Self::ExcludedByDefaultIgnore { .. }
974 | Self::NoSourceFilesAnalyzed { .. }
975 | Self::FileScoresUnavailable { .. }
976 | Self::HotspotsSkipped { .. }
977 | Self::ChangeCouplingSkipped { .. }
978 | Self::ShallowClone { .. }
979 | Self::UnpinnedClock
980 | Self::OwnershipUnavailable { .. }
981 | Self::TrendSnapshotUnreadable { .. }
982 | Self::TrendGroupBaselineUnavailable { .. }
983 | Self::PluginConfigUnreadable { .. }
984 | Self::PluginEffectNotModeled { .. }
985 | Self::CoverageAutoDetected
986 | Self::FlagAgeShallowClone
987 | Self::FlagAgeUnavailable { .. }
988 | Self::IgnoreDependenciesGlobUnmatched { .. }
989 | Self::IgnoreFindingsPatternUnmatched { .. }
990 | Self::EntryPatternRedundant { .. }
991 | Self::IgnoreDependenciesEntryRedundant { .. }
992 | Self::IgnorePatternsEntryRedundant { .. }
993 | Self::BaselineInstallStateMismatch { .. } => false,
994 }
995 }
996
997 /// Whether this diagnostic is recorded by the HEALTH pipeline (scoring,
998 /// churn, ownership, trend, coverage input resolution) rather than by
999 /// workspace discovery, source discovery or the analyze stage.
1000 ///
1001 /// Health-stage diagnostics are appended to the registry after config
1002 /// load, so `stash_workspace_diagnostics` must preserve them across
1003 /// combined mode's per-analysis config re-loads, and the health run clears
1004 /// its previous entries before re-recording so a fixed CODEOWNERS drops out
1005 /// on the next run (issue #2689).
1006 ///
1007 /// They are deliberately NOT [`Self::is_analysis_stage`], although they
1008 /// share both of those properties. That predicate additionally means "the
1009 /// dead-code analyze pass re-records this", and the pass clears every kind
1010 /// answering it on entry. Health computes file scores by running that same
1011 /// pass, so a health-stage kind classified there would be wiped mid-run by
1012 /// the analysis it is reporting on.
1013 #[must_use]
1014 pub const fn is_health_stage(&self) -> bool {
1015 match self {
1016 Self::FileScoresUnavailable { .. }
1017 | Self::HotspotsSkipped { .. }
1018 | Self::ChangeCouplingSkipped { .. }
1019 | Self::ShallowClone { .. }
1020 | Self::UnpinnedClock
1021 | Self::OwnershipUnavailable { .. }
1022 | Self::TrendSnapshotUnreadable { .. }
1023 | Self::TrendGroupBaselineUnavailable { .. }
1024 | Self::CoverageAutoDetected => true,
1025 Self::UndeclaredWorkspace
1026 | Self::MalformedPackageJson { .. }
1027 | Self::GlobMatchedNoPackageJson { .. }
1028 | Self::WorkspaceGlobOutsideRoot { .. }
1029 | Self::WorkspacePatternDirMissing { .. }
1030 | Self::NestedProjectRoot
1031 | Self::MalformedTsconfig { .. }
1032 | Self::TsconfigReferenceDirMissing
1033 | Self::MalformedPnpmWorkspaceYaml { .. }
1034 | Self::SkippedLargeFile { .. }
1035 | Self::SkippedMinifiedFile { .. }
1036 | Self::SkippedSourceDotdir
1037 | Self::SourceReadFailure { .. }
1038 | Self::SourceParseDegraded { .. }
1039 | Self::SourceNestingTooDeep { .. }
1040 | Self::BunLockbOverrideResolutionSkipped
1041 | Self::BunLockOverrideResolutionSkipped
1042 | Self::PnpmLockOverrideResolutionSkipped
1043 | Self::NpmLockOverrideResolutionSkipped
1044 | Self::BunResolutionsShadowedByOverrides
1045 | Self::PnpmWorkspaceOverridesIgnored { .. }
1046 | Self::NodeModulesMissing
1047 | Self::BoundariesNotConfigured
1048 | Self::RulePacksNotConfigured
1049 | Self::ExcludedByDefaultIgnore { .. }
1050 | Self::PluginConfigUnreadable { .. }
1051 | Self::PluginEffectNotModeled { .. }
1052 | Self::NoSourceFilesAnalyzed { .. }
1053 | Self::FlagAgeShallowClone
1054 | Self::FlagAgeUnavailable { .. }
1055 | Self::IgnoreDependenciesGlobUnmatched { .. }
1056 | Self::IgnoreFindingsPatternUnmatched { .. }
1057 | Self::EntryPatternRedundant { .. }
1058 | Self::IgnoreDependenciesEntryRedundant { .. }
1059 | Self::IgnorePatternsEntryRedundant { .. }
1060 | Self::BaselineInstallStateMismatch { .. } => false,
1061 }
1062 }
1063
1064 /// Whether this diagnostic is recorded by the PLUGIN stage (framework
1065 /// plugins reading their own build configs) rather than by workspace
1066 /// discovery, source discovery, the analyze stage or the health pipeline.
1067 ///
1068 /// Plugin-stage diagnostics are recorded after config load, so
1069 /// `stash_workspace_diagnostics` must preserve them across combined mode's
1070 /// per-analysis config re-loads, and each plugin run replaces the previous
1071 /// run's set so a fixed config drops out on the next run (issue #2736).
1072 ///
1073 /// They are deliberately NOT [`Self::is_analysis_stage`], although they
1074 /// share both of those properties. That predicate additionally means "the
1075 /// dead-code analyze pass re-records this", and the pass clears every kind
1076 /// answering it on entry. Plugins run in the prelude of that same pass, so
1077 /// a plugin-stage kind classified there would be wiped inside the run that
1078 /// produced it.
1079 ///
1080 /// The match is exhaustive on purpose: a new kind must be classified here
1081 /// before it compiles.
1082 #[must_use]
1083 pub const fn is_plugin_stage(&self) -> bool {
1084 match self {
1085 Self::PluginConfigUnreadable { .. } | Self::PluginEffectNotModeled { .. } => true,
1086 Self::UndeclaredWorkspace
1087 | Self::MalformedPackageJson { .. }
1088 | Self::GlobMatchedNoPackageJson { .. }
1089 | Self::WorkspaceGlobOutsideRoot { .. }
1090 | Self::WorkspacePatternDirMissing { .. }
1091 | Self::NestedProjectRoot
1092 | Self::MalformedTsconfig { .. }
1093 | Self::TsconfigReferenceDirMissing
1094 | Self::MalformedPnpmWorkspaceYaml { .. }
1095 | Self::SkippedLargeFile { .. }
1096 | Self::SkippedMinifiedFile { .. }
1097 | Self::SkippedSourceDotdir
1098 | Self::SourceReadFailure { .. }
1099 | Self::SourceParseDegraded { .. }
1100 | Self::SourceNestingTooDeep { .. }
1101 | Self::BunLockbOverrideResolutionSkipped
1102 | Self::BunLockOverrideResolutionSkipped
1103 | Self::PnpmLockOverrideResolutionSkipped
1104 | Self::NpmLockOverrideResolutionSkipped
1105 | Self::BunResolutionsShadowedByOverrides
1106 | Self::PnpmWorkspaceOverridesIgnored { .. }
1107 | Self::NodeModulesMissing
1108 | Self::BoundariesNotConfigured
1109 | Self::RulePacksNotConfigured
1110 | Self::ExcludedByDefaultIgnore { .. }
1111 | Self::NoSourceFilesAnalyzed { .. }
1112 | Self::FileScoresUnavailable { .. }
1113 | Self::HotspotsSkipped { .. }
1114 | Self::ChangeCouplingSkipped { .. }
1115 | Self::ShallowClone { .. }
1116 | Self::UnpinnedClock
1117 | Self::OwnershipUnavailable { .. }
1118 | Self::TrendSnapshotUnreadable { .. }
1119 | Self::TrendGroupBaselineUnavailable { .. }
1120 | Self::CoverageAutoDetected
1121 | Self::FlagAgeShallowClone
1122 | Self::FlagAgeUnavailable { .. }
1123 | Self::IgnoreDependenciesGlobUnmatched { .. }
1124 | Self::IgnoreFindingsPatternUnmatched { .. }
1125 | Self::EntryPatternRedundant { .. }
1126 | Self::IgnoreDependenciesEntryRedundant { .. }
1127 | Self::IgnorePatternsEntryRedundant { .. }
1128 | Self::BaselineInstallStateMismatch { .. } => false,
1129 }
1130 }
1131}
1132
1133/// Render a byte count as a megabyte figure with one decimal place for
1134/// human-readable diagnostic messages (e.g. `12.3 MB`).
1135#[must_use]
1136fn format_size_mb(bytes: u64) -> String {
1137 #[expect(
1138 clippy::cast_precision_loss,
1139 reason = "display-only size figure; precision loss past 2^53 bytes is irrelevant"
1140 )]
1141 let mb = bytes as f64 / (1024.0 * 1024.0);
1142 format!("{mb:.1} MB")
1143}
1144
1145/// A diagnostic about a workspace-discovery candidate.
1146///
1147/// The `message` field is a human-readable rendering derived from `kind`. It
1148/// always ends with a concrete next step ("fix the JSON syntax", "remove from
1149/// `workspaces`", "add to `ignorePatterns`") so first-time users have a path
1150/// forward.
1151#[derive(Debug, Clone, Serialize, Deserialize)]
1152#[cfg_attr(feature = "schema", derive(JsonSchema))]
1153pub struct WorkspaceDiagnostic {
1154 /// Path to the directory or file that triggered the diagnostic.
1155 #[serde(serialize_with = "serde_path::serialize")]
1156 pub path: PathBuf,
1157 /// Kind discriminator with the typed payload.
1158 #[serde(flatten)]
1159 pub kind: WorkspaceDiagnosticKind,
1160 /// Human-readable rendering derived from `kind` + `path`. Always ends
1161 /// with a next-step hint.
1162 pub message: String,
1163 /// True when this diagnostic reports a run whose RESULTS are degraded:
1164 /// something the user installed, wrote, or expected did not reach the
1165 /// analysis. Projected from [`WorkspaceDiagnosticKind::warns_on_stderr`],
1166 /// which is the same classification that decides whether the CLI prints a
1167 /// stderr line, so a CI log built from this field and a local non-quiet run
1168 /// say the same thing.
1169 ///
1170 /// Omitted when false, which is what keeps every clean run byte-identical.
1171 /// The two unconfigured-check kinds answer false on purpose: they fire in
1172 /// the product's default state on every project that never opted into
1173 /// boundaries or rule packs, so warning on them would warn forever. So does
1174 /// `excluded-by-default-ignore`, which is designed behavior on generated
1175 /// output; the alarm for that case is `no-source-files-analyzed`.
1176 ///
1177 /// Read this instead of hardcoding a kind allowlist: a degrading kind added
1178 /// in a later release then reaches an unchanged consumer.
1179 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1180 pub degrades_analysis: bool,
1181}
1182
1183impl WorkspaceDiagnostic {
1184 /// Construct a diagnostic with the message rendered from `kind` + `path`.
1185 ///
1186 /// `root` is used to produce project-relative paths in the message text
1187 /// AND inside the variant payload (e.g. the `error` field of
1188 /// `MalformedPackageJson` / `MalformedTsconfig` which embed the absolute
1189 /// file path from `PackageJson::load()`'s error text). Without the
1190 /// payload-side normalisation the embedded path would survive
1191 /// environment-specific differences (CI vs Docker vs local) because the
1192 /// post-serialisation `strip_root_prefix` only catches whole-string
1193 /// matches, not paths embedded mid-sentence.
1194 ///
1195 /// If `path` is not under `root` (e.g. canonicalisation crossed a
1196 /// symlink), the absolute path is emitted instead.
1197 ///
1198 /// `path` also loses any no-op `.` component, for the same reason the
1199 /// payload loses a glob's `./` prefix: one directory reached through two
1200 /// spellings of one glob must be one diagnostic.
1201 #[must_use]
1202 pub fn new(root: &Path, path: PathBuf, kind: WorkspaceDiagnosticKind) -> Self {
1203 let path = normalise_diagnostic_path(path);
1204 let kind = normalise_payload_paths(root, kind);
1205 let message = render_message(root, &path, &kind);
1206 let degrades_analysis = kind.warns_on_stderr();
1207 Self {
1208 path,
1209 kind,
1210 message,
1211 degrades_analysis,
1212 }
1213 }
1214
1215 /// Return this diagnostic with `path` rewritten relative to `root`.
1216 ///
1217 /// `path` is stored absolute so callers can act on it. Every JSON envelope
1218 /// emits it project-relative instead: the analysis envelopes get there
1219 /// through the post-serialisation `strip_root_prefix` pass, which the
1220 /// `fallow workspaces` / `fallow list --workspaces` envelope and the MCP
1221 /// `project_info` tool never run, so those emitted the absolute path while
1222 /// the sibling `workspaces[].path` next to it was relative. They normalise
1223 /// at the typed layer with this method instead.
1224 ///
1225 /// Paths outside `root` (canonicalisation crossed a symlink) are left
1226 /// absolute, matching how [`Self::new`] renders the message.
1227 ///
1228 /// A diagnostic anchored at the root itself becomes `.`, not the empty
1229 /// path: an empty string is not a location, and the analysis envelopes'
1230 /// post-serialisation strip only removes a `root + separator` prefix, so a
1231 /// root-anchored path that stays absolute here leaks a host path.
1232 #[must_use]
1233 pub fn into_root_relative(mut self, root: &Path) -> Self {
1234 if let Ok(relative) = self.path.strip_prefix(root) {
1235 self.path = if relative.as_os_str().is_empty() {
1236 PathBuf::from(".")
1237 } else {
1238 relative.to_path_buf()
1239 };
1240 }
1241 self
1242 }
1243}
1244
1245/// Rebuild `path` from its components so one directory has one spelling.
1246///
1247/// The dedupe key was never the problem: [`Path`] equality already ignores an
1248/// interior `.`, so `<root>/./pkgs/aaa` and `<root>/pkgs/aaa` are one key. The
1249/// stored bytes were. A workspace glob spelled `./pkgs/*` in `package.json`
1250/// expands to the first spelling and the same glob spelled `pkgs/*` in
1251/// `pnpm-workspace.yaml` expands to the second, and the two envelope families
1252/// make a project-relative path differently: the analysis envelopes strip the
1253/// root as a string (leaving `./pkgs/aaa`) while the workspace listing
1254/// envelope uses [`WorkspaceDiagnostic::into_root_relative`] (leaving
1255/// `pkgs/aaa`). Whichever
1256/// manifest happened to be read first then decided which shape every consumer
1257/// saw. Collapsing at construction gives them one answer (issue #2366).
1258///
1259/// A path that is already component-clean rebuilds to itself. Serialization
1260/// normalises separators, so the rebuild is wire-invisible on Windows.
1261fn normalise_diagnostic_path(path: PathBuf) -> PathBuf {
1262 let rebuilt: PathBuf = path.components().collect();
1263 if rebuilt.as_os_str() == path.as_os_str() {
1264 path
1265 } else {
1266 rebuilt
1267 }
1268}
1269
1270/// Strip the project root from absolute paths embedded inside variant
1271/// payloads (the `error` field of malformed-config and source-read failures),
1272/// and drop a glob pattern's no-op `./` prefix.
1273///
1274/// Mirrors the per-platform `display()` byte sequence so the substring match
1275/// works on Windows too.
1276///
1277/// The pattern prefix matters because the payload is part of the dedupe key in
1278/// [`merge_workspace_diagnostics`]. A repository whose `package.json` declares
1279/// `"./apps/**"` and whose `pnpm-workspace.yaml` declares `apps/**` names one
1280/// glob twice, and without this both spellings would report every package-less
1281/// directory under `apps/` a second time (issue #2366).
1282fn normalise_payload_paths(root: &Path, kind: WorkspaceDiagnosticKind) -> WorkspaceDiagnosticKind {
1283 let root_str = root.display().to_string();
1284 let root_alt = root_str.replace('\\', "/");
1285 let normalise = |text: String| -> String {
1286 let stripped = text
1287 .replace(&format!("{root_str}/"), "")
1288 .replace(&format!("{root_alt}/"), "");
1289 stripped
1290 .replace(&format!("{root_str}\\"), "")
1291 .replace(&format!("{root_alt}\\"), "")
1292 };
1293 match kind {
1294 WorkspaceDiagnosticKind::MalformedPackageJson { error } => {
1295 WorkspaceDiagnosticKind::MalformedPackageJson {
1296 error: normalise(error),
1297 }
1298 }
1299 WorkspaceDiagnosticKind::MalformedTsconfig { error } => {
1300 WorkspaceDiagnosticKind::MalformedTsconfig {
1301 error: normalise(error),
1302 }
1303 }
1304 WorkspaceDiagnosticKind::SourceReadFailure { error } => {
1305 WorkspaceDiagnosticKind::SourceReadFailure {
1306 error: normalise(error),
1307 }
1308 }
1309 WorkspaceDiagnosticKind::FileScoresUnavailable { error } => {
1310 WorkspaceDiagnosticKind::FileScoresUnavailable {
1311 error: normalise(error),
1312 }
1313 }
1314 WorkspaceDiagnosticKind::OwnershipUnavailable { cause, error } => {
1315 WorkspaceDiagnosticKind::OwnershipUnavailable {
1316 cause,
1317 error: normalise(error),
1318 }
1319 }
1320 WorkspaceDiagnosticKind::TrendSnapshotUnreadable { error } => {
1321 WorkspaceDiagnosticKind::TrendSnapshotUnreadable {
1322 error: normalise(error),
1323 }
1324 }
1325 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => {
1326 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1327 pattern: canonical_glob_pattern(pattern),
1328 }
1329 }
1330 WorkspaceDiagnosticKind::WorkspaceGlobOutsideRoot { pattern } => {
1331 WorkspaceDiagnosticKind::WorkspaceGlobOutsideRoot {
1332 pattern: canonical_glob_pattern(pattern),
1333 }
1334 }
1335 WorkspaceDiagnosticKind::WorkspacePatternDirMissing { pattern } => {
1336 WorkspaceDiagnosticKind::WorkspacePatternDirMissing {
1337 pattern: canonical_glob_pattern(pattern),
1338 }
1339 }
1340 other => other,
1341 }
1342}
1343
1344/// Drop the leading `./` (or `.\`) a workspace glob may carry, so the same
1345/// pattern declared in two manifests is one payload.
1346///
1347/// A pattern that is nothing BUT the prefix (`"./"`, the root itself) keeps
1348/// its spelling: stripping it would report an empty `pattern` field and an
1349/// empty quoted glob in the warning text, which names no glob at all.
1350fn canonical_glob_pattern(pattern: String) -> String {
1351 for prefix in ["./", ".\\"] {
1352 if let Some(rest) = pattern.strip_prefix(prefix)
1353 && !rest.is_empty()
1354 {
1355 return rest.to_owned();
1356 }
1357 }
1358 pattern
1359}
1360
1361/// Concatenate two diagnostic lists, keeping the first occurrence of each
1362/// `(kind, path)` pair and the order of `primary` followed by the entries only
1363/// `secondary` has.
1364///
1365/// The single place diagnostics from two observation points are folded
1366/// together: an engine session's own capture plus the process registry, and
1367/// the combined run's per-analysis lists (issue #2366). A combined run walks
1368/// the project once per analysis, and per-analysis `production` modes can make
1369/// those walks see different file sets, so no single observation point holds
1370/// everything the run recorded; the union does, and folding it the same way
1371/// everywhere is what keeps the CLI and the programmatic route answering
1372/// identically.
1373///
1374/// The key is the WHOLE kind, payload included, not its
1375/// [`id`](WorkspaceDiagnosticKind::id). Two entries can share a kind id and a
1376/// path and still be two distinct diagnostics: overlapping workspace globs
1377/// (`["packages/*", "packages/*/*"]`) each report the same package-less
1378/// directory with their own `pattern`, and the standalone envelopes report
1379/// both. An id-keyed fold silently dropped the second one.
1380#[must_use]
1381pub fn merge_workspace_diagnostics(
1382 primary: Vec<WorkspaceDiagnostic>,
1383 secondary: Vec<WorkspaceDiagnostic>,
1384) -> Vec<WorkspaceDiagnostic> {
1385 let mut merged = Vec::with_capacity(primary.len() + secondary.len());
1386 let mut seen: FxHashSet<(WorkspaceDiagnosticKind, PathBuf)> = FxHashSet::default();
1387 for diagnostic in primary.into_iter().chain(secondary) {
1388 let key = (diagnostic.kind.clone(), diagnostic.path.clone());
1389 if seen.insert(key) {
1390 merged.push(diagnostic);
1391 }
1392 }
1393 merged
1394}
1395
1396/// Keep the first occurrence of each `(kind, path)` pair in one list.
1397///
1398/// The single-list form of [`merge_workspace_diagnostics`], applied where
1399/// diagnostics are produced rather than where two observation points are
1400/// folded: workspace discovery reads `package.json` `workspaces`,
1401/// `pnpm-workspace.yaml` `packages`, `deno.json` `workspace` and the root
1402/// `tsconfig.json` references additively, so a repository that declares one
1403/// glob in two of them reports every package-less directory under it twice.
1404/// Deduplicating at that source is what keeps the JSON envelopes, the
1405/// aggregated stderr warning and the process registry telling one story
1406/// (issue #2366).
1407#[must_use]
1408pub fn dedupe_workspace_diagnostics(
1409 diagnostics: Vec<WorkspaceDiagnostic>,
1410) -> Vec<WorkspaceDiagnostic> {
1411 merge_workspace_diagnostics(diagnostics, Vec::new())
1412}
1413
1414/// The first segment of a glob that contains no glob metacharacter, so it
1415/// names a real directory rather than a wildcard.
1416///
1417/// Source discovery uses it to decide which directory a built-in ignore
1418/// pattern excluded a file "at"; `render_message` uses it to decide which
1419/// remedy is true for that pattern. The two have to agree, so the function
1420/// lives here rather than once per crate: a pattern with such a segment
1421/// (`**/dist/**`) is lifted by re-rooting inside the matched directory,
1422/// because the glob is matched against the path relative to the run root. A
1423/// pattern without one (`**/*.min.js`) matches on the file name and keeps
1424/// matching at every root.
1425#[must_use]
1426pub fn glob_first_literal_segment(pattern: &str) -> Option<&str> {
1427 pattern.split('/').find(|segment| {
1428 !segment.is_empty()
1429 && !segment.contains(['*', '?', '[', ']', '{', '}'])
1430 && *segment != "."
1431 && *segment != ".."
1432 })
1433}
1434
1435/// The directory part of a workspace pattern: the segments before the first
1436/// segment with a glob character, joined with `/`.
1437///
1438/// `vite/packages/*` gives `vite/packages`, `vite` gives `vite`, and a
1439/// pattern that starts with a glob segment (`**/pkg`, `{a,b}/*`) gives
1440/// `None`. A leading `./` and empty or `.` segments are dropped. Workspace
1441/// discovery uses it to find a pattern whose directory does not exist, and
1442/// the diagnostic message uses it to name that directory, so both read the
1443/// same part of the pattern.
1444#[must_use]
1445pub fn workspace_pattern_literal_dir(pattern: &str) -> Option<String> {
1446 let literal: Vec<&str> = pattern
1447 .split(['/', '\\'])
1448 .filter(|segment| !segment.is_empty() && *segment != ".")
1449 .take_while(|segment| !segment.contains(['*', '?', '[', ']', '{', '}']))
1450 .collect();
1451 if literal.is_empty() {
1452 None
1453 } else {
1454 Some(literal.join("/"))
1455 }
1456}
1457
1458/// The clause naming why a plugin could not read a config key in full, for one
1459/// `plugin-config-unreadable` reason token.
1460///
1461/// The token set is open, so an unrecognised token renders the general claim
1462/// rather than nothing: a diagnostic from a plugin added later still reads as a
1463/// sentence.
1464fn unreadable_situation(reason: &str) -> &'static str {
1465 match reason {
1466 "array-form" => "uses the array form, which is not read yet",
1467 "spread" => "spreads a value that is not statically readable",
1468 "unreadable-entries" => "has entries that hold no statically readable value",
1469 "not-object-literal" => "is not a static object literal",
1470 "unrecognized-call" => "is passed through a call that is not a known config wrapper",
1471 "import-target-unreadable" => "comes from an imported file that is not statically readable",
1472 "dynamic-argument" => "receives an argument that is not a static literal",
1473 _ => "could not be read statically",
1474 }
1475}
1476
1477/// What an unread config key costs, and the configuration option that covers
1478/// the gap.
1479///
1480/// Keyed on the config KEY rather than on the plugin name, because one key is
1481/// read by several plugins: Module Federation `exposes` and `remotes` reach a
1482/// build from a standalone config file and inline from the webpack, rspack,
1483/// rsbuild and vite configs, and both the consequence and the remedy are the
1484/// same in all five. A key this build does not know falls back to the general
1485/// claim rather than borrowing another key's remedy, so a plugin added later
1486/// still renders a sentence that is true.
1487///
1488/// Two reasons change the remedy. An unrecognized call was read as a lower
1489/// bound, so only what the call adds is missing. An unreadable import target
1490/// holds config that is shared across files, so the remedy names the option
1491/// and does not ask for an object literal.
1492fn unreadable_key_consequence(key: &str, reason: &str) -> (&'static str, &'static str) {
1493 match (key, reason) {
1494 ("exposes", "unrecognized-call") => (
1495 "only the targets in the object literal it receives are registered as entry points",
1496 "Name any other exposed files in `dynamicallyLoaded`.",
1497 ),
1498 ("remotes", "unrecognized-call") => (
1499 "only the aliases in the object literal it receives are treated as provided by a \
1500 remote container",
1501 "Name any other aliases in `ignoreDependencies`.",
1502 ),
1503 ("exposes", "import-target-unreadable") => (
1504 "the targets that file declares are not registered as entry points",
1505 "Name the exposed files in `dynamicallyLoaded`.",
1506 ),
1507 ("remotes", "import-target-unreadable") => (
1508 "the aliases that file declares are not treated as provided by a remote container",
1509 "Name the aliases in `ignoreDependencies`.",
1510 ),
1511 ("registerRemotes", _) => (
1512 "the remotes it registers are not treated as provided by a remote container",
1513 "Name the remote aliases in `ignoreDependencies`, or pass the remote names as \
1514 string literals.",
1515 ),
1516 ("init" | "createInstance", _) => (
1517 "the remotes its options declare are not treated as provided by a remote container",
1518 "Name the remote aliases in `ignoreDependencies`, or pass `remotes` as an array of \
1519 objects with literal names.",
1520 ),
1521 ("loadRemote", _) => (
1522 "the remote it loads is not treated as provided by a remote container",
1523 "Name the remote alias in `ignoreDependencies`, or pass the request as a string \
1524 literal.",
1525 ),
1526 ("exposes", _) => (
1527 "the targets are not registered as entry points",
1528 "Name the exposed files in `dynamicallyLoaded`.",
1529 ),
1530 ("remotes", _) => (
1531 "the aliases are not treated as provided by a remote container",
1532 "Name the aliases in `ignoreDependencies`, or declare them as the keys of an object \
1533 literal, whose values may be computed.",
1534 ),
1535 _ => (
1536 "what it declares is not fully registered",
1537 "Declare the value as a static object literal.",
1538 ),
1539 }
1540}
1541
1542fn render_message(root: &Path, path: &Path, kind: &WorkspaceDiagnosticKind) -> String {
1543 let display = display_relative(root, path);
1544 match kind {
1545 WorkspaceDiagnosticKind::UndeclaredWorkspace => format!(
1546 "Directory '{display}' contains package.json but is not declared as a workspace. \
1547 Add it to package.json workspaces or pnpm-workspace.yaml, or add it to ignorePatterns."
1548 ),
1549 WorkspaceDiagnosticKind::MalformedPackageJson { error } => format!(
1550 "Dropped workspace '{display}': package.json is not valid JSON ({error}). \
1551 Fix the JSON syntax or remove '{display}' from the workspaces pattern."
1552 ),
1553 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => format!(
1554 "Glob '{pattern}' matched '{display}' but no package.json is present. \
1555 Add a package.json, narrow the pattern, or add '{display}' to ignorePatterns."
1556 ),
1557 WorkspaceDiagnosticKind::WorkspaceGlobOutsideRoot { pattern } => {
1558 let source = if display.is_empty() {
1559 "the fallow config `workspaces.patterns`".to_owned()
1560 } else {
1561 format!("'{display}'")
1562 };
1563 format!(
1564 "Skipped workspace entry '{pattern}' in {source}: it is an absolute path \
1565 or its '..' segments lead above the project root, so it can match directories \
1566 outside the root. \
1567 Fallow analyzes no package from it. Move the package inside the project root, \
1568 or run fallow with --root set to a directory that holds both."
1569 )
1570 }
1571 WorkspaceDiagnosticKind::WorkspacePatternDirMissing { pattern } => {
1572 let source = if display.is_empty() {
1573 "the fallow config `workspaces.patterns`".to_owned()
1574 } else {
1575 format!("'{display}'")
1576 };
1577 let dir = workspace_pattern_literal_dir(pattern).unwrap_or_else(|| pattern.clone());
1578 format!(
1579 "Workspace pattern '{pattern}' in {source} names directory '{dir}', which does \
1580 not exist. Fallow analyzes no package from it. Unused catalog entries are not \
1581 reported in this run, because packages in that directory can use them. \
1582 Create the directory before the run, or remove the pattern."
1583 )
1584 }
1585 WorkspaceDiagnosticKind::NestedProjectRoot => format!(
1586 "Directory '{display}' has its own pnpm-workspace.yaml, so it is an independent \
1587 project. Fallow analyzes it as a workspace: its package.json owns its files and \
1588 dependencies, and its catalog references are not checked against the root \
1589 catalog. Add '{display}' to ignorePatterns to exclude it."
1590 ),
1591 WorkspaceDiagnosticKind::MalformedTsconfig { error } => format!(
1592 "tsconfig at '{display}' failed to parse ({error}); \
1593 its project references, path aliases and compiler options are ignored. \
1594 Fix the JSON syntax."
1595 ),
1596 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => format!(
1597 "tsconfig.json references '{display}' but the directory does not exist. \
1598 Update or remove the reference, or restore the missing directory."
1599 ),
1600 WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml { error } => format!(
1601 "'{display}' is not valid YAML ({error}). Catalog checks are skipped \
1602 and its override entries are ignored until it parses. pnpm install \
1603 can accept this file, but `pnpm add` and YAML formatters reject it. \
1604 Indent each continuation line of a quoted value or a `[...]` / \
1605 `{{...}}` list deeper than its key."
1606 ),
1607 WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes } => format!(
1608 "Skipped '{display}' ({size}): exceeds the max file size limit. \
1609 Its imports and exports are not analyzed. Raise the limit with \
1610 --max-file-size <MB> (or FALLOW_MAX_FILE_SIZE), or add '{display}' \
1611 to ignorePatterns.",
1612 size = format_size_mb(*size_bytes)
1613 ),
1614 WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes } => format!(
1615 "Skipped '{display}' ({size}): appears to be minified generated JavaScript. \
1616 Its imports and exports are not analyzed. Add '{display}' to ignorePatterns, \
1617 rename it with a .min.js suffix, or use --max-file-size 0 if this file \
1618 should be analyzed.",
1619 size = format_size_mb(*size_bytes)
1620 ),
1621 WorkspaceDiagnosticKind::SkippedSourceDotdir => format!(
1622 "Skipped hidden directory '{display}': it contains source files but hidden \
1623 directories are not traversed. Its imports and exports are not analyzed. \
1624 A file, export or dependency that only this directory uses can be reported as \
1625 unused. To analyze it, add '!{display}/**' to ignorePatterns. To stop \
1626 that false positive without that, add the file to entry, the export to \
1627 ignoreExports or the dependency to ignoreDependencies. To silence this message, \
1628 add '{display}/**' to ignorePatterns. fallow --root {display} analyzes only \
1629 that directory on its own and does not fix this run."
1630 ),
1631 WorkspaceDiagnosticKind::SourceReadFailure { error } => format!(
1632 "Could not read source '{display}' ({error}). Restore the file or its read permissions, \
1633 ensure it contains valid UTF-8 text, or add '{display}' to ignorePatterns."
1634 ),
1635 WorkspaceDiagnosticKind::SourceParseDegraded {
1636 error_count,
1637 panicked,
1638 } => {
1639 let outcome = if *panicked {
1640 "the parser stopped there"
1641 } else {
1642 "the parser recovered and continued"
1643 };
1644 format!(
1645 "Parsed '{display}' with {error_count} error(s); {outcome}. Imports, exports, and \
1646 references it did not reach are missing from this run, so files and symbols it \
1647 uses can be reported as unused. Fix the syntax, or ignore this if the file uses \
1648 syntax newer than fallow's parser."
1649 )
1650 }
1651 WorkspaceDiagnosticKind::SourceNestingTooDeep { limit } => format!(
1652 "Did not parse '{display}': it nests brackets deeper than {limit} levels, or it \
1653 needs more stack than fallow reserves, so a parse could overflow the stack. Its \
1654 imports and exports are missing from this run, so files and symbols it uses can be \
1655 reported as unused, and while the file is reachable `fallow fix` holds back its \
1656 actions for the whole project. Add '{display}' to ignoreFindings if it is generated \
1657 code, or reduce its nesting."
1658 ),
1659 WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped => format!(
1660 "Skipped dependency-override resolution for '{display}': bun's legacy binary bun.lockb \
1661 sits next to it, fallow cannot read the binary format, and no parseable text lockfile \
1662 (bun.lock, pnpm-lock.yaml, package-lock.json, or npm-shrinkwrap.json) was found to \
1663 use instead, so unused-dependency-overrides findings are not reported. Run bun install \
1664 --save-text-lockfile (bun 1.2 or newer) to write a text bun.lock, or delete the stale \
1665 bun.lockb if this repository no longer uses bun."
1666 ),
1667 WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped => format!(
1668 "Skipped dependency-override resolution because '{display}' could not be parsed and \
1669 no readable pnpm or npm lockfile was available, so unused-dependency-overrides \
1670 findings are not reported. Run bun install to regenerate the text lockfile, then \
1671 rerun fallow."
1672 ),
1673 WorkspaceDiagnosticKind::PnpmLockOverrideResolutionSkipped => format!(
1674 "Skipped dependency-override resolution because '{display}' could not be parsed and \
1675 no other parseable lockfile was available, so unused-dependency-overrides findings \
1676 are not reported. Resolve any merge-conflict markers in the lockfile, or run pnpm \
1677 install to regenerate it, then rerun fallow."
1678 ),
1679 WorkspaceDiagnosticKind::NpmLockOverrideResolutionSkipped => format!(
1680 "Skipped dependency-override resolution because '{display}' could not be parsed and \
1681 no other parseable lockfile was available, so unused-dependency-overrides findings \
1682 are not reported. Resolve any merge-conflict markers in the lockfile, or run npm \
1683 install to regenerate it, then rerun fallow."
1684 ),
1685 WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides => format!(
1686 "'{display}' declares both `overrides` and non-empty `resolutions`; bun applies \
1687 `overrides` and ignores `resolutions`. Move the intended pins into `overrides` or \
1688 remove the shadowed `resolutions` entries."
1689 ),
1690 WorkspaceDiagnosticKind::PnpmWorkspaceOverridesIgnored {
1691 cause: PnpmWorkspaceOverridesIgnoredCause::PackageJsonOverrides,
1692 } => format!(
1693 "pnpm 10 and earlier ignore the `overrides` in '{display}' because the root package.json \
1694 declares `pnpm.overrides` or `resolutions`, so fallow does not check them. Move the \
1695 entries into one source."
1696 ),
1697 WorkspaceDiagnosticKind::PnpmWorkspaceOverridesIgnored {
1698 cause: PnpmWorkspaceOverridesIgnoredCause::PnpmVersion,
1699 } => format!(
1700 "pnpm versions before 10.5.1 do not read the `overrides` in '{display}', so fallow \
1701 does not check them. Update `packageManager` to pnpm 10.5.1 or later, or move the \
1702 entries into `pnpm.overrides` in the root package.json."
1703 ),
1704 WorkspaceDiagnosticKind::NodeModulesMissing => format!(
1705 "'{display}' does not exist. Package exports and conditional exports cannot be read, \
1706 framework plugins that activate on an installed package stay inactive, and \
1707 dependency classification degrades, so imports and dependencies can be \
1708 misreported. Run npm install / pnpm install / yarn / bun install first."
1709 ),
1710 WorkspaceDiagnosticKind::BoundariesNotConfigured => {
1711 "No architecture boundaries are configured, so the boundary detector did not run and \
1712 its violation counts are zero because nothing was measured. Add `boundaries` to the \
1713 config, or set `boundary-violation` to off to state that the check is not wanted."
1714 .to_string()
1715 }
1716 WorkspaceDiagnosticKind::RulePacksNotConfigured => {
1717 "No rule packs are configured, so the policy detector did not run and its violation \
1718 counts are zero because nothing was measured. Add `rulePacks` to the config, or set \
1719 `policy-violation` to off to state that the check is not wanted."
1720 .to_string()
1721 }
1722 WorkspaceDiagnosticKind::NoSourceFilesAnalyzed {
1723 excluded_file_count,
1724 } => {
1725 if *excluded_file_count == 0 {
1726 "No source files were analyzed, so every finding count this run reports is zero \
1727 because nothing was measured. Check the analysis root, ignorePatterns, and any \
1728 path or workspace filter this run applied."
1729 .to_owned()
1730 } else {
1731 format!(
1732 "No source files were analyzed. Fallow's built-in ignore patterns excluded \
1733 {excluded_file_count} candidate files, so every finding count this run \
1734 reports is zero because nothing was measured; run with --explain-skipped \
1735 for the breakdown."
1736 )
1737 }
1738 }
1739 WorkspaceDiagnosticKind::FileScoresUnavailable { error } => format!(
1740 "Could not compute per-file health scores ({error}), so the score list is empty and \
1741 the scored-file count is 0 because nothing was measured rather than because the \
1742 project has nothing to score. Rerun with --no-cache, or scope the run to a \
1743 subdirectory to find the input that fails."
1744 ),
1745 WorkspaceDiagnosticKind::HotspotsSkipped { cause } => match cause.as_str() {
1746 "invalid-since" => "Hotspot analysis was skipped because --since could not be read \
1747 as a time window, so the hotspots, churn and ownership sections report nothing \
1748 rather than zero. Spell it as a duration such as 6m or 90d, or drop it to use \
1749 the default window."
1750 .to_owned(),
1751 "no-commits" => "Hotspot analysis was skipped because the current branch has no \
1752 commits yet, so the hotspots, churn and ownership sections report nothing \
1753 rather than zero. Commit the project to give churn a history, or pass \
1754 --churn-file with exported change history."
1755 .to_owned(),
1756 "churn-file-unreadable" => format!(
1757 "Hotspot analysis was skipped because the churn file '{display}' could no longer \
1758 be read after it was validated, so the hotspots, churn and ownership sections \
1759 report nothing rather than zero. Make sure nothing rewrites the file while \
1760 fallow runs, and rerun."
1761 ),
1762 // The original single cause, whose wording predates the token and
1763 // is kept byte-identical: a consumer matching on this sentence is
1764 // reading the same run it always was.
1765 _ => "Hotspot analysis was skipped because no git repository was found at the \
1766 project root, so the hotspots, churn and ownership sections report nothing \
1767 rather than zero. Run fallow inside the repository, or pass --churn-file with \
1768 exported change history."
1769 .to_owned(),
1770 },
1771 WorkspaceDiagnosticKind::ChangeCouplingSkipped { cause } => match cause.as_str() {
1772 "shallow-clone" => "Change coupling was not measured because the repository is a \
1773 shallow clone, so no change coupling pairs are reported. Fetch the full \
1774 history with `git fetch --unshallow` to measure it."
1775 .to_owned(),
1776 _ => "Change coupling found no pairs because the history window has fewer \
1777 analyzed commits than the minimum revisions of one file. Widen the window \
1778 with --since, or lower health.changeCoupling.minRevisions."
1779 .to_owned(),
1780 },
1781 WorkspaceDiagnosticKind::ShallowClone {
1782 ownership_requested,
1783 } => {
1784 let ownership = if *ownership_requested {
1785 " Ownership signals are skewed too, because a shallow clone inflates \
1786 single-author dominance."
1787 } else {
1788 ""
1789 };
1790 format!(
1791 "This is a shallow clone, so churn covers only the fetched history and every \
1792 hotspot figure is incomplete.{ownership} Run git fetch --unshallow for the full \
1793 history."
1794 )
1795 }
1796 WorkspaceDiagnosticKind::UnpinnedClock => {
1797 "No commit timestamp was available, so churn recency and ownership staleness were \
1798 measured against the wall clock and drift between runs over the same commit. Set \
1799 FALLOW_CLOCK_EPOCH to pin the run clock."
1800 .to_owned()
1801 }
1802 WorkspaceDiagnosticKind::OwnershipUnavailable { cause, error } => {
1803 if cause == "codeowners-parse-failed" {
1804 format!(
1805 "Ownership signals are degraded: CODEOWNERS could not be parsed ({error}), \
1806 so hotspot entries carry no declared owner. Fix the CODEOWNERS syntax, or \
1807 drop --ownership for this run."
1808 )
1809 } else {
1810 format!(
1811 "Ownership signals are degraded: health.ownership.botPatterns contains an \
1812 invalid glob ({error}), so no author is classified as a bot and bot commits \
1813 count towards ownership. Fix the pattern, or remove it from the config."
1814 )
1815 }
1816 }
1817 WorkspaceDiagnosticKind::TrendSnapshotUnreadable { error } => format!(
1818 "Skipped health snapshot '{display}' ({error}), so the trend is computed over fewer \
1819 snapshots than this project has on disk. Delete the unreadable file, or rewrite it \
1820 with fallow health --save-snapshot."
1821 ),
1822 WorkspaceDiagnosticKind::TrendGroupBaselineUnavailable { cause } => {
1823 if cause == "grouped-by-mismatch" {
1824 format!(
1825 "Health snapshot '{display}' was saved with a different --group-by mode, so \
1826 the groups have no trend. The project trend is not affected. Save a new \
1827 snapshot with the same --group-by mode."
1828 )
1829 } else {
1830 format!(
1831 "Health snapshot '{display}' holds no group data, so the groups have no \
1832 trend. The project trend is not affected. Save a new snapshot with \
1833 --group-by and --save-snapshot."
1834 )
1835 }
1836 }
1837 WorkspaceDiagnosticKind::CoverageAutoDetected => format!(
1838 "Coverage was auto-detected at '{display}' rather than passed with --coverage, so the \
1839 CRAP scores depend on whichever coverage file is on disk at run time. Pass --coverage \
1840 '{display}' explicitly for reproducible scores."
1841 ),
1842 WorkspaceDiagnosticKind::FlagAgeShallowClone => {
1843 "This is a shallow clone, so the flag retirement report gives no flag age. Run git \
1844 fetch --unshallow for the full history, or pass --flag-age off."
1845 .to_owned()
1846 }
1847 WorkspaceDiagnosticKind::IgnoreDependenciesGlobUnmatched { pattern } => format!(
1848 "ignoreDependencies glob '{pattern}' matched no declared dependency in this run, so \
1849 it has no effect. A glob matches package names, such as @scope/*. Fix the glob, or \
1850 remove it from the config."
1851 ),
1852 WorkspaceDiagnosticKind::BaselineInstallStateMismatch {
1853 baseline_node_modules,
1854 ..
1855 } => {
1856 let (saved, now, remedy) = match baseline_node_modules {
1857 NodeModulesState::Present => (
1858 "present",
1859 "has no node_modules",
1860 "Install dependencies before you compare",
1861 ),
1862 NodeModulesState::Missing => (
1863 "missing",
1864 "has node_modules",
1865 "Compare without installed dependencies",
1866 ),
1867 };
1868 format!(
1869 "The baseline '{display}' was saved with node_modules {saved}, and this run \
1870 {now}. Findings that need installed packages (dependency, unlisted, \
1871 unresolved-import and script-binary findings) can differ, so the run can report \
1872 baseline findings as new. {remedy}, or save the baseline again in the same \
1873 install state."
1874 )
1875 }
1876 WorkspaceDiagnosticKind::EntryPatternRedundant { .. }
1877 | WorkspaceDiagnosticKind::IgnoreDependenciesEntryRedundant { .. }
1878 | WorkspaceDiagnosticKind::IgnorePatternsEntryRedundant { .. } => {
1879 config_entry::redundant_entry_message(kind)
1880 }
1881 WorkspaceDiagnosticKind::IgnoreFindingsPatternUnmatched { pattern } => format!(
1882 "ignoreFindings pattern '{pattern}' matched no finding in this run, so it has no \
1883 effect. A pattern is a glob relative to the project root. Fix the pattern, or \
1884 remove it from the config."
1885 ),
1886 WorkspaceDiagnosticKind::FlagAgeUnavailable { cause } => {
1887 if cause == "no-commits" {
1888 "The flag retirement report gives no flag age, because the current branch has no \
1889 commit. Commit the code, or pass --flag-age off."
1890 .to_owned()
1891 } else {
1892 "The flag retirement report gives no flag age, because no git repository was \
1893 found at the project root. Run fallow inside the repository, or pass --flag-age \
1894 off."
1895 .to_owned()
1896 }
1897 }
1898 WorkspaceDiagnosticKind::PluginConfigUnreadable {
1899 plugin,
1900 key,
1901 reason,
1902 } => {
1903 let (consequence, advice) = unreadable_key_consequence(key, reason);
1904 format!(
1905 "Plugin '{plugin}': `{key}` in '{display}' {situation}, so {consequence}. {advice}",
1906 situation = unreadable_situation(reason)
1907 )
1908 }
1909 WorkspaceDiagnosticKind::PluginEffectNotModeled {
1910 plugin,
1911 key,
1912 reason,
1913 } => {
1914 // Two causes, one effect, one remedy. Each cause gets its own
1915 // sentence: the cause and the effect on the findings are separate
1916 // facts, and one sentence with two `so` clauses states neither fact
1917 // clearly.
1918 let effect = "`autoImports` kept the convention entry patterns for that surface, and \
1919 fallow reports no unused file there. Write the setting as static \
1920 literals, or remove the key to use the framework defaults.";
1921 if key.starts_with('#') {
1922 format!(
1923 "Plugin '{plugin}': fallow cannot read which names '{display}' takes from \
1924 `{key}`, so every name of `{key}` counts as used, and fallow reports no \
1925 unused file for these names. Read each name with a member access such as \
1926 `C.Card`, or import it by name."
1927 )
1928 } else if reason == "config-property-unreadable" {
1929 format!(
1930 "Plugin '{plugin}': fallow cannot read a top-level property in '{display}', so \
1931 it cannot classify the `{key}` surface. {effect}"
1932 )
1933 } else {
1934 format!(
1935 "Plugin '{plugin}': fallow does not model the effect of `{key}` in \
1936 '{display}'. {effect}"
1937 )
1938 }
1939 }
1940 WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
1941 pattern,
1942 file_count,
1943 directory_count,
1944 } => {
1945 // `path` is a location, and an empty string is not one: a built-in
1946 // that matched a file sitting directly at the analysis root
1947 // anchors at the root itself.
1948 let display = if display.is_empty() {
1949 ".".to_owned()
1950 } else {
1951 display
1952 };
1953 // The payload carries no directory list, so the message names the
1954 // one directory `path` anchors at. With several excluded
1955 // directories that is the largest group and NOT a majority, so the
1956 // sentence says which claim it is making and how many directories
1957 // it is leaving unnamed.
1958 let location = if *directory_count > 1 {
1959 format!(
1960 "Skipped {file_count} source files across {directory_count} directories, \
1961 the largest group under '{display}'"
1962 )
1963 } else if *file_count == 1 {
1964 format!("Skipped 1 source file under '{display}'")
1965 } else {
1966 format!("Skipped {file_count} source files under '{display}'")
1967 };
1968 let singular = *file_count == 1 && *directory_count <= 1;
1969 let (subject, effect) = if singular {
1970 ("it matches", "it imports, exports, or defines")
1971 } else {
1972 ("they match", "they import, export, or define")
1973 };
1974 // Only a directory-shaped built-in is lifted by re-rooting. Telling
1975 // a user with a `vendor/lib.min.js` to run `fallow --root vendor`
1976 // hands them a command that excludes the same file again.
1977 let remedy = if glob_first_literal_segment(pattern).is_some() {
1978 format!(
1979 "To analyze first-party source there, add '!{display}/**' to \
1980 ignorePatterns, or analyze that directory on its own with \
1981 fallow --root {display}."
1982 )
1983 } else {
1984 "This pattern matches a file name rather than a directory, so re-running under \
1985 a different --root excludes the same files again. Rename first-party source \
1986 that only looks generated, dropping the '.min' or '.bundle' infix, or add a \
1987 '!' entry for the file to ignorePatterns."
1988 .to_owned()
1989 };
1990 format!(
1991 "{location}: {subject} fallow's built-in ignore pattern '{pattern}', so nothing \
1992 {effect} is visible to this run. {remedy}"
1993 )
1994 }
1995 }
1996}
1997
1998#[cfg(test)]
1999#[path = "workspace_tests.rs"]
2000mod tests;