sqzass.git

sqzass

~ static site generator in Rust
git clone https://github.com/debuman/sqzass.git
commit 4e8d25d449d5f22ec264209d9548a7d73d0933f2
parent 9f6a5d12a828e9f2c7b055295651154dbcf2dccd
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Sat, 25 Jul 2026 22:52:59 +0900

Add sqzass doctor, scoped to what the build lets through

The build already refuses everything it cannot resolve: broken `@/` links,
missing templates, duplicate URLs, misspelled configuration and front matter
keys, absent assets, absent translation keys. Repeating those in doctor would
give two commands the same job, and one of them would go stale.

So doctor only reports what a build accepts and an author might not have meant.
Six checks, and the list was arrived at by asking what is left rather than by
asking what could be checked:

base-url base_url is still init's placeholder
untranslated a page exists in some languages and not others
empty-section a section has no pages, so its nav entry leads nowhere
description a page has none, which search results and <meta> use
draft a page is excluded from the build
unused-template no page selects it and no template names it

`--fail-on` gates, `warn` by default, exiting 7 — a number deliberately outside
the error kinds, because "your site has notes" and "the build broke" are
different events and CI should be able to tell them apart. The default is not
`note`: notes are things to know, and a pipeline that stops for them gets
switched off rather than fixed.

unused-template resolves `include`/`extends` references by substring rather
than by parsing. The check is wrong in only one direction — it stays quiet
about a template it cannot prove is unused — and telling someone to delete a
partial that is in use would be much worse than saying nothing.

CI runs it against docs/, which is currently clean. That keeps it clean, and
keeps doctor itself exercised against a real site rather than only fixtures.

Diffstat:
M.github/workflows/ci.yml | 6++++++
Mdocs/content/start/cli.ko.md | 30++++++++++++++++++++++++++++++
Mdocs/content/start/cli.md | 30++++++++++++++++++++++++++++++
Asrc/doctor.rs | 314+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib.rs | 12+++++++++++-
Msrc/main.rs | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 450 insertion(+), 1 deletion(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
@@ -34,6 +34,12 @@ jobs:
       - name: Build the docs site
         run: cargo run --quiet -- build -i docs
 
+      # doctor는 빌드가 통과시키는 것들을 본다 — 미번역 페이지, 빈 섹션, 자리표시자
+      # base_url. 지금 문서 사이트는 깨끗하고, 그 상태를 유지하는 게 이 검사의 목적이다.
+      # 동시에 doctor 자체가 실제 사이트에서 도는지 확인하는 자리이기도 하다.
+      - name: Doctor
+        run: cargo run --quiet -- doctor -i docs
+
       # 같은 입력이면 같은 바이트가 나와야 한다. 두 페이지가 같은 출력 URL을
       # 주장할 때 파일 순회 순서에 따라 결과가 달라지는 종류의 버그를 잡는다.
       - name: Build is reproducible
diff --git a/docs/content/start/cli.ko.md b/docs/content/start/cli.ko.md
@@ -9,6 +9,7 @@ toc = true
 sqzass init  [DIR]
 sqzass build [-i DIR] [-o DIR] [--drafts] [--base-url URL]
 sqzass serve [-i DIR] [-b ADDR] [-p PORT] [--drafts] [--base-url URL]
+sqzass doctor [-i DIR] [--fail-on note|warn] [--drafts]
 ```
 
 `--json`은 셋 다에 붙습니다.
@@ -36,6 +37,34 @@ sqzass serve [-i DIR] [-b ADDR] [-p PORT] [--drafts] [--base-url URL]
 메모리에서 서빙하며 <http://127.0.0.1:3000>에 라이브 리로드를 붙입니다.
 [개발 서버](@/features/dev-server.md)를 참고하세요.
 
+## doctor
+
+```bash
+sqzass doctor -i mysite
+```
+
+`build`는 해석하지 못하는 것을 이미 거부합니다 — 깨진 `@/` 링크, 없는 템플릿, 같은
+URL을 주장하는 두 페이지, 오타 난 설정 키. `doctor`는 **빌드가 받아들이지만 당신이
+의도하지 않았을 수도 있는 것**을 봅니다.
+
+| 검사 | | |
+|---|---|---|
+| `base-url` | warn | `base_url`이 아직 자리표시자 `https://example.com`입니다. |
+| `untranslated` | warn | 어떤 언어에는 있고 어떤 언어에는 없는 페이지입니다. |
+| `empty-section` | warn | 섹션에 페이지가 없어서 내비게이션 항목이 빈 곳으로 갑니다. |
+| `description` | note | `description`이 없는 페이지입니다. |
+| `draft` | note | 빌드에서 빠지는 페이지입니다. |
+| `unused-template` | note | 어떤 페이지도 고르지 않고, 어떤 템플릿도 이름으로 부르지 않습니다. |
+
+`--fail-on`으로 게이트를 정하고, 기본은 `warn`이며, 걸리면 `7`로 끝납니다. 기본을
+`note`로 두지 않은 건 의도적입니다. note는 "알아 두라"는 말이고, 그것 때문에
+파이프라인이 멈추면 사람들은 검사를 고치는 대신 doctor를 꺼 버립니다.
+
+```bash
+sqzass doctor -i mysite --fail-on note    # 엄격하게
+sqzass doctor -i mysite --json            # 모든 지적을 데이터로
+```
+
 ## exit code
 
 빌드는 성공하거나, 사이트의 **어느 부분을** 받아들일 수 없었는지 알려 줍니다. 코드는
@@ -50,6 +79,7 @@ sqzass serve [-i DIR] [-b ADDR] [-p PORT] [--drafts] [--base-url URL]
 | `4` | `SQZASS_E_CONTENT` | `content/` 아래 — front matter, 빠진 title, 같은 URL을 주장하는 두 페이지, 해석 안 되는 `@/` 링크. |
 | `5` | `SQZASS_E_TEMPLATE` | `templates/`, `i18n/`, 또는 템플릿이 요청했는데 없는 에셋. |
 | `6` | `SQZASS_E_IO` | 읽기나 쓰기 실패. |
+| `7` | | `doctor`가 `--fail-on` 기준 이상을 찾았습니다. |
 
 식별자는 메시지와 함께 찍히므로 그대로 검색할 수 있습니다.
 
diff --git a/docs/content/start/cli.md b/docs/content/start/cli.md
@@ -9,6 +9,7 @@ toc = true
 sqzass init  [DIR]
 sqzass build [-i DIR] [-o DIR] [--drafts] [--base-url URL]
 sqzass serve [-i DIR] [-b ADDR] [-p PORT] [--drafts] [--base-url URL]
+sqzass doctor [-i DIR] [--fail-on note|warn] [--drafts]
 ```
 
 `--json` works on any of them.
@@ -36,6 +37,34 @@ a ghost in the built site.
 Serves from memory with live reload on <http://127.0.0.1:3000>. See
 [Development server](@/features/dev-server.md).
 
+## doctor
+
+```bash
+sqzass doctor -i mysite
+```
+
+`build` already refuses anything it cannot resolve — a broken `@/` link, a
+missing template, two pages claiming one URL, a misspelled configuration key.
+`doctor` is for what the build accepts and you might not have meant.
+
+| Check | | |
+|---|---|---|
+| `base-url` | warn | `base_url` is still the placeholder `https://example.com`. |
+| `untranslated` | warn | A page exists in some languages and not others. |
+| `empty-section` | warn | A section has no pages, so its navigation entry leads nowhere. |
+| `description` | note | A page has no `description`. |
+| `draft` | note | A page is excluded from the build. |
+| `unused-template` | note | No page selects this template, and no template names it. |
+
+`--fail-on` sets the gate, `warn` by default, and a gated run exits `7`. The
+default is not `note` on purpose: notes are things to know, and a pipeline that
+stops for them gets switched off rather than fixed.
+
+```bash
+sqzass doctor -i mysite --fail-on note    # strict
+sqzass doctor -i mysite --json            # every finding as data
+```
+
 ## Exit codes
 
 A build either succeeds or tells you which part of your site it could not
@@ -51,6 +80,7 @@ without parsing text.
 | `4` | `SQZASS_E_CONTENT` | Something under `content/` — front matter, a missing title, two pages claiming one URL, an unresolved `@/` link. |
 | `5` | `SQZASS_E_TEMPLATE` | `templates/`, `i18n/`, or an asset a template asked for and did not get. |
 | `6` | `SQZASS_E_IO` | Reading or writing failed. |
+| `7` | | `doctor` found something at or above the `--fail-on` gate. |
 
 The identifier is printed with the message, so it can be searched for:
 
diff --git a/src/doctor.rs b/src/doctor.rs
@@ -0,0 +1,314 @@
+//! `sqzass doctor` — 빌드가 통과시키지만 사람이 알아야 하는 것들.
+//!
+//! **빌드가 이미 잡는 건 여기서 다시 잡지 않는다.** 설정 오타, front matter 오타,
+//! URL 충돌, 해석 안 되는 `@/` 링크, 없는 템플릿·에셋·번역 키는 전부 빌드가 멈추는
+//! 사유다. 그걸 doctor가 또 보고하면 두 명령이 같은 말을 하게 되고, 그중 하나는
+//! 언젠가 낡는다.
+//!
+//! 남는 건 **틀리지는 않았지만 의도한 게 아닐 가능성이 있는 것들**이다. 그래서
+//! 경고이고, 그래서 게이팅 기준을 고를 수 있어야 한다.
+
+use crate::config::Config;
+use crate::content::Page;
+use crate::render::Templates;
+use crate::site::Site;
+use anyhow::Result;
+use serde::Serialize;
+use std::collections::{BTreeMap, BTreeSet};
+use std::path::Path;
+
+/// 찾은 게 있을 때의 exit code. `Kind`의 번호들과 겹치지 않는다 — doctor의 지적은
+/// 빌드 실패와 다른 종류의 사건이고, CI가 둘을 구분할 수 있어야 한다.
+pub const FINDINGS_EXIT_CODE: i32 = 7;
+
+/// `init`이 넣어 주는 자리표시자. 이대로 배포하면 canonical URL과 sitemap이
+/// 남의 도메인을 가리킨다.
+const PLACEHOLDER_BASE_URL: &str = "https://example.com";
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Severity {
+    /// 알아 두면 좋은 것. 기본 게이트는 여기서 걸리지 않는다.
+    Note,
+    /// 의도한 게 아닐 가능성이 높은 것.
+    Warn,
+}
+
+impl std::fmt::Display for Severity {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.write_str(match self {
+            Self::Note => "note",
+            Self::Warn => "warn",
+        })
+    }
+}
+
+impl std::str::FromStr for Severity {
+    type Err = String;
+    fn from_str(s: &str) -> std::result::Result<Self, String> {
+        match s {
+            "note" => Ok(Self::Note),
+            "warn" => Ok(Self::Warn),
+            other => Err(format!("알 수 없는 심각도 '{other}' (note | warn)")),
+        }
+    }
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct Finding {
+    pub severity: Severity,
+    /// 검사 이름. 안정된 문자열이라 스크립트가 잡아도 된다.
+    pub check: &'static str,
+    pub message: String,
+    /// 관련된 소스 파일이 있으면.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub file: Option<String>,
+}
+
+impl std::fmt::Display for Finding {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{}: [{}] {}", self.severity, self.check, self.message)?;
+        if let Some(file) = &self.file {
+            write!(f, " ({file})")?;
+        }
+        Ok(())
+    }
+}
+
+/// 사이트를 읽고 지적할 것들을 모은다. 아무것도 쓰지 않는다.
+pub fn run(root: &Path, cfg: &Config, pages: &[Page], site: &Site) -> Result<Vec<Finding>> {
+    let mut out = Vec::new();
+
+    placeholder_base_url(cfg, &mut out);
+    untranslated_pages(cfg, pages, site, &mut out);
+    missing_descriptions(pages, &mut out);
+    drafts(root, cfg, &mut out)?;
+    empty_sections(cfg, site, &mut out);
+    unused_templates(root, pages, site, &mut out)?;
+
+    // 심각도가 높은 것부터, 같으면 검사 이름순. 출력 순서가 빌드처럼 결정적이어야
+    // CI 로그를 diff 할 수 있다.
+    out.sort_by(|a, b| {
+        b.severity
+            .cmp(&a.severity)
+            .then_with(|| a.check.cmp(b.check))
+            .then_with(|| a.message.cmp(&b.message))
+    });
+    Ok(out)
+}
+
+fn placeholder_base_url(cfg: &Config, out: &mut Vec<Finding>) {
+    if cfg.base_url_trimmed() == PLACEHOLDER_BASE_URL {
+        out.push(Finding {
+            severity: Severity::Warn,
+            check: "base-url",
+            message: format!(
+                "base_url이 아직 `{PLACEHOLDER_BASE_URL}` 입니다. \
+                 canonical URL과 sitemap이 이 주소로 나갑니다."
+            ),
+            file: Some("sqzass.toml".into()),
+        });
+    }
+}
+
+/// 어떤 언어에는 있고 어떤 언어에는 없는 페이지. 빌드는 이걸 통과시킨다 —
+/// 미번역 페이지를 감추는 건 의도된 동작이다. 다만 감췄다는 사실은 알아야 한다.
+fn untranslated_pages(cfg: &Config, pages: &[Page], site: &Site, out: &mut Vec<Finding>) {
+    if cfg.languages.len() < 2 {
+        return;
+    }
+    let declared: BTreeSet<&str> = cfg.languages.keys().map(String::as_str).collect();
+
+    let mut seen: BTreeSet<&str> = BTreeSet::new();
+    for page in pages {
+        if !seen.insert(&page.translation_key) {
+            continue;
+        }
+        let have: BTreeSet<String> = site
+            .language_set_of(page)
+            .into_iter()
+            .map(|(code, _)| code)
+            .collect();
+        let missing: Vec<&str> = declared
+            .iter()
+            .filter(|code| !have.contains(**code))
+            .copied()
+            .collect();
+        if !missing.is_empty() {
+            out.push(Finding {
+                severity: Severity::Warn,
+                check: "untranslated",
+                message: format!("{} 번역이 없습니다", missing.join(", ")),
+                file: Some(page.rel.to_string_lossy().into_owned()),
+            });
+        }
+    }
+}
+
+fn missing_descriptions(pages: &[Page], out: &mut Vec<Finding>) {
+    for page in pages {
+        if page.front.description.trim().is_empty() {
+            out.push(Finding {
+                severity: Severity::Note,
+                check: "description",
+                message: "description이 없습니다. 검색 결과와 <meta>에 쓰입니다.".into(),
+                file: Some(page.rel.to_string_lossy().into_owned()),
+            });
+        }
+    }
+}
+
+/// 드래프트는 기본 빌드에서 빠지므로, 사이트에 없는 이유가 여기 있다.
+fn drafts(root: &Path, cfg: &Config, out: &mut Vec<Finding>) -> Result<()> {
+    let all = crate::content::discover(root, cfg, true)?;
+    for page in all.iter().filter(|p| p.front.draft) {
+        out.push(Finding {
+            severity: Severity::Note,
+            check: "draft",
+            message: "draft = true 이므로 빌드에 포함되지 않습니다".into(),
+            file: Some(page.rel.to_string_lossy().into_owned()),
+        });
+    }
+    Ok(())
+}
+
+/// 자식이 하나도 없는 섹션. 내비게이션에 들어가지만 눌러도 목록이 비어 있다.
+fn empty_sections(cfg: &Config, site: &Site, out: &mut Vec<Finding>) {
+    for lang in cfg.languages.keys() {
+        let mut stack: Vec<&crate::site::Section> = site.sections(lang).iter().collect();
+        while let Some(section) = stack.pop() {
+            if section.pages.is_empty() && section.subsections.is_empty() {
+                out.push(Finding {
+                    severity: Severity::Warn,
+                    check: "empty-section",
+                    message: format!("'{}' 섹션에 페이지가 없습니다", section.title),
+                    file: Some(section.url.clone()),
+                });
+            }
+            stack.extend(section.subsections.iter());
+        }
+    }
+}
+
+/// 어떤 페이지도 고르지 않은 템플릿. 이름을 바꾸고 참조를 안 고쳤을 때 남는다.
+fn unused_templates(
+    root: &Path,
+    pages: &[Page],
+    site: &Site,
+    out: &mut Vec<Finding>,
+) -> Result<()> {
+    let templates = Templates::load(root)?;
+    let mut used: BTreeSet<String> = BTreeSet::new();
+    for page in pages {
+        if let Ok(name) = crate::select_template(page, site, &templates) {
+            used.insert(name);
+        }
+    }
+
+    // `include`/`extends`로 끌려 들어가는 것들은 페이지가 직접 고르지 않는다.
+    // 그것까지 미사용이라고 하면 이 검사는 매번 틀린 말을 하게 된다.
+    let referenced = referenced_templates(root, &templates)?;
+
+    for name in templates.names() {
+        if !used.contains(name) && !referenced.contains(name) {
+            out.push(Finding {
+                severity: Severity::Note,
+                check: "unused-template",
+                message: "어떤 페이지도 이 템플릿을 고르지 않았습니다".into(),
+                file: Some(format!("templates/{name}")),
+            });
+        }
+    }
+    Ok(())
+}
+
+/// 다른 템플릿이 이름으로 언급하는 템플릿들.
+///
+/// 문자열 포함 검사다. 정확한 파서가 아니지만, 여기서 틀리는 방향은 **미사용이라고
+/// 말하지 않는 쪽**이다 — 지우라고 잘못 권하는 것보다 조용한 게 낫다.
+fn referenced_templates(root: &Path, templates: &Templates) -> Result<BTreeSet<String>> {
+    let dir = root.join(crate::render::TEMPLATE_DIR);
+    let mut sources = BTreeMap::new();
+    for name in templates.names() {
+        if let Ok(text) = std::fs::read_to_string(dir.join(name)) {
+            sources.insert(name.clone(), text);
+        }
+    }
+
+    let mut out = BTreeSet::new();
+    for name in templates.names() {
+        let quoted = format!("\"{name}\"");
+        if sources
+            .iter()
+            .any(|(owner, text)| owner != name && text.contains(&quoted))
+        {
+            out.insert(name.clone());
+        }
+    }
+    Ok(out)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn severity_orders_warn_above_note() {
+        assert!(Severity::Warn > Severity::Note);
+    }
+
+    #[test]
+    fn severity_parses_and_rejects_nonsense() {
+        assert_eq!("warn".parse::<Severity>().unwrap(), Severity::Warn);
+        assert!("loud".parse::<Severity>().is_err());
+    }
+
+    #[test]
+    fn placeholder_base_url_is_reported() {
+        let cfg: Config =
+            toml::from_str("title = \"t\"\nbase_url = \"https://example.com/\"\n").unwrap();
+        let mut out = Vec::new();
+        placeholder_base_url(&cfg, &mut out);
+        assert_eq!(out.len(), 1, "{out:?}");
+        assert_eq!(out[0].check, "base-url");
+    }
+
+    #[test]
+    fn a_real_base_url_is_not_reported() {
+        let cfg: Config =
+            toml::from_str("title = \"t\"\nbase_url = \"https://sqzass.sqzer.com\"\n").unwrap();
+        let mut out = Vec::new();
+        placeholder_base_url(&cfg, &mut out);
+        assert!(out.is_empty(), "{out:?}");
+    }
+
+    #[test]
+    fn findings_render_with_check_and_file() {
+        let f = Finding {
+            severity: Severity::Warn,
+            check: "untranslated",
+            message: "ko 번역이 없습니다".into(),
+            file: Some("start/x.md".into()),
+        };
+        assert_eq!(
+            f.to_string(),
+            "warn: [untranslated] ko 번역이 없습니다 (start/x.md)"
+        );
+    }
+
+    /// doctor의 exit code는 빌드 실패의 코드들과 겹치면 안 된다. 겹치면 CI가
+    /// "지적이 있었다"와 "빌드가 깨졌다"를 구분할 수 없다.
+    #[test]
+    fn findings_exit_code_is_distinct_from_error_kinds() {
+        use crate::error::Kind;
+        for k in [
+            Kind::Config,
+            Kind::Content,
+            Kind::Template,
+            Kind::Io,
+            Kind::Other,
+        ] {
+            assert_ne!(k.code(), FINDINGS_EXIT_CODE);
+        }
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
@@ -3,6 +3,7 @@
 pub mod assets;
 pub mod config;
 pub mod content;
+pub mod doctor;
 pub mod error;
 pub mod highlight;
 pub mod i18n;
@@ -154,6 +155,15 @@ fn render_site(opts: &BuildOptions, cfg: Config) -> Result<BuildOutput> {
     Ok(out)
 }
 
+/// 사이트를 읽고 지적할 것들을 모은다. 아무것도 쓰지 않는다.
+pub fn diagnose(opts: &BuildOptions) -> Result<Vec<doctor::Finding>> {
+    let cfg = load_config(opts)?;
+    let pages = content::discover(&opts.input, &cfg, opts.drafts || cfg.build.drafts)
+        .map_err(Kind::Content.tag())?;
+    let site = Site::build(&pages, &cfg);
+    doctor::run(&opts.input, &cfg, &pages, &site).map_err(Kind::Other.tag())
+}
+
 /// 사이트를 빌드해 디스크에 쓴다.
 pub fn build(opts: &BuildOptions) -> Result<BuildStats> {
     let cfg = load_config(opts)?;
@@ -283,7 +293,7 @@ fn render_page(
 /// Hugo의 20단계 lookup order 캐스케이드는 채택하지 않는다 — kind × section × type ×
 /// layout × language × output format을 곱해 만든 순서표는 사용자가 가장 많이 헤매는
 /// 지점이고, "해석 순서를 출력이라도 해달라"는 요청이 10년째 열려 있다.
-fn select_template(page: &Page, site: &Site, templates: &Templates) -> Result<String> {
+pub(crate) fn select_template(page: &Page, site: &Site, templates: &Templates) -> Result<String> {
     if let Some(explicit) = &page.front.template {
         if templates.has(explicit) {
             return Ok(explicit.clone());
diff --git a/src/main.rs b/src/main.rs
@@ -33,6 +33,26 @@ enum Command {
     Serve(ServeArgs),
     /// 새 사이트를 만든다
     Init(InitArgs),
+    /// 빌드는 통과하지만 알아 둘 만한 것들을 찾는다
+    Doctor(DoctorArgs),
+}
+
+#[derive(clap::Args)]
+struct DoctorArgs {
+    /// 사이트 루트 (sqzass.toml이 있는 디렉터리)
+    #[arg(short, long, default_value = ".", value_name = "DIR")]
+    input: PathBuf,
+
+    /// 이 심각도 이상이 하나라도 있으면 실패로 끝낸다 (note | warn)
+    ///
+    /// 기본이 warn인 이유: note는 "알아 두라"는 말이고, 그것 때문에 파이프라인이
+    /// 멈추면 사람들은 doctor를 끄지 검사를 고치지 않는다.
+    #[arg(long, default_value = "warn", value_name = "SEVERITY")]
+    fail_on: sqzass::doctor::Severity,
+
+    /// 드래프트 페이지도 포함해서 본다
+    #[arg(long)]
+    drafts: bool,
 }
 
 #[derive(clap::Args)]
@@ -155,6 +175,45 @@ fn run(cli: Cli) -> Result<()> {
             }
             Ok(())
         }
+        Command::Doctor(args) => {
+            let findings = sqzass::diagnose(&BuildOptions {
+                input: args.input,
+                output: None,
+                drafts: args.drafts,
+                base_url: None,
+            })?;
+            let gated = findings
+                .iter()
+                .filter(|f| f.severity >= args.fail_on)
+                .count();
+
+            if json {
+                println!(
+                    "{}",
+                    serde_json::json!({
+                        "ok": gated == 0,
+                        "findings": findings,
+                        "gated": gated,
+                    })
+                );
+            } else if findings.is_empty() {
+                println!("지적할 것이 없습니다");
+            } else {
+                for f in &findings {
+                    println!("{f}");
+                }
+                println!(
+                    "\n{}건 중 {gated}건이 {} 이상입니다",
+                    findings.len(),
+                    args.fail_on
+                );
+            }
+
+            if gated > 0 {
+                std::process::exit(sqzass::doctor::FINDINGS_EXIT_CODE);
+            }
+            Ok(())
+        }
         Command::Serve(args) => {
             // `build`는 동기 경로라 런타임을 서버에서만 만든다.
             let rt = tokio::runtime::Runtime::new()?;