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