Skip to main content

nichlink/registry_core/diagnostic/
build.rs

1use super::*;
2
3// The one JSON string encoder in the workspace; the MIR JSONL artifact and the
4// generated editor snippets call the same function, so the three cannot drift.
5// 工作区里唯一的 JSON 字符串编码器;MIR JSONL 工件与生成的编辑器片段调用同一个函数,
6// 因此三者不会漂移。
7use crate::json::push_json_string;
8
9// ---------------------------------------------------------------------------
10// Build-phase diagnostics. Pure data plus in-memory rendering; the build
11// surface collects them, the kernel owns the model.
12// 构建期诊断。纯数据与内存渲染;由 build 面收集,模型归 kernel 所有。
13
14/// One declaration error before it is rendered for rustc or a terminal.
15/// 在输出给 rustc 或终端之前的一条声明错误。
16#[derive(Clone, Debug, Eq, PartialEq)]
17pub struct BuildDiagnostic {
18    /// Build phase that raised this diagnostic; empty means the phase is not
19    /// applicable to this diagnostic.
20    /// 提出该诊断的构建阶段;为空表示该阶段不适用于此诊断。
21    pub phase: &'static str,
22    /// Graft branch the failure was reported under; empty means not applicable.
23    /// 报告该失败时所在的 graft 分支;为空表示不适用。
24    pub branch: String,
25    /// Registration face identity rendered as `name (kind)`; empty means not
26    /// applicable.
27    /// 渲染为 `name (kind)` 的注册面身份;为空表示不适用。
28    pub node: String,
29    /// Declaration source file carrying the failure; empty means not applicable.
30    /// 携带该失败的声明源文件;为空表示不适用。
31    pub source: String,
32    /// 1-based line inside `source`; 0 when the diagnostic has no line.
33    /// `source` 内以 1 起始的行号;诊断没有行号时为 0。
34    pub line: usize,
35    /// Logical function or handle the failure is about; empty means not
36    /// applicable.
37    /// 失败所针对的逻辑函数或 handle;为空表示不适用。
38    pub function: String,
39    /// Declaration field that failed its check; empty means not applicable.
40    /// 未通过检查的声明字段;为空表示不适用。
41    pub field: String,
42    /// Value the contract required; empty means not applicable.
43    /// 契约要求的值;为空表示不适用。
44    pub expected: String,
45    /// Value actually observed; empty means not applicable.
46    /// 实际观测到的值;为空表示不适用。
47    pub actual: String,
48    /// Kind of ancestor expected to provide the missing capability; empty means
49    /// not applicable.
50    /// 本应提供缺失能力的祖先类型;为空表示不适用。
51    pub provider: String,
52    /// Human-readable failure summary; empty means no message applies.
53    /// 人类可读的失败摘要;为空表示没有消息。
54    pub message: String,
55}
56
57impl BuildDiagnostic {
58    /// Start a diagnostic in `phase` with its human-readable message.
59    /// 以 `phase` 和人类可读消息开启一条诊断。
60    ///
61    /// Every optional field starts empty, so the builder methods below attach
62    /// only the facts this diagnostic actually has.
63    /// 所有可选字段起始为空,由下面的 builder 方法只挂上该诊断真正具有的事实。
64    pub fn new(phase: &'static str, message: impl Into<String>) -> Self {
65        Self {
66            phase,
67            branch: String::new(),
68            node: String::new(),
69            source: String::new(),
70            line: 0,
71            function: String::new(),
72            field: String::new(),
73            expected: String::new(),
74            actual: String::new(),
75            provider: String::new(),
76            message: message.into(),
77        }
78    }
79
80    /// Attach the declaration source and its 1-based line; pass `0` for no line.
81    /// 挂上声明源码与以 1 起始的行号;没有行号时传 `0`。
82    pub fn at(mut self, source: impl Into<String>, line: usize) -> Self {
83        self.source = source.into();
84        self.line = line;
85        self
86    }
87
88    /// Attach the failing face identity and kind, stored together as `node (kind)`.
89    /// 挂上失败注册面的身份与类型,合并存储为 `node (kind)`。
90    pub fn node(mut self, node: impl Into<String>, kind: impl Into<String>) -> Self {
91        self.node = format!("{} ({})", node.into(), kind.into());
92        self
93    }
94
95    /// Attach the graft branch the failure belongs to.
96    /// 挂上该失败所属的 graft 分支。
97    pub fn branch(mut self, branch: impl Into<String>) -> Self {
98        self.branch = branch.into();
99        self
100    }
101
102    /// Attach the logical function or handle the failure is about.
103    /// 挂上该失败所针对的逻辑函数或 handle。
104    pub fn function(mut self, function: impl Into<String>) -> Self {
105        self.function = function.into();
106        self
107    }
108
109    /// Attach the declaration field that failed its check.
110    /// 挂上未通过检查的声明字段。
111    pub fn field(mut self, field: impl Into<String>) -> Self {
112        self.field = field.into();
113        self
114    }
115
116    /// Attach the value the contract required.
117    /// 挂上契约要求的值。
118    pub fn expected(mut self, expected: impl Into<String>) -> Self {
119        self.expected = expected.into();
120        self
121    }
122
123    /// Attach the value actually observed.
124    /// 挂上实际观测到的值。
125    pub fn actual(mut self, actual: impl Into<String>) -> Self {
126        self.actual = actual.into();
127        self
128    }
129
130    /// Attach the kind of ancestor expected to provide the missing capability.
131    /// 挂上本应提供缺失能力的祖先类型。
132    pub fn provider(mut self, provider: impl Into<String>) -> Self {
133        self.provider = provider.into();
134        self
135    }
136}
137
138/// Sorted, deduplicated collection of [`BuildDiagnostic`]s.
139/// 排序去重后的 [`BuildDiagnostic`] 集合。
140#[derive(Clone, Debug, Default, Eq, PartialEq)]
141pub struct BuildDiagnostics {
142    items: Vec<BuildDiagnostic>,
143}
144
145impl BuildDiagnostics {
146    /// Append one diagnostic; duplicates are kept here and collapsed only by
147    /// [`BuildDiagnostics::iter`], `len`, and `render`.
148    /// 追加一条诊断;重复项在此保留,仅由 [`BuildDiagnostics::iter`]、`len` 与 `render`
149    /// 折叠。
150    pub fn push(&mut self, diagnostic: BuildDiagnostic) {
151        self.items.push(diagnostic);
152    }
153
154    /// Move every diagnostic of `other` into this collection.
155    /// 把 `other` 的全部诊断移入本集合。
156    pub fn extend(&mut self, other: Self) {
157        self.items.extend(other.items);
158    }
159
160    /// Whether nothing has been collected; true exactly when `len` is `0`.
161    /// 是否尚未收集到任何诊断;当且仅当 `len` 为 `0` 时为真。
162    pub fn is_empty(&self) -> bool {
163        self.items.is_empty()
164    }
165
166    /// Iterate the diagnostics in the exact order [`BuildDiagnostics::render`]
167    /// prints them, with byte-identical duplicates collapsed.
168    /// 按 [`BuildDiagnostics::render`] 打印它们的完全相同顺序迭代诊断,并折叠完全相同的
169    /// 重复项。
170    ///
171    /// The read surface and the human renderer must agree: a CI job that counts
172    /// `iter()` items while a developer reads `render()` has to see the same
173    /// set, or the two disagree about whether "the build is clean". Sorting and
174    /// deduplicating here (rather than once per caller) is what keeps them from
175    /// drifting; `render` itself is written in terms of this iterator.
176    /// 读取面与人类可读渲染必须一致:CI 数 `iter()` 的条目、开发者读 `render()`,
177    /// 两者必须看到同一集合,否则会对"构建是否干净"给出不同答案。把排序与去重放在
178    /// 这里(而不是每个调用方各做一遍)正是防止两者漂移的办法;`render` 本身就以
179    /// 这个迭代器书写。
180    pub fn iter(&self) -> impl Iterator<Item = &BuildDiagnostic> {
181        let mut items = self.items.iter().collect::<Vec<_>>();
182        items.sort_by(|left, right| {
183            (
184                left.phase,
185                &left.source,
186                left.line,
187                &left.node,
188                &left.message,
189            )
190                .cmp(&(
191                    right.phase,
192                    &right.source,
193                    right.line,
194                    &right.node,
195                    &right.message,
196                ))
197        });
198        items.dedup();
199        items.into_iter()
200    }
201
202    /// The number of distinct diagnostics [`BuildDiagnostics::render`] would
203    /// print.
204    /// [`BuildDiagnostics::render`] 会打印的去重后诊断条数。
205    pub fn len(&self) -> usize {
206        self.iter().count()
207    }
208
209    /// Render all distinct diagnostics as one human-readable report, or the
210    /// empty string when there are none.
211    /// 把所有去重后的诊断渲染为一份人类可读报告;没有诊断时返回空字符串。
212    pub fn render(&self) -> String {
213        if self.items.is_empty() {
214            return String::new();
215        }
216        let mut output = String::from("NICHLink BUILD CHECK FAILED / NichLink 构建检查失败\n");
217        for (index, diagnostic) in self.iter().enumerate() {
218            if index > 0 {
219                output.push('\n');
220            }
221            render_build_item(&mut output, diagnostic);
222        }
223        output
224    }
225
226    /// Serialize the diagnostics as one JSON document for a machine reader.
227    /// 把诊断序列化为供机器读取的单个 JSON 文档。
228    ///
229    /// Hand-rolled rather than `serde_json`: the kernel deliberately has no
230    /// serialization dependency (AGENTS.md rule 3 keeps the kernel pure, and
231    /// `serde_json` is a `cli`/`mcp` dependency only), and the shape is eleven
232    /// flat string/integer fields. Adding a derive to the kernel for that would
233    /// put a new public dependency on the crate every host links.
234    /// 手写而不是用 `serde_json`:内核刻意不带序列化依赖(AGENTS.md 规则 3 要求内核
235    /// 保持纯净,`serde_json` 只是 `cli`/`mcp` 的依赖),而形状只有十一个扁平字符串/
236    /// 整数字段。为此在内核上加 derive,等于给每个宿主都会链接的 crate 加一个新公开
237    /// 依赖。
238    ///
239    /// Every key is always present, so a reader never has to distinguish "absent"
240    /// from "empty"; `line` is `0` when the diagnostic has no line. The order is
241    /// the same as [`BuildDiagnostics::iter`], so the JSON and the rendered text
242    /// list the same diagnostics in the same order.
243    /// 所有键始终存在,读取方无需区分"缺失"与"空";诊断没有行号时 `line` 为 `0`。
244    /// 顺序与 [`BuildDiagnostics::iter`] 相同,因此 JSON 与渲染文本列出的是同一批
245    /// 诊断、同一顺序。
246    pub fn to_json(&self) -> String {
247        let mut output = String::from("{\"schema\":\"nichlink.build-diagnostics/1\",\"count\":");
248        output.push_str(&self.len().to_string());
249        output.push_str(",\"diagnostics\":[");
250        for (index, diagnostic) in self.iter().enumerate() {
251            if index > 0 {
252                output.push(',');
253            }
254            output.push_str("{\"phase\":");
255            push_json_string(&mut output, diagnostic.phase);
256            output.push_str(",\"branch\":");
257            push_json_string(&mut output, &diagnostic.branch);
258            output.push_str(",\"node\":");
259            push_json_string(&mut output, &diagnostic.node);
260            output.push_str(",\"source\":");
261            push_json_string(&mut output, &diagnostic.source);
262            output.push_str(",\"line\":");
263            output.push_str(&diagnostic.line.to_string());
264            output.push_str(",\"function\":");
265            push_json_string(&mut output, &diagnostic.function);
266            output.push_str(",\"field\":");
267            push_json_string(&mut output, &diagnostic.field);
268            output.push_str(",\"expected\":");
269            push_json_string(&mut output, &diagnostic.expected);
270            output.push_str(",\"actual\":");
271            push_json_string(&mut output, &diagnostic.actual);
272            output.push_str(",\"provider\":");
273            push_json_string(&mut output, &diagnostic.provider);
274            output.push_str(",\"message\":");
275            push_json_string(&mut output, &diagnostic.message);
276            output.push('}');
277        }
278        output.push_str("]}");
279        output
280    }
281}
282
283fn render_build_item(output: &mut String, diagnostic: &BuildDiagnostic) {
284    let phase = match diagnostic.phase {
285        "requirements" => "requirements / 注册需求",
286        "contract" => "contract / 注册合同",
287        "stable-identity" => "stable identity / 稳定标识",
288        "static-plan" => "static plan / 静态计划",
289        other => other,
290    };
291    writeln!(
292        output,
293        "+-- phase={phase} branch={}",
294        unknown(&diagnostic.branch)
295    )
296    .unwrap();
297    if !diagnostic.node.is_empty() {
298        writeln!(output, "|   node={}", diagnostic.node).unwrap();
299    }
300    if !diagnostic.source.is_empty() {
301        if diagnostic.line == 0 {
302            writeln!(output, "|   source={}", diagnostic.source).unwrap();
303        } else {
304            writeln!(
305                output,
306                "|   source={}:{}",
307                diagnostic.source, diagnostic.line
308            )
309            .unwrap();
310        }
311    }
312    if !diagnostic.function.is_empty() {
313        writeln!(output, "|   function={}", diagnostic.function).unwrap();
314    }
315    if !diagnostic.field.is_empty() {
316        writeln!(output, "|   field={}", diagnostic.field).unwrap();
317    }
318    if !diagnostic.expected.is_empty() {
319        writeln!(output, "|   expected={}", diagnostic.expected).unwrap();
320    }
321    if !diagnostic.actual.is_empty() {
322        writeln!(output, "|   actual={}", diagnostic.actual).unwrap();
323    }
324    if !diagnostic.provider.is_empty() {
325        writeln!(output, "|   provider={}", diagnostic.provider).unwrap();
326    }
327    writeln!(output, "`-- {}", diagnostic.message).unwrap();
328}
329
330fn unknown(value: &str) -> &str {
331    if value.is_empty() { "<unknown>" } else { value }
332}
333
334#[cfg(test)]
335mod build_diagnostic_tests {
336    use super::{BuildDiagnostic, BuildDiagnostics};
337
338    #[test]
339    fn render_keeps_source_and_contract_fields_together() {
340        let mut diagnostics = BuildDiagnostics::default();
341        let diagnostic = BuildDiagnostic::new("contract", "output contract does not match")
342            .branch("control")
343            .node("abc", "Button")
344            .at("control/button.rs", 12)
345            .function("Button::render")
346            .field("output")
347            .expected("Canvas")
348            .actual("String")
349            .provider("CanvasProvider");
350        diagnostics.push(diagnostic.clone());
351        diagnostics.push(diagnostic);
352        let rendered = diagnostics.render();
353        assert!(rendered.contains("phase=contract / 注册合同"));
354        assert!(rendered.contains("source=control/button.rs:12"));
355        assert!(rendered.contains("expected=Canvas"));
356        assert_eq!(
357            rendered.matches("output contract does not match").count(),
358            1
359        );
360    }
361
362    /// The read surface and `render()` must describe one set: a caller that
363    /// counts `iter()` while a human reads `render()` cannot be told two
364    /// different things about a failed build.
365    /// 读取面与 `render()` 必须描述同一集合:CI 数 `iter()`、人类读 `render()`,
366    /// 两者对构建失败不能给出两种说法。
367    #[test]
368    fn iteration_len_and_json_agree_with_the_rendered_set() {
369        let mut diagnostics = BuildDiagnostics::default();
370        assert!(diagnostics.is_empty());
371        assert_eq!(diagnostics.len(), 0);
372        assert_eq!(diagnostics.iter().count(), 0);
373        assert_eq!(
374            diagnostics.to_json(),
375            "{\"schema\":\"nichlink.build-diagnostics/1\",\"count\":0,\"diagnostics\":[]}"
376        );
377
378        diagnostics.push(BuildDiagnostic::new("contract", "second message"));
379        diagnostics.push(BuildDiagnostic::new("requirements", "first message"));
380        diagnostics.push(BuildDiagnostic::new("requirements", "first message"));
381
382        assert!(!diagnostics.is_empty());
383        assert_eq!(diagnostics.len(), 2);
384        assert_eq!(diagnostics.iter().count(), 2);
385        let rendered = diagnostics.render();
386        assert_eq!(rendered.matches("first message").count(), 1);
387        assert_eq!(rendered.matches("second message").count(), 1);
388
389        let json = diagnostics.to_json();
390        assert!(json.starts_with("{\"schema\":\"nichlink.build-diagnostics/1\",\"count\":2,"));
391        assert!(json.ends_with("]}"));
392        assert_eq!(json.matches("first message").count(), 1);
393        assert_eq!(json.matches("second message").count(), 1);
394        // The rendered order is the JSON order: phase sorts first and
395        // `contract` sorts before `requirements`, so a reader of either sees the
396        // same first failure.
397        // 渲染顺序即 JSON 顺序:先按 phase 排序,`contract` 排在 `requirements`
398        // 前,因此两种读取方看到的第一条失败相同。
399        let first = json.find("second message").expect("contract diagnostic");
400        let second = json.find("first message").expect("requirements diagnostic");
401        assert!(first < second, "{json}");
402    }
403
404    /// A message can carry source text with a quote, a backslash, a newline, or
405    /// a tab. Emitting those raw would break the whole document, so the escape
406    /// has to leave the structural characters intact.
407    /// 消息可能带有含引号、反斜杠、换行或制表的源码片段。原样写出会破坏整个文档,
408    /// 因此转义必须让结构字符保持完整。
409    #[test]
410    fn json_escapes_quotes_backslashes_and_control_characters() {
411        let mut diagnostics = BuildDiagnostics::default();
412        diagnostics.push(
413            BuildDiagnostic::new("contract", "expected \"Canvas\" \\ actual\nnext\ttab")
414                .at("control/button.rs", 3),
415        );
416        let json = diagnostics.to_json();
417        assert!(json.contains(r#"\"Canvas\""#), "{json}");
418        assert!(json.contains(r"\\ actual"), "{json}");
419        assert!(json.contains(r"\nnext\ttab"), "{json}");
420        assert!(
421            !json.contains('\n') && !json.contains('\t'),
422            "a control character must never reach the document raw: {json}"
423        );
424        assert!(
425            json.contains("\"source\":\"control/button.rs\",\"line\":3"),
426            "{json}"
427        );
428    }
429}