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