1use std::collections::{BTreeMap, BTreeSet};
4
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8mod suggestions;
9
10#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "camelCase")]
12pub enum Operation {
13 ReadData,
14 OpenRead,
15 Metadata,
16 ListDirectory,
17 ReadDirectory,
18 ReadLink,
19 Execute,
20 Write,
21 Rename,
22 Delete,
23}
24
25impl Operation {
26 pub fn writes(&self) -> bool {
27 matches!(self, Self::Write | Self::Rename | Self::Delete)
28 }
29}
30
31#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "camelCase")]
33pub enum Category {
34 Input,
35 Uncovered,
36 OwnOutput,
37 DependencyOutput,
38 WarmState,
39 Generated,
40 InstalledPackage,
43 UnkeyedPackage,
46 ResolutionMetadata,
47 StructuredInput,
48 Discovery,
49 GitState,
50 External,
51}
52
53#[derive(Clone, Debug, Serialize, Deserialize)]
54#[serde(rename_all = "camelCase")]
55pub struct Access {
56 pub process: u32,
57 pub path: String,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub resolved_path: Option<String>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub keyed_path: Option<String>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub package: Option<String>,
68 pub operation: Operation,
69 pub result: String,
71 pub category: Category,
72}
73
74#[derive(Clone, Debug, Serialize, Deserialize)]
75#[serde(rename_all = "camelCase")]
76pub struct Coverage {
77 pub backend: String,
78 pub partial: bool,
79 pub limitations: Vec<String>,
80 pub diagnostics: Vec<String>,
81}
82
83#[derive(Clone, Debug, Serialize, Deserialize)]
85#[serde(rename_all = "camelCase")]
86pub struct GlobSuggestion {
87 pub fileset: String,
88 pub observed_files: usize,
89 pub additional_declared_files: usize,
90 #[serde(default)]
91 pub reason: String,
92}
93
94#[derive(Clone, Debug, Serialize, Deserialize)]
96#[serde(rename_all = "camelCase")]
97pub struct Declaration {
98 pub target: String,
99 pub inputs: Vec<Value>,
100 pub named_inputs: BTreeMap<String, Vec<Value>>,
101}
102
103#[derive(Clone, Debug, Serialize, Deserialize)]
104#[serde(rename_all = "camelCase")]
105pub struct TaskAnalysis {
106 pub context: Value,
107 pub outcome: String,
108 pub coverage: Coverage,
109 pub declared_files: BTreeSet<String>,
110 pub mandatory_files: BTreeSet<String>,
112 pub accesses: Vec<Access>,
113 pub observed_inputs: BTreeSet<String>,
114 pub uncovered_accesses: BTreeSet<String>,
115 pub unobserved_inputs: BTreeSet<String>,
116 pub successful_runs: BTreeSet<String>,
118 pub successful_observations: BTreeSet<String>,
119 pub candidate_filesets: BTreeSet<String>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub declaration: Option<Declaration>,
123 #[serde(default)]
124 pub glob_suggestions: Vec<GlobSuggestion>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub configuration_fragment: Option<Value>,
128 #[serde(default)]
129 pub successful_directories: BTreeSet<String>,
130 #[serde(default)]
131 pub suggestion_notes: Vec<String>,
132 #[serde(default)]
133 pub access_review: AccessReview,
134 #[serde(default)]
137 pub unkeyed_packages: BTreeMap<String, usize>,
138}
139
140#[derive(Clone, Debug, Default, Serialize, Deserialize)]
142#[serde(rename_all = "camelCase")]
143pub struct AccessReview {
144 pub content_reads: BTreeSet<String>,
145 pub manifest_reads: BTreeSet<String>,
146 pub toolchain_reads: BTreeSet<String>,
147 #[serde(default)]
148 pub tsconfig_reads: BTreeSet<String>,
149 pub metadata_checks: BTreeSet<String>,
150}
151
152#[derive(Clone, Copy, Debug, PartialEq, Eq)]
155pub enum ReadKind {
156 Manifest,
159 Toolchain,
162 TsConfig,
165 Content,
167}
168
169impl ReadKind {
170 pub fn of(path: &str) -> Self {
171 let name = path.rsplit('/').next().unwrap_or(path);
172 if name == "package.json" {
173 Self::Manifest
174 } else if matches!(
175 path,
176 ".tool-versions"
177 | "mise.toml"
178 | ".nvmrc"
179 | ".node-version"
180 | ".npmrc"
181 | ".pnpmfile.cjs"
182 | "rust-toolchain"
183 | "rust-toolchain.toml"
184 ) {
185 Self::Toolchain
186 } else if name.starts_with("tsconfig") && name.ends_with(".json") {
187 Self::TsConfig
188 } else {
189 Self::Content
190 }
191 }
192}
193
194impl TaskAnalysis {
195 pub fn summarize(&mut self, run: &str) {
196 self.observed_inputs = self
197 .accesses
198 .iter()
199 .filter(|event| !event.operation.writes() && event.result != "missing")
200 .filter(|event| event.category == Category::Input)
201 .map(|event| event.keyed_path.as_ref().unwrap_or(&event.path).clone())
202 .collect();
203 self.uncovered_accesses = self
204 .accesses
205 .iter()
206 .filter(|event| !event.operation.writes() && event.category == Category::Uncovered)
207 .map(|event| event.path.clone())
208 .collect();
209 let mut packages: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
210 for event in &self.accesses {
211 if event.category == Category::UnkeyedPackage
212 && let Some(package) = &event.package
213 {
214 packages.entry(package).or_default().insert(&event.path);
215 }
216 }
217 self.unkeyed_packages = packages
218 .into_iter()
219 .map(|(package, paths)| (package.to_owned(), paths.len()))
220 .collect();
221 if self.outcome == "success" && self.coverage.diagnostics.is_empty() {
222 self.successful_runs.insert(run.to_owned());
223 self.successful_observations
224 .extend(self.observed_inputs.clone());
225 self.successful_directories
226 .extend(self.recorded_directories());
227 }
228 self.refresh_candidates();
229 }
230
231 pub fn merge(&mut self, previous: &Self) -> bool {
234 if self.context != previous.context {
235 return false;
236 }
237 self.successful_runs
238 .extend(previous.successful_runs.clone());
239 self.successful_observations
240 .extend(previous.successful_observations.clone());
241 self.successful_directories
242 .extend(previous.successful_directories.clone());
243 if previous.outcome == "success" && previous.coverage.diagnostics.is_empty() {
245 self.successful_directories
246 .extend(previous.recorded_directories());
247 }
248 self.refresh_candidates();
249 true
250 }
251
252 fn recorded_directories(&self) -> BTreeSet<String> {
253 self.accesses
254 .iter()
255 .filter(|event| {
256 event.category == Category::Discovery
257 && matches!(
258 event.operation,
259 Operation::ListDirectory | Operation::ReadDirectory
260 )
261 && matches!(event.result.as_str(), "success" | "unknown")
262 })
263 .map(|event| event.path.clone())
264 .collect()
265 }
266
267 fn refresh_candidates(&mut self) {
268 self.unobserved_inputs = if self.successful_runs.is_empty() {
269 BTreeSet::new()
270 } else {
271 &(&self.declared_files - &self.mandatory_files) - &self.successful_observations
272 };
273 self.candidate_filesets = self
275 .successful_observations
276 .iter()
277 .filter(|path| self.declared_files.contains(*path))
278 .filter(|path| !self.mandatory_files.contains(*path))
279 .filter(|path| {
280 !path.contains([
281 '*', '?', '[', ']', '{', '}', '!', '(', ')', '\\', '+', '@', '|', ',',
282 ])
283 })
284 .map(|path| format!("{{workspaceRoot}}/{path}"))
285 .collect();
286 self.access_review = AccessReview::default();
287 let reads: BTreeSet<_> = self
288 .accesses
289 .iter()
290 .filter(|event| {
291 event.category == Category::Uncovered
292 && !event.operation.writes()
293 && event.result != "missing"
294 })
295 .filter(|event| event.operation != Operation::Metadata)
296 .map(|event| event.path.clone())
297 .collect();
298 for path in &self.uncovered_accesses {
299 let review = &mut self.access_review;
300 let group = if !reads.contains(path) {
301 &mut review.metadata_checks
302 } else {
303 match ReadKind::of(path) {
304 ReadKind::Manifest => &mut review.manifest_reads,
305 ReadKind::Toolchain => &mut review.toolchain_reads,
306 ReadKind::TsConfig => &mut review.tsconfig_reads,
307 ReadKind::Content => &mut review.content_reads,
308 }
309 };
310 group.insert(path.clone());
311 }
312 self.suggest();
313 }
314}
315
316#[derive(Clone, Debug, Serialize, Deserialize)]
317#[serde(rename_all = "camelCase")]
318pub struct Report {
319 pub schema_version: u32,
320 pub run_id: String,
321 pub tasks: BTreeMap<String, TaskAnalysis>,
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 fn analysis(outcome: &str, path: &str) -> TaskAnalysis {
329 let mut task = TaskAnalysis {
330 context: serde_json::json!({"args": []}),
331 outcome: outcome.into(),
332 coverage: Coverage {
333 backend: "fixture".into(),
334 partial: true,
335 limitations: vec![],
336 diagnostics: vec![],
337 },
338 declared_files: ["a".into(), "b".into()].into(),
339 mandatory_files: BTreeSet::new(),
340 accesses: vec![Access {
341 process: 1,
342 path: path.into(),
343 resolved_path: None,
344 keyed_path: None,
345 package: None,
346 operation: Operation::OpenRead,
347 result: "success".into(),
348 category: Category::Input,
349 }],
350 observed_inputs: BTreeSet::new(),
351 uncovered_accesses: BTreeSet::new(),
352 unobserved_inputs: BTreeSet::new(),
353 successful_runs: BTreeSet::new(),
354 successful_observations: BTreeSet::new(),
355 candidate_filesets: BTreeSet::new(),
356 declaration: None,
357 glob_suggestions: vec![],
358 configuration_fragment: None,
359 successful_directories: BTreeSet::new(),
360 suggestion_notes: vec![],
361 access_review: AccessReview::default(),
362 unkeyed_packages: BTreeMap::new(),
363 };
364 task.summarize(path);
365 task
366 }
367
368 #[test]
369 fn union_only_uses_successful_compatible_recordings_and_deduplicates_runs() {
370 let mut first = analysis("success", "a");
371 let second = analysis("success", "b");
372 assert!(first.merge(&second));
373 assert!(first.merge(&second));
374 assert_eq!(first.successful_runs.len(), 2);
375 assert!(first.unobserved_inputs.is_empty());
376 let mut different = analysis("success", "c");
377 different.context = serde_json::json!({"args": ["--prod"]});
378 assert!(!first.merge(&different));
379 let mut failed = analysis("failed", "b");
380 assert!(failed.unobserved_inputs.is_empty());
381 failed.merge(&analysis("failed", "a"));
382 assert!(failed.successful_runs.is_empty());
383 }
384
385 #[test]
386 fn interrupted_collection_does_not_support_narrowing() {
387 let mut task = analysis("failed", "a");
388 task.outcome = "success".into();
389 task.coverage.diagnostics.push("collector stopped".into());
390 task.summarize("interrupted");
391 assert!(task.candidate_filesets.is_empty());
392 }
393 #[test]
394 fn drafts_expand_local_named_inputs_and_preserve_other_dependencies() {
395 let mut task = analysis("success", "src/a.ts");
396 task.declared_files = [
397 "src/a.ts",
398 "src/b.ts",
399 "src/unused.ts",
400 "src/nested/c.ts",
401 "src/config.json",
402 ]
403 .map(String::from)
404 .into();
405 task.successful_observations
406 .extend(["src/b.ts".into(), "src/config.json".into()]);
407 let retained = vec![
408 serde_json::json!("!{projectRoot}/src/**/*.test.ts"),
409 serde_json::json!({"env": "MODE"}),
410 serde_json::json!({"runtime": "node --version"}),
411 serde_json::json!({"json": "{projectRoot}/settings.json", "fields": ["value"]}),
412 serde_json::json!("^production"),
413 serde_json::json!({"input": "shared", "projects": ["tools"]}),
414 serde_json::json!({"fileset": "{projectRoot}/**/*", "dependencies": true}),
415 serde_json::json!({"dependentTasksOutputFiles": "**/*.d.ts"}),
416 ];
417 let mut local = vec![serde_json::json!({"fileset": "{projectRoot}/**/*"})];
418 local.extend(retained.clone());
419 task.declaration = Some(Declaration {
420 target: "build".into(),
421 inputs: vec![serde_json::json!({"input": "production", "projects": "self"})],
422 named_inputs: [
423 ("production".into(), vec![serde_json::json!("shared")]),
424 ("shared".into(), local),
425 ]
426 .into(),
427 });
428 task.refresh_candidates();
429 assert_eq!(task.glob_suggestions.len(), 1);
430 let glob = &task.glob_suggestions[0];
431 assert_eq!(glob.fileset, "{projectRoot}/src/**/*.{json,ts}");
432 assert_eq!(glob.observed_files, 3);
433 assert_eq!(glob.additional_declared_files, 2);
434 let inputs = task.configuration_fragment.as_ref().unwrap()["targets"]["build"]["inputs"]
435 .as_array()
436 .unwrap();
437 assert!(inputs.contains(&serde_json::json!("{projectRoot}/src/**/*.{json,ts}")));
438 for input in retained {
439 assert!(inputs.contains(&input), "{input}");
440 }
441 assert!(!inputs.contains(&serde_json::json!("{projectRoot}/src/**/*")));
442 }
443
444 #[test]
445 fn fragments_require_safe_observed_paths_and_successful_evidence() {
446 let mut task = analysis("failed", "src/a.ts");
447 task.declaration = Some(Declaration {
448 target: "build".into(),
449 inputs: vec![serde_json::json!("default"), serde_json::json!("^default")],
450 named_inputs: BTreeMap::new(),
451 });
452 task.refresh_candidates();
453 assert!(task.configuration_fragment.is_none());
454 task.successful_runs.insert("prior".into());
455 task.declared_files = ["src/a.ts".into(), "src/a[1].ts".into(), "nx.json".into()].into();
456 task.mandatory_files.insert("nx.json".into());
457 task.successful_observations = task.declared_files.clone();
458 task.refresh_candidates();
459 assert_eq!(
460 task.candidate_filesets,
461 ["{workspaceRoot}/src/a.ts".into()].into()
462 );
463 assert_eq!(
464 task.configuration_fragment.unwrap(),
465 serde_json::json!({"targets": {"build": {"inputs": ["{projectRoot}/src/**/*.ts", "^default"]}}})
466 );
467 }
468
469 #[test]
470 fn old_reports_deserialize_and_merging_rebuilds_drafts_from_current_declarations() {
471 let mut old = serde_json::to_value(analysis("success", "a")).unwrap();
472 for key in ["declaration", "globSuggestions", "configurationFragment"] {
473 old.as_object_mut().unwrap().remove(key);
474 }
475 let old: TaskAnalysis = serde_json::from_value(old).unwrap();
476 let mut current = analysis("success", "b");
477 current.declaration = Some(Declaration {
478 target: "check".into(),
479 inputs: vec![
480 serde_json::json!("default"),
481 serde_json::json!({"env": "CHECK"}),
482 ],
483 named_inputs: BTreeMap::new(),
484 });
485 assert!(current.merge(&old));
486 assert_eq!(
487 current.configuration_fragment.unwrap(),
488 serde_json::json!({"targets": {"check": {"inputs": ["{projectRoot}/a", "{projectRoot}/b", {"env": "CHECK"}]}}})
489 );
490 }
491 #[test]
492 fn specific_named_inputs_are_kept_without_a_fragment_even_with_sparse_observations() {
493 let mut task = analysis("success", "src/a.ts");
494 task.declaration = Some(Declaration {
495 target: "lint".into(),
496 inputs: vec![
497 serde_json::json!("source"),
498 serde_json::json!({"env": "CI"}),
499 ],
500 named_inputs: [(
501 "source".into(),
502 vec![
503 serde_json::json!("{projectRoot}/**/*.{ts,tsx}"),
504 serde_json::json!("!{projectRoot}/generated/**/*"),
505 ],
506 )]
507 .into(),
508 });
509 task.refresh_candidates();
510 assert!(task.configuration_fragment.is_none());
511 assert!(task.glob_suggestions.is_empty());
512 assert!(task.suggestion_notes[0].contains("retained"));
513 }
514
515 #[test]
516 fn directory_unions_keep_future_asset_types_and_nested_files() {
517 let mut task = analysis("success", "apps/web/public/logo.svg");
518 task.context = serde_json::json!({"projectRoot": "apps/web"});
519 task.declared_files = [
520 "apps/web/public/logo.svg".into(),
521 "apps/web/public/fonts/a.woff2".into(),
522 "apps/web/src/a.ts".into(),
523 "apps/web/src/b.tsx".into(),
524 ]
525 .into();
526 task.successful_observations = [
527 "apps/web/public/logo.svg".into(),
528 "apps/web/src/a.ts".into(),
529 ]
530 .into();
531 task.declaration = Some(Declaration {
532 target: "build".into(),
533 inputs: vec![serde_json::json!("default"), serde_json::json!("tsconfigs")],
534 named_inputs: [(
535 "tsconfigs".into(),
536 vec![serde_json::json!("{projectRoot}/tsconfig*.json")],
537 )]
538 .into(),
539 });
540 let mut previous = task.clone();
541 previous
542 .successful_directories
543 .insert("apps/web/public".into());
544 task.merge(&previous);
545 let fragment = task.configuration_fragment.as_ref().unwrap();
546 assert_eq!(
547 fragment["targets"]["build"]["inputs"],
548 serde_json::json!([
549 "{projectRoot}/public/**/*",
550 "{projectRoot}/src/**/*.{ts,tsx}",
551 "tsconfigs"
552 ])
553 );
554 let matcher = globset::Glob::new("apps/web/public/**/*")
555 .unwrap()
556 .compile_matcher();
557 assert!(matcher.is_match("apps/web/public/new.json"));
558 assert!(matcher.is_match("apps/web/public/nested/image.png"));
559 assert_eq!(task.glob_suggestions[0].additional_declared_files, 1);
560 assert_eq!(task.glob_suggestions[1].additional_declared_files, 1);
561 }
562
563 #[test]
564 fn rewriting_preserves_exclusion_order_and_opaque_cross_project_inputs() {
565 let declaration = Declaration {
566 target: "build".into(),
567 inputs: vec![
568 serde_json::json!("!{projectRoot}/src/private/**/*"),
569 serde_json::json!("default"),
570 serde_json::json!("!{projectRoot}/src/tests/**/*"),
571 serde_json::json!({"input": "default", "projects": "dependencies"}),
572 ],
573 named_inputs: BTreeMap::new(),
574 };
575 let mut task = analysis("success", "src/a.ts");
576 task.declared_files.insert("src/a.ts".into());
577 task.declaration = Some(declaration);
578 task.refresh_candidates();
579 assert_eq!(
580 task.configuration_fragment.unwrap()["targets"]["build"]["inputs"],
581 serde_json::json!(["!{projectRoot}/src/private/**/*", "{projectRoot}/src/**/*.ts", "!{projectRoot}/src/tests/**/*", {"input": "default", "projects": "dependencies"}])
582 );
583 }
584
585 #[test]
586 fn uncovered_review_groups_reads_without_suppressing_raw_paths() {
587 let mut task = analysis("failed", "unused");
588 for (path, operation) in [
589 ("packages/a/package.json", Operation::ReadData),
590 ("mise.toml", Operation::OpenRead),
591 ("packages/a/tsconfig.lib.json", Operation::ReadData),
592 ("src/hidden.ts", Operation::Metadata),
593 ("src/hidden.ts", Operation::ReadData),
594 ("patches/a.patch", Operation::Metadata),
595 ] {
596 task.accesses.push(Access {
597 process: 1,
598 path: path.into(),
599 resolved_path: None,
600 keyed_path: None,
601 package: None,
602 operation,
603 result: "unknown".into(),
604 category: Category::Uncovered,
605 });
606 }
607 task.summarize("failed");
608 assert_eq!(task.uncovered_accesses.len(), 5);
609 assert_eq!(
610 task.access_review.content_reads,
611 ["src/hidden.ts".into()].into()
612 );
613 assert_eq!(
614 task.access_review.manifest_reads,
615 ["packages/a/package.json".into()].into()
616 );
617 assert_eq!(
618 task.access_review.toolchain_reads,
619 ["mise.toml".into()].into()
620 );
621 assert_eq!(
622 task.access_review.tsconfig_reads,
623 ["packages/a/tsconfig.lib.json".into()].into()
624 );
625 assert_eq!(
626 task.access_review.metadata_checks,
627 ["patches/a.patch".into()].into()
628 );
629 assert!(task.successful_directories.is_empty());
630 }
631 #[test]
632 fn legacy_directory_events_merge_only_from_successful_collection() {
633 let mut current = analysis("success", "public/logo.svg");
634 let mut old = current.clone();
635 old.accesses.push(Access {
636 process: 1,
637 path: "public".into(),
638 resolved_path: None,
639 keyed_path: None,
640 package: None,
641 operation: Operation::ReadDirectory,
642 result: "unknown".into(),
643 category: Category::Discovery,
644 });
645 assert!(current.merge(&old));
646 assert!(current.successful_directories.contains("public"));
647 let mut failed = old.clone();
648 failed.outcome = "cancelled".into();
649 failed.successful_directories.clear();
650 let mut clean = analysis("success", "public/logo.svg");
651 clean.merge(&failed);
652 assert!(clean.successful_directories.is_empty());
653 old.coverage.diagnostics.push("collector stopped".into());
654 clean.merge(&old);
655 assert!(clean.successful_directories.is_empty());
656 }
657}