Skip to main content

ssg/audit/gates/
ai_discovery.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! AI-discovery protocol validation (depends on E8 — issue #552).
5//!
6//! Asserts presence + schema-validity of:
7//! - `llms.txt` (already shipped in v0.0.43).
8//! - `agents.txt` (E8).
9//! - `.well-known/ai-plugin.json` (E8).
10//!
11//! When the E8-produced files are absent, the gate emits info-level
12//! findings rather than errors — those upgrade to enforcement once
13//! issue #552 (E8) merges.
14
15use super::super::{AuditGate, AuditOptions, Finding, Severity, Site};
16
17const NAME: &str = "ai_discovery";
18
19/// AI-discovery protocol gate.
20///
21/// # Examples
22///
23/// ```
24/// use ssg::audit::AuditGate;
25/// use ssg::audit::gates::ai_discovery::AiDiscoveryGate;
26/// assert_eq!(AiDiscoveryGate.name(), "ai_discovery");
27/// assert!(!AiDiscoveryGate.explain().is_empty());
28/// ```
29#[derive(Debug, Clone, Copy)]
30pub struct AiDiscoveryGate;
31
32impl AuditGate for AiDiscoveryGate {
33    fn name(&self) -> &'static str {
34        NAME
35    }
36
37    fn explain(&self) -> &'static str {
38        "Asserts that AI-discovery protocol files exist + parse: \
39         llms.txt (shipped in v0.0.43), agents.txt and \
40         .well-known/ai-plugin.json (both from E8 — issue #552). \
41         Files produced by E8 emit info notes when absent; the \
42         already-shipped llms.txt emits a warning."
43    }
44
45    fn run(&self, site: &Site, _opts: &AuditOptions) -> Vec<Finding> {
46        let mut findings = Vec::new();
47
48        // llms.txt — already shipped; missing = warn
49        let llms = site.root.join("llms.txt");
50        if !llms.exists() {
51            findings.push(
52                Finding::new(
53                    NAME,
54                    Severity::Warn,
55                    "llms.txt is missing (LlmManifestPlugin should emit it)",
56                )
57                .with_code("AI-LLMS-MISSING")
58                .with_path("llms.txt".to_string()),
59            );
60        } else if let Ok(text) = std::fs::read_to_string(&llms) {
61            if text.trim().is_empty() {
62                findings.push(
63                    Finding::new(NAME, Severity::Warn, "llms.txt is empty")
64                        .with_code("AI-LLMS-EMPTY")
65                        .with_path("llms.txt".to_string()),
66                );
67            }
68        }
69
70        // agents.txt — depends on E8
71        let agents = site.root.join("agents.txt");
72        if !agents.exists() {
73            findings.push(
74                Finding::new(
75                    NAME,
76                    Severity::Info,
77                    "agents.txt absent (depends on E8 — issue #552)",
78                )
79                .with_code("AI-AGENTS-MISSING")
80                .with_path("agents.txt".to_string()),
81            );
82        }
83
84        // .well-known/ai-plugin.json — depends on E8
85        let plugin_json = site.root.join(".well-known/ai-plugin.json");
86        if !plugin_json.exists() {
87            findings.push(
88                Finding::new(
89                    NAME,
90                    Severity::Info,
91                    ".well-known/ai-plugin.json absent (depends on E8 — issue #552)",
92                )
93                .with_code("AI-PLUGIN-JSON-MISSING")
94                .with_path(".well-known/ai-plugin.json".to_string()),
95            );
96        } else if let Ok(text) = std::fs::read_to_string(&plugin_json) {
97            if serde_json::from_str::<serde_json::Value>(&text).is_err() {
98                findings.push(
99                    Finding::new(
100                        NAME,
101                        Severity::Error,
102                        ".well-known/ai-plugin.json is not valid JSON",
103                    )
104                    .with_code("AI-PLUGIN-JSON-INVALID")
105                    .with_path(".well-known/ai-plugin.json".to_string()),
106                );
107            }
108        }
109
110        findings
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    fn site_with_files(files: &[(&str, &str)]) -> Site {
119        let tmp = tempfile::tempdir().unwrap();
120        let root = tmp.path().to_path_buf();
121        for (name, body) in files {
122            let p = root.join(name);
123            // root.join(name) always has a parent directory.
124            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
125            std::fs::write(&p, body).unwrap();
126        }
127        std::mem::forget(tmp);
128        Site {
129            root,
130            html_files: Vec::new(),
131        }
132    }
133
134    #[test]
135    fn all_present_and_valid_passes() {
136        let s = site_with_files(&[
137            ("llms.txt", "# llms\n"),
138            ("agents.txt", "# agents\n"),
139            (".well-known/ai-plugin.json", r#"{"schema_version":"v1"}"#),
140        ]);
141        let f = AiDiscoveryGate.run(&s, &AuditOptions::default());
142        assert!(f.is_empty(), "got {f:?}");
143    }
144
145    #[test]
146    fn missing_e8_files_emit_info_not_error() {
147        let s = site_with_files(&[("llms.txt", "# llms\n")]);
148        let f = AiDiscoveryGate.run(&s, &AuditOptions::default());
149        assert!(f.iter().all(|x| matches!(x.severity, Severity::Info)));
150        let codes: Vec<_> =
151            f.iter().filter_map(|x| x.code.as_deref()).collect();
152        assert!(codes.contains(&"AI-AGENTS-MISSING"));
153        assert!(codes.contains(&"AI-PLUGIN-JSON-MISSING"));
154    }
155
156    #[test]
157    fn invalid_ai_plugin_json_flagged_error() {
158        let s = site_with_files(&[
159            ("llms.txt", "# llms\n"),
160            (".well-known/ai-plugin.json", "{ this is not json }"),
161        ]);
162        let f = AiDiscoveryGate.run(&s, &AuditOptions::default());
163        assert!(f
164            .iter()
165            .any(|x| x.code.as_deref() == Some("AI-PLUGIN-JSON-INVALID")
166                && x.severity == Severity::Error));
167    }
168
169    #[test]
170    fn missing_llms_txt_warns() {
171        let s = site_with_files(&[
172            ("agents.txt", "# agents\n"),
173            (".well-known/ai-plugin.json", r#"{"schema_version":"v1"}"#),
174        ]);
175        let f = AiDiscoveryGate.run(&s, &AuditOptions::default());
176        let llms = f
177            .iter()
178            .find(|x| x.code.as_deref() == Some("AI-LLMS-MISSING"))
179            .expect("missing llms.txt finding");
180        assert_eq!(llms.severity, Severity::Warn);
181    }
182
183    #[test]
184    fn empty_llms_txt_warns() {
185        let s = site_with_files(&[
186            ("llms.txt", "   \n   "),
187            ("agents.txt", "# agents\n"),
188            (".well-known/ai-plugin.json", r#"{"schema_version":"v1"}"#),
189        ]);
190        let f = AiDiscoveryGate.run(&s, &AuditOptions::default());
191        let empty = f
192            .iter()
193            .find(|x| x.code.as_deref() == Some("AI-LLMS-EMPTY"))
194            .expect("empty llms.txt finding");
195        assert_eq!(empty.severity, Severity::Warn);
196    }
197
198    #[test]
199    fn all_three_missing_emits_warn_plus_two_infos() {
200        let s = site_with_files(&[("placeholder.txt", "noop")]);
201        let f = AiDiscoveryGate.run(&s, &AuditOptions::default());
202        let codes: Vec<_> =
203            f.iter().filter_map(|x| x.code.as_deref()).collect();
204        assert!(codes.contains(&"AI-LLMS-MISSING"));
205        assert!(codes.contains(&"AI-AGENTS-MISSING"));
206        assert!(codes.contains(&"AI-PLUGIN-JSON-MISSING"));
207        let warn_count = f
208            .iter()
209            .filter(|x| matches!(x.severity, Severity::Warn))
210            .count();
211        let info_count = f
212            .iter()
213            .filter(|x| matches!(x.severity, Severity::Info))
214            .count();
215        assert_eq!(warn_count, 1);
216        assert_eq!(info_count, 2);
217    }
218
219    #[test]
220    fn valid_ai_plugin_json_passes_silent() {
221        let s = site_with_files(&[
222            ("llms.txt", "# llms\n"),
223            ("agents.txt", "# agents\n"),
224            (".well-known/ai-plugin.json", r#"{"foo": [1, 2, 3]}"#),
225        ]);
226        let f = AiDiscoveryGate.run(&s, &AuditOptions::default());
227        assert!(f.is_empty(), "got {f:?}");
228    }
229
230    #[test]
231    fn unreadable_llms_txt_is_tolerated() {
232        // llms.txt exists but is a directory: read_to_string fails and
233        // the gate stays silent (presence is what the check asserts).
234        let s = site_with_files(&[
235            ("agents.txt", "# agents\n"),
236            (".well-known/ai-plugin.json", r#"{"schema_version":"v1"}"#),
237        ]);
238        std::fs::create_dir_all(s.root.join("llms.txt")).unwrap();
239        let f = AiDiscoveryGate.run(&s, &AuditOptions::default());
240        assert!(f.is_empty(), "unreadable llms.txt must not panic: {f:?}");
241    }
242
243    #[test]
244    fn unreadable_ai_plugin_json_is_tolerated() {
245        let s = site_with_files(&[
246            ("llms.txt", "# llms\n"),
247            ("agents.txt", "# agents\n"),
248        ]);
249        std::fs::create_dir_all(s.root.join(".well-known/ai-plugin.json"))
250            .unwrap();
251        let f = AiDiscoveryGate.run(&s, &AuditOptions::default());
252        assert!(f.is_empty(), "unreadable plugin json must skip: {f:?}");
253    }
254
255    #[test]
256    fn metadata_methods_exposed() {
257        let g = AiDiscoveryGate;
258        assert_eq!(g.name(), "ai_discovery");
259        assert!(g.explain().contains("llms.txt"));
260        let _copy: AiDiscoveryGate = g;
261        let _clone = g;
262        assert!(format!("{g:?}").contains("AiDiscoveryGate"));
263    }
264}