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