1use std::collections::{HashMap, HashSet, VecDeque};
17use std::fmt;
18use std::path::{Component, Path, PathBuf};
19
20use wyvern_schema::{FieldName, WizardPageFieldError, WizardPageHtml, WizardPageId};
21use wyvern_wizard::{
22 add_edge, extract_local_script_srcs, extract_next_hops, extract_next_wizard_refs,
23 lint_dataflow, lint_page, merge_html_dataflow_overlay, parse_dataflow_from_json,
24 DataflowLintInput, DataflowSpec, GraphPage, LintFinding, PageInfo, PageRole, WizardPageGraph,
25};
26
27use crate::error::{BuiltinDomain, EmitError, UsageErrorKind};
28use crate::extensions::resolve_wyvern_share;
29use crate::workflow::Allowlist;
30
31#[derive(Debug)]
35pub enum WizardCmdResult {
36 Clean(String),
38 Findings(String),
40}
41
42#[derive(Debug)]
44pub enum WizardCmdError {
45 Usage {
47 kind: UsageErrorKind,
49 message: String,
51 },
52 Stage(WizardLintStageError),
54 Emit(EmitError),
56}
57
58#[derive(Debug)]
64pub enum WizardLintStageError {
65 Io {
67 path: PathBuf,
69 message: String,
71 },
72 Parse {
74 path: PathBuf,
76 message: String,
78 },
79 Validation {
81 path: PathBuf,
83 field: FieldName,
85 message: String,
87 },
88}
89
90impl fmt::Display for WizardLintStageError {
91 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92 f.write_str(self.message())
93 }
94}
95
96impl std::error::Error for WizardLintStageError {}
97
98impl WizardLintStageError {
99 #[must_use]
101 pub fn message(&self) -> &str {
102 match self {
103 Self::Io { message, .. }
104 | Self::Parse { message, .. }
105 | Self::Validation { message, .. } => message,
106 }
107 }
108
109 #[must_use]
111 pub const fn exit_code(&self) -> i32 {
112 1
113 }
114
115 #[must_use]
117 pub const fn subcode(&self) -> &'static str {
118 match self {
119 Self::Io { .. } => "wizard_lint_io",
120 Self::Parse { .. } => "wizard_lint_parse",
121 Self::Validation { .. } => "wizard_lint_validation",
122 }
123 }
124}
125
126fn combine_stage_errors(mut errors: Vec<WizardLintStageError>) -> WizardLintStageError {
127 if errors.len() == 1 {
128 return errors.remove(0);
129 }
130 let combined = errors
131 .iter()
132 .map(ToString::to_string)
133 .collect::<Vec<_>>()
134 .join("\n");
135 match errors.remove(0) {
136 WizardLintStageError::Io { path, .. } => WizardLintStageError::Io {
137 path,
138 message: combined,
139 },
140 WizardLintStageError::Parse { path, .. } => WizardLintStageError::Parse {
141 path,
142 message: combined,
143 },
144 WizardLintStageError::Validation { path, field, .. } => WizardLintStageError::Validation {
145 path,
146 field,
147 message: combined,
148 },
149 }
150}
151
152#[must_use]
154pub fn wizard_usage_message() -> String {
155 concat!(
156 "Usage: wyvern wizard lint <path> [<path>...]\n",
157 " wyvern wizard lint --help\n",
158 "\n",
159 "Commands:\n",
160 " lint <path...> Lint one or more wizard packages for missing nav buttons\n",
161 "\n",
162 "Arguments:\n",
163 " <path> Directory containing wizard.json, or path to wizard.json itself\n",
164 "\n",
165 "Exit codes:\n",
166 " 0 All pages are clean\n",
167 " 1 One or more findings\n",
168 " 2 Usage / bad arguments\n",
169 "\n",
170 "Findings codes:\n",
171 " WIZARD-LINT-001 Non-entry page missing back button\n",
172 " WIZARD-LINT-002 Terminal page missing cancel button\n",
173 " WIZARD-LINT-003 wizard-nav.js chrome opt-in but no nav region\n",
174 " WIZARD-LINT-004 Non-terminal page (with chrome opt-in) missing next button\n",
175 " WIZARD-LINT-005 config.dataflow requires unsatisfied or type conflict\n",
176 " WIZARD-LINT-006 Terminal post_input not covered by exports\n",
177 " WIZARD-LINT-007 next_wizard input keys undeclared on target\n",
178 " WIZARD-LINT-008 Local JS reads a key no page exports\n",
179 "\n",
180 "See also: wyvern --help\n",
181 )
182 .to_string()
183}
184
185pub fn run_wizard_command(args: &[String]) -> Result<WizardCmdResult, WizardCmdError> {
191 if args
192 .first()
193 .is_some_and(|t| t == "--help" || t == "-h" || t == "help")
194 {
195 return Ok(WizardCmdResult::Clean(wizard_usage_message()));
196 }
197
198 match args.first().map(String::as_str) {
199 Some("lint") => run_lint(&args[1..]),
200 Some("--help") | Some("-h") | Some("help") | None => {
201 Ok(WizardCmdResult::Clean(wizard_usage_message()))
203 }
204 Some(other) => Err(WizardCmdError::Usage {
205 kind: UsageErrorKind::UnknownSubcommand {
206 domain: BuiltinDomain::Wizard,
207 token: other.to_string(),
208 },
209 message: format!(
210 "unknown wizard subcommand '{other}'\n{}",
211 wizard_usage_message()
212 ),
213 }),
214 }
215}
216
217fn run_lint(args: &[String]) -> Result<WizardCmdResult, WizardCmdError> {
220 if args
221 .iter()
222 .any(|a| a == "--help" || a == "-h" || a == "help")
223 {
224 return Ok(WizardCmdResult::Clean(wizard_usage_message()));
225 }
226
227 if args.is_empty() {
228 return Err(WizardCmdError::Usage {
229 kind: UsageErrorKind::Generic,
230 message: format!(
231 "wyvern wizard lint requires at least one <path> argument\n{}",
232 wizard_usage_message()
233 ),
234 });
235 }
236
237 let mut all_findings: Vec<LintFinding> = Vec::new();
238 let mut errors: Vec<WizardLintStageError> = Vec::new();
239 let mut total_pages: usize = 0;
240
241 for path_str in args {
242 match lint_package(path_str) {
243 Ok((findings, page_count)) => {
244 total_pages += page_count;
245 all_findings.extend(findings);
246 }
247 Err(err) => {
248 errors.push(err);
249 }
250 }
251 }
252
253 if !errors.is_empty() {
255 return Err(WizardCmdError::Stage(combine_stage_errors(errors)));
256 }
257
258 if all_findings.is_empty() {
259 let noun = if total_pages == 1 { "page" } else { "pages" };
260 let checked = if args.len() == 1 {
261 format!("Checked {total_pages} {noun} — no findings.\n")
262 } else {
263 format!(
264 "Checked {} package(s), {total_pages} {noun} total — no findings.\n",
265 args.len()
266 )
267 };
268 return Ok(WizardCmdResult::Clean(checked));
269 }
270
271 let report = format_findings(&all_findings, args.len(), total_pages);
272 Ok(WizardCmdResult::Findings(report))
273}
274
275fn lint_package(path_str: &str) -> Result<(Vec<LintFinding>, usize), WizardLintStageError> {
281 let input = Path::new(path_str);
282
283 let (wizard_json_path, wizard_dir) = resolve_wizard_paths(input)?;
285
286 let json_content =
288 std::fs::read_to_string(&wizard_json_path).map_err(|e| WizardLintStageError::Io {
289 path: wizard_json_path.clone(),
290 message: format!("error: cannot read '{}': {e}", wizard_json_path.display()),
291 })?;
292
293 let (entry_id, entry_html) = parse_wizard_json_entry(&json_content, &wizard_json_path)?;
294
295 let (pages, graph) = build_page_graph(&wizard_dir, &entry_id, &entry_html)?;
297
298 let mut findings: Vec<LintFinding> = pages
300 .iter()
301 .flat_map(|p| {
302 lint_page(&PageInfo {
303 id: p.id.as_str(),
304 file: p.rel_path.as_str(),
305 html: &p.html,
306 role: p.role,
307 })
308 })
309 .collect();
310
311 if let Some(mut spec) = parse_dataflow_from_json(&json_content) {
313 for page in &pages {
314 merge_html_dataflow_overlay(&mut spec, page.id.as_str(), &page.html);
315 }
316 let js_files = collect_local_js_files(&wizard_dir, &pages);
317 let has_workflow_post = wizard_json_has_workflow_post(&json_content);
318 let next_wizard_targets = build_next_wizard_targets(&wizard_dir, &js_files);
319 findings.extend(lint_dataflow(&DataflowLintInput {
320 spec: &spec,
321 graph: &graph,
322 js_files: &js_files,
323 has_workflow_post,
324 next_wizard_targets: &next_wizard_targets,
325 }));
326 }
327
328 let page_count = pages.len();
329 Ok((findings, page_count))
330}
331
332fn resolve_wizard_paths(input: &Path) -> Result<(PathBuf, PathBuf), WizardLintStageError> {
334 if input.is_dir() {
335 let json = input.join("wizard.json");
336 if json.is_file() {
337 return Ok((json, input.to_path_buf()));
338 }
339 return Err(WizardLintStageError::Io {
340 path: json,
341 message: format!(
342 "error: '{}' is a directory but contains no wizard.json",
343 input.display()
344 ),
345 });
346 }
347 if input.is_file() {
348 let dir = input.parent().unwrap_or(Path::new("."));
349 return Ok((input.to_path_buf(), dir.to_path_buf()));
350 }
351 Err(WizardLintStageError::Io {
352 path: input.to_path_buf(),
353 message: format!(
354 "error: '{}' not found (expected directory or wizard.json path)",
355 input.display()
356 ),
357 })
358}
359
360fn parse_wizard_json_entry(
365 json: &str,
366 path: &Path,
367) -> Result<(WizardPageId, WizardPageHtml), WizardLintStageError> {
368 let value: serde_json::Value =
369 serde_json::from_str(json).map_err(|e| WizardLintStageError::Parse {
370 path: path.to_path_buf(),
371 message: format!("error: '{}': invalid JSON: {e}", path.display()),
372 })?;
373
374 let page = value
375 .get("page")
376 .ok_or_else(|| WizardLintStageError::Validation {
377 path: path.to_path_buf(),
378 field: FieldName::new("page"),
379 message: format!(
380 "error: '{}': missing 'page' field in wizard.json",
381 path.display()
382 ),
383 })?;
384
385 let id = parse_page_newtype_field(page, path, "id", WizardPageId::try_new)?;
386 let html = parse_page_newtype_field(page, path, "html", WizardPageHtml::try_new)?;
387
388 Ok((id, html))
389}
390
391fn parse_page_newtype_field<T>(
392 page: &serde_json::Value,
393 path: &Path,
394 field: &str,
395 ctor: impl FnOnce(String) -> Result<T, WizardPageFieldError>,
396) -> Result<T, WizardLintStageError> {
397 let field_name = format!("page.{field}");
398 let raw = page.get(field).and_then(|v| v.as_str()).ok_or_else(|| {
399 WizardLintStageError::Validation {
400 path: path.to_path_buf(),
401 field: FieldName::new(field_name.clone()),
402 message: format!(
403 "error: '{}': wizard.json {field_name} must be a string",
404 path.display()
405 ),
406 }
407 })?;
408 ctor(raw.to_string()).map_err(|err| WizardLintStageError::Validation {
409 path: path.to_path_buf(),
410 field: FieldName::new(field_name.clone()),
411 message: format!(
412 "error: '{}': wizard.json {field_name}: {err}",
413 path.display()
414 ),
415 })
416}
417
418struct PageNode {
421 id: WizardPageId,
422 rel_path: WizardPageHtml,
424 html: String,
425 role: PageRole,
426}
427
428fn build_page_graph(
435 wizard_dir: &Path,
436 entry_id: &WizardPageId,
437 entry_html: &WizardPageHtml,
438) -> Result<(Vec<PageNode>, WizardPageGraph), WizardLintStageError> {
439 let mut visited: HashSet<WizardPageHtml> = HashSet::new();
440 let mut queue: VecDeque<(WizardPageId, WizardPageHtml, PageRole, Option<WizardPageId>)> =
441 VecDeque::new();
442 let mut pages: Vec<PageNode> = Vec::new();
443 let mut graph = WizardPageGraph {
444 entry_id: entry_id.as_str().to_string(),
445 pages: HashMap::new(),
446 edges: HashMap::new(),
447 };
448
449 queue.push_back((entry_id.clone(), entry_html.clone(), PageRole::Entry, None));
450
451 while let Some((id, html_rel, role, from_id)) = queue.pop_front() {
452 if visited.contains(&html_rel) {
453 continue;
454 }
455 visited.insert(html_rel.clone());
456
457 if let Some(from) = from_id {
458 add_edge(&mut graph, from.as_str(), id.as_str());
459 }
460
461 let html_abs = wizard_dir.join(html_rel.as_str());
462 let html_content =
463 std::fs::read_to_string(&html_abs).map_err(|e| WizardLintStageError::Io {
464 path: html_abs.clone(),
465 message: format!("error: cannot read page '{}': {e}", html_abs.display()),
466 })?;
467
468 let html_dir = html_abs.parent().unwrap_or(wizard_dir);
470 let srcs = extract_local_script_srcs(&html_content);
471 for src in srcs {
472 let script_abs = normalize_path(&html_dir.join(&src));
473 let Ok(js) = std::fs::read_to_string(&script_abs) else {
474 continue;
475 };
476 for hop in extract_next_hops(&js) {
477 let Ok(hop_html) = WizardPageHtml::try_new(hop.html) else {
478 continue;
479 };
480 if !visited.contains(&hop_html) {
481 let Ok(hop_id) = WizardPageId::try_new(hop.id) else {
482 continue;
483 };
484 queue.push_back((hop_id, hop_html, PageRole::Inner, Some(id.clone())));
485 }
486 }
487 }
488
489 graph.pages.insert(
490 id.as_str().to_string(),
491 GraphPage {
492 id: id.as_str().to_string(),
493 file: html_rel.as_str().to_string(),
494 html: html_content.clone(),
495 },
496 );
497
498 pages.push(PageNode {
499 id,
500 rel_path: html_rel,
501 html: html_content,
502 role,
503 });
504 }
505
506 Ok((pages, graph))
507}
508
509fn build_next_wizard_targets(
510 wizard_dir: &Path,
511 js_files: &HashMap<String, String>,
512) -> HashMap<String, DataflowSpec> {
513 let allowlist = Allowlist {
514 share_root: resolve_wyvern_share(),
515 cwd: std::env::current_dir().unwrap_or_else(|_| wizard_dir.to_path_buf()),
516 wizard_dir: wizard_dir.to_path_buf(),
517 };
518
519 let mut paths = HashSet::new();
520 for (file, js) in js_files {
521 for nw in extract_next_wizard_refs(js, file) {
522 paths.insert(nw.path);
523 }
524 }
525
526 let mut targets = HashMap::new();
527 for path in paths {
528 let Ok(wizard_json) = allowlist.resolve_allowed(&path) else {
529 continue;
530 };
531 let Ok(json) = std::fs::read_to_string(&wizard_json) else {
532 continue;
533 };
534 if let Some(spec) = parse_dataflow_from_json(&json) {
535 targets.insert(path, spec);
536 }
537 }
538 targets
539}
540
541fn collect_local_js_files(wizard_dir: &Path, pages: &[PageNode]) -> HashMap<String, String> {
542 let mut files: HashMap<String, String> = HashMap::new();
543 for page in pages {
544 let html_abs = wizard_dir.join(page.rel_path.as_str());
545 let html_dir = html_abs.parent().unwrap_or(wizard_dir);
546 let srcs = extract_local_script_srcs(&page.html);
547 for src in srcs {
548 let script_abs = normalize_path(&html_dir.join(&src));
549 let Ok(rel) = script_abs.strip_prefix(wizard_dir) else {
550 continue;
551 };
552 let rel_str = rel.to_string_lossy().replace('\\', "/");
553 if files.contains_key(&rel_str) {
554 continue;
555 }
556 if let Ok(js) = std::fs::read_to_string(&script_abs) {
557 files.insert(rel_str, js);
558 }
559 }
560 }
561 files
562}
563
564fn wizard_json_has_workflow_post(json: &str) -> bool {
565 let Ok(value) = serde_json::from_str::<serde_json::Value>(json) else {
566 return false;
567 };
568 value
569 .get("workflow")
570 .and_then(|w| w.get("post"))
571 .and_then(|v| v.as_str())
572 .is_some_and(|s| !s.is_empty())
573}
574
575fn normalize_path(path: &Path) -> PathBuf {
577 let mut out: Vec<Component<'_>> = Vec::new();
578 for component in path.components() {
579 match component {
580 Component::ParentDir => {
581 match out.last() {
583 Some(Component::Normal(_)) => {
584 out.pop();
585 }
586 _ => out.push(component),
587 }
588 }
589 c => out.push(c),
590 }
591 }
592 out.iter().collect()
593}
594
595fn format_findings(findings: &[LintFinding], pkg_count: usize, page_count: usize) -> String {
598 let mut out = String::new();
599 for f in findings {
600 out.push_str(&f.display_line());
601 out.push('\n');
602 }
603 let pkg_noun = if pkg_count == 1 {
604 "package"
605 } else {
606 "packages"
607 };
608 let page_noun = if page_count == 1 { "page" } else { "pages" };
609 let finding_noun = if findings.len() == 1 {
610 "finding"
611 } else {
612 "findings"
613 };
614 out.push_str(&format!(
615 "\n{} {} in {} {} ({} {} checked)\n",
616 findings.len(),
617 finding_noun,
618 pkg_count,
619 pkg_noun,
620 page_count,
621 page_noun,
622 ));
623 out
624}
625
626#[cfg(test)]
629mod tests {
630 use super::*;
631
632 #[test]
633 fn wizard_help_flag_returns_usage() {
634 let result = run_wizard_command(&["--help".into()]).expect("ok");
635 match result {
636 WizardCmdResult::Clean(text) => {
637 assert!(text.contains("wyvern wizard lint"), "{text}");
638 assert!(text.contains("WIZARD-LINT-001"), "{text}");
639 }
640 WizardCmdResult::Findings(_) => panic!("expected clean / usage"),
641 }
642 }
643
644 #[test]
645 fn wizard_lint_help_returns_usage() {
646 let result = run_wizard_command(&["lint".into(), "--help".into()]).expect("ok");
647 match result {
648 WizardCmdResult::Clean(text) => {
649 assert!(text.contains("wyvern wizard lint"), "{text}");
650 }
651 WizardCmdResult::Findings(_) => panic!("expected clean"),
652 }
653 }
654
655 #[test]
656 fn unknown_wizard_subcommand_is_usage_error() {
657 let err = run_wizard_command(&["dump".into()]).expect_err("should err");
658 match err {
659 WizardCmdError::Usage { kind, message } => {
660 assert!(
661 matches!(
662 kind,
663 UsageErrorKind::UnknownSubcommand { domain, ref token }
664 if domain == BuiltinDomain::Wizard && token == "dump"
665 ),
666 "{kind:?}"
667 );
668 assert!(message.contains("unknown wizard subcommand"), "{message}");
669 }
670 other => panic!("expected Usage, got {other:?}"),
671 }
672 }
673
674 #[test]
675 fn wizard_lint_no_args_is_generic_usage_error() {
676 let err = run_wizard_command(&["lint".into()]).expect_err("should err");
677 match err {
678 WizardCmdError::Usage { kind, message } => {
679 assert_eq!(kind, UsageErrorKind::Generic);
680 assert!(message.contains("requires at least one"), "{message}");
681 }
682 other => panic!("expected Usage, got {other:?}"),
683 }
684 }
685
686 #[test]
687 fn wizard_usage_message_mentions_lint_codes() {
688 let text = wizard_usage_message();
689 assert!(text.contains("WIZARD-LINT-001"), "{text}");
690 assert!(text.contains("WIZARD-LINT-002"), "{text}");
691 assert!(text.contains("WIZARD-LINT-003"), "{text}");
692 assert!(text.contains("WIZARD-LINT-004"), "{text}");
693 assert!(text.contains("WIZARD-LINT-005"), "{text}");
694 assert!(text.contains("WIZARD-LINT-008"), "{text}");
695 }
696
697 #[test]
698 fn parse_wizard_json_entry_keeps_page_newtypes() {
699 let json = r#"{"page":{"id":"start","html":"pages/start.html"}}"#;
700 let (id, html) = parse_wizard_json_entry(json, Path::new("wizard.json")).expect("ok");
701 assert_eq!(id.as_str(), "start");
702 assert_eq!(html.as_str(), "pages/start.html");
703 }
704
705 #[test]
706 fn parse_wizard_json_entry_preserves_field_error_detail() {
707 let json = r#"{"page":{"id":"","html":"pages/start.html"}}"#;
708 let err = parse_wizard_json_entry(json, Path::new("wizard.json")).expect_err("empty id");
709 match err {
710 WizardLintStageError::Validation { field, message, .. } => {
711 assert_eq!(field.as_str(), "page.id");
712 assert!(
713 message.contains("wizard page field must be a non-empty string"),
714 "{message}"
715 );
716 }
717 other => panic!("expected Validation, got {other:?}"),
718 }
719 }
720
721 #[test]
722 fn parse_wizard_json_entry_invalid_json_is_parse() {
723 let err = parse_wizard_json_entry("{", Path::new("wizard.json")).expect_err("parse");
724 assert!(matches!(err, WizardLintStageError::Parse { .. }), "{err:?}");
725 }
726
727 #[test]
728 fn lint_missing_path_is_io_stage_error() {
729 let err =
730 run_wizard_command(&["lint".into(), "/no/such/wizard-pkg".into()]).expect_err("io");
731 match err {
732 WizardCmdError::Stage(stage) => {
733 assert!(
734 matches!(stage, WizardLintStageError::Io { .. }),
735 "{stage:?}"
736 );
737 assert!(stage.message().contains("not found"), "{}", stage.message());
738 assert_eq!(stage.exit_code(), 1);
739 assert_eq!(stage.subcode(), "wizard_lint_io");
740 }
741 other => panic!("expected Stage, got {other:?}"),
742 }
743 }
744}