First Commit
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
detections / detections (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s

This commit is contained in:
dela
2026-10-09 08:38:16 +08:00
commit 0335d572de
756 changed files with 201663 additions and 0 deletions
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""저장소 안 마크다운 문서의 내부 링크·이미지·앵커 참조 무결성을 검사한다.
추적되는 모든 `.md` 문서를 훑어 두 가지를 확인한다.
1. 저장소 안 다른 파일을 가리키는 상대 경로 링크(`[text](path)`)와 이미지
참조(`![alt](path)`·`<img src="path">`)가 실제로 존재하는 파일을 가리키는지.
2. 문서 앵커 링크(`[text](#heading)`·`[text](other.md#heading)`)가 대상 `.md`
문서에 실제로 있는 헤딩을 가리키는지. 앵커 slug 는 GitHub 의 규칙
(소문자화 → `\\p{Word}`·하이픈·공백만 남기고 제거 → 공백을 하이픈으로,
같은 slug 가 겹치면 등장 순서대로 `-1`·`-2` 접미)을 표준 라이브러리로
재현해 생성한다.
깨진 참조가 하나라도 있으면 종료 코드 1 로 끝나므로, CI 머지 게이트
(`.github/workflows/docs.yml`)와 기여자의 로컬 검증에 그대로 쓸 수 있다.
검사 대상이 아닌 것:
- 외부 URL(`http://`·`https://`·`mailto:`·`tel:`·`data:`): 네트워크에 의존해
flaky 하므로 이 결정론적 게이트에서는 다루지 않는다. 외부 링크 상태는 별도로
점검한다.
- `.md` 가 아닌 파일을 가리키는 링크의 `#` 뒤 조각(예: 소스 코드 줄 번호): 헤딩
앵커가 아니므로 파일 실존만 확인하고 앵커는 검사하지 않는다.
- 이미지 참조의 `#` 조각: 이미지에는 헤딩 앵커가 의미가 없다.
- fenced code block(``` 또는 ~~~ 로 감싼 블록) 안의 예시 링크·헤딩 꼴 주석:
실제 참조·헤딩이 아니라 코드 예시이므로 건너뛴다.
표준 라이브러리만 쓰고 네트워크에 접속하지 않는다. 실행: `python3 -I scripts/check-doc-links.py`
"""
import os
import re
import subprocess
import sys
import unicodedata
from collections import defaultdict
# [text](path) 중 이미지(`![...]`)가 아닌 링크. 경로는 공백 전까지(제목 "..." 제외).
MD_LINK = re.compile(r"(?<!\!)\[[^\]]*\]\(([^)\s]+)")
# ![alt](path) 이미지 링크.
MD_IMAGE = re.compile(r"!\[[^\]]*\]\(([^)\s]+)")
# <img ... src="path" ...> HTML 이미지.
HTML_IMAGE = re.compile(r"<img[^>]*\bsrc=[\"']([^\"']+)[\"']", re.IGNORECASE)
# 네트워크·비파일 스킴. 앵커(`#`)는 같은 문서 참조이므로 여기 넣지 않는다.
EXTERNAL_PREFIXES = ("http://", "https://", "mailto:", "tel:", "data:")
ATX_HEADING = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$")
# 헤딩 텍스트 안의 인라인 마크다운을 걷어내 순수 텍스트만 남긴다.
MD_INLINE_LINK = re.compile(r"\[([^\]]*)\]\([^)]*\)")
def is_external(target: str) -> bool:
return target.startswith(EXTERNAL_PREFIXES)
def _is_word_char(ch: str) -> bool:
"""GitHub 의 앵커 slug 규칙이 남기는 `\\p{Word}` 문자인지 판정한다.
Ruby 정규식 `\\p{Word}` = Letter(L*) + Mark(M*) + Decimal_Number(Nd)
+ Connector_Punctuation(Pc) + Join_Control(U+200C·U+200D). 한글·한자·
언더스코어·이모지의 variation selector(Mn) 등이 그대로 유지된다.
"""
if ch == "_":
return True
if ch in ("‌", "‍"):
return True
category = unicodedata.category(ch)
if category[0] in ("L", "M"):
return True
return category in ("Nd", "Pc")
def slugify(text: str) -> str:
"""헤딩 텍스트를 GitHub 의 앵커 slug 로 변환한다(중복 접미 처리 제외)."""
text = text.strip().lower()
out = []
for ch in text:
if ch == " ":
out.append("-")
elif ch == "-" or _is_word_char(ch):
out.append(ch)
return "".join(out)
def heading_slugs(root: str, md: str) -> set:
"""파일의 모든 ATX 헤딩에서 GitHub 앵커 slug 집합을 만든다.
같은 slug 가 겹치면 GitHub 처럼 등장 순서대로 `-1`·`-2` 접미를 붙인다.
"""
slugs = set()
counts = defaultdict(int)
in_fence = False
with open(os.path.join(root, md), encoding="utf-8", errors="ignore") as fh:
for line in fh:
stripped = line.lstrip()
if stripped.startswith("```") or stripped.startswith("~~~"):
in_fence = not in_fence
continue
if in_fence:
continue
match = ATX_HEADING.match(line.rstrip("\n"))
if not match:
continue
raw = match.group(2)
raw = MD_INLINE_LINK.sub(r"\1", raw)
raw = raw.replace("`", "").replace("*", "").replace("_", "")
base = slugify(raw)
seen = counts[base]
counts[base] += 1
slugs.add(base if seen == 0 else f"{base}-{seen}")
return slugs
def main() -> int:
root = subprocess.check_output(
["git", "rev-parse", "--show-toplevel"], text=True
).strip()
tracked = subprocess.check_output(
["git", "ls-files"], cwd=root, text=True
).splitlines()
md_files = [f for f in tracked if f.endswith(".md")]
slug_cache: dict = {}
def slugs_of(md_path: str) -> set:
if md_path not in slug_cache:
slug_cache[md_path] = heading_slugs(root, md_path)
return slug_cache[md_path]
checked_files = 0
checked_anchors = 0
broken_files = []
broken_anchors = []
for md in md_files:
with open(os.path.join(root, md), encoding="utf-8", errors="ignore") as fh:
lines = fh.readlines()
base_dir = os.path.dirname(md)
in_fence = False
for lineno, line in enumerate(lines, 1):
stripped = line.lstrip()
if stripped.startswith("```") or stripped.startswith("~~~"):
in_fence = not in_fence
continue
if in_fence:
continue
for pattern in (MD_LINK, MD_IMAGE, HTML_IMAGE):
for match in pattern.finditer(line):
target = match.group(1).strip()
if is_external(target):
continue
if target.startswith("#"):
file_part, anchor = "", target[1:]
elif "#" in target:
file_part, anchor = target.split("#", 1)
else:
file_part, anchor = target, ""
file_part = file_part.split("?", 1)[0]
anchor = anchor.strip()
# 가리키는 .md 문서(앵커 검사 대상). 다른 파일 링크면 그 파일,
# 파일 조각이 없는 `#앵커`면 자기 문서.
target_md = None
if file_part:
checked_files += 1
resolved = os.path.normpath(
os.path.join(root, base_dir, file_part)
)
if not os.path.exists(resolved):
broken_files.append((md, lineno, target))
continue
if file_part.endswith(".md"):
target_md = os.path.normpath(
os.path.join(base_dir, file_part)
)
else:
target_md = md
# 앵커는 본문 링크(MD_LINK)에만 의미가 있다. 이미지 참조의
# `#` 조각은 건너뛴다.
if anchor and target_md is not None and pattern is MD_LINK:
checked_anchors += 1
if anchor not in slugs_of(target_md):
broken_anchors.append((md, lineno, target, target_md))
print(
f"추적 마크다운 {len(md_files)}개 · 파일 참조 {checked_files}개 · "
f"앵커 참조 {checked_anchors}개 검사"
)
if broken_files:
print(f"깨진 파일 참조 {len(broken_files)}개 — 가리키는 파일이 저장소에 없습니다:")
for md, lineno, target in broken_files:
print(f" {md}:{lineno} -> {target}")
if broken_anchors:
print(f"깨진 앵커 참조 {len(broken_anchors)}개 — 대상 문서에 그 헤딩이 없습니다:")
for md, lineno, target, target_md in broken_anchors:
print(f" {md}:{lineno} -> {target} (대상: {target_md})")
if broken_files or broken_anchors:
return 1
print("깨진 참조 0 — 모든 내부 링크·이미지·앵커가 실존 대상을 가리킵니다.")
return 0
if __name__ == "__main__":
sys.exit(main())
+277
View File
@@ -0,0 +1,277 @@
#!/usr/bin/env python3
"""추적되는 마크다운 문서가 가리키는 외부 링크(http·https)가 아직 살아 있는지 점검한다.
자매 스크립트 `check-doc-links.py` 는 저장소 안 내부 링크·앵커만 보고 외부 URL 은
설계상 건너뛴다(네트워크에 의존해 flaky 하므로 머지 게이트에서 다루지 않는다). 이
스크립트가 그 "별도 점검"을 맡는다. README 최상단·방어 가이드·detections README 가
방문자와 방어자에게 "여기로 가 보라"고 안내하는 외부 링크(사고 신고 창구 boho.or.kr·
privacy.go.kr·pipc.go.kr·fsec.or.kr, CISA KEV, OWASP·SigmaHQ·Suricata·MITRE·MISP,
원본 데모 artex-demo.vercel.app, GitHub 배지 등)가 변질·이동·폐쇄되면 조용히 깨진
채로 남는데, 그것을 주기적으로·수동으로 잡아낸다.
점검 대상 URL 을 고르는 규칙
- 추적되는 모든 `.md` 를 훑되, fenced code block(``` 또는 ~~~)과 인라인 코드 스팬
(`` `...` ``) 안의 URL 은 건너뛴다. 그 안의 URL 은 명령 예시·설정 값·인용된 외부
자산(예: 곁질문 검증 문서의 `id.redhaze.top`)이라 "독자가 따라갈 참조 링크"가 아니다.
- 마크다운 링크·이미지(`[text](url)`·`![alt](url)`), 자동 링크(`<url>`), 그리고 남은
산문 안의 맨 URL 에서 http·https 주소를 모은다.
- 예약·플레이스홀더 호스트는 제외한다: localhost·사설/루프백 IP(127.·10.·192.168.·
169.254.·172.16~31.·0.0.0.0·::1), RFC 2606/6761 예약(example.com/org/net/edu·
`*.example.*`·`.test`·`.invalid`·`.local`·`.tld`), 점이 없어 FQDN 이 아닌 이름(예: `target`).
살아 있는지 확인하는 방법 (MAINTAINING.md 8.2 의 교훈을 코드로 옮긴 것)
국내 공공·보안 기관 사이트는 HEAD 요청·기본 User-Agent 를 거부하거나 여러 번
리다이렉트하므로, 단순 확인은 멀쩡한 링크를 깨진 것으로 오인한다. 그래서 이 스크립트는
**브라우저 User-Agent 로, GET 으로, 리다이렉트를 따라가며** 확인한다. 상태를 세 가지로
나눈다.
- OK: 최종 상태가 2xx·3xx. 링크가 유효하다.
- RESTRICTED: 401·403·405·429. 호스트는 살아 있으나 확인 방법이 서버 접근 정책(봇 차단·
메서드 거부·속도 제한)에 막힌 것일 뿐 깨진 링크가 아니다. 보고하되 실패로 치지 않는다.
- DOWN: 404·410·5xx(재시도 후에도)·DNS/연결/타임아웃/SSL 오류(재시도 후에도). 실제로
깨졌을 가능성이 높다.
네트워크 오류·5xx·429 는 소폭 지연을 두고 재시도해 일시적 깜빡임과 진짜 장애를 가른다.
표준 라이브러리만 쓴다. 이 스크립트는 저장소의 **공개 문서가 이미 가리키는** 참조 URL 에만
GET 을 보내 생존을 확인할 뿐, 어떤 대상도 스캔·탐침하지 않는다.
알려진 예외(allowlist)
`scripts/external-links-allowlist.txt` 에 적힌 URL 이 DOWN 으로 나오면 "ALLOWED" 로 따로
분류하고 strict 종료 코드에 넣지 않는다. 우리가 소유하지 않아 고칠 수 없는, 상류 원문 보존
파일(예: 상류 CHANGELOG.zh.md)이 물려받은 죽은 링크를 투명하게 기록해, 주기 strict 점검이
그 하나 때문에 영구히 빨갛게 되지 않고 **새로 깨진 링크가 생길 때만** 빨갛게 되도록 한다.
실행
- 기본(보고·종료 코드 0): `python3 -I scripts/check-external-links.py`
- 머지 게이트가 아닌 주기/릴리스 점검(새로 깨진 링크가 있으면 빨갛게): `--strict` (allowlist 에
없는 DOWN 이 하나라도 있으면 종료 코드 1). RESTRICTED·ALLOWED 는 strict 에서도 실패로 치지 않는다.
- 네트워크 없이 추출 집합만 미리 보기: `--list` (호출 없이 점검 대상 URL 과 출처를 출력).
"""
import argparse
import http.client
import os
import re
import socket
import ssl
import subprocess
import sys
import time
import urllib.error
import urllib.request
from collections import defaultdict
from urllib.parse import urlsplit
# [text](url) 본문 링크(이미지 아님)와 ![alt](url) 이미지. 경로는 공백·닫는 괄호 전까지.
MD_LINK = re.compile(r"(?<!\!)\[[^\]]*\]\(([^)\s]+)")
MD_IMAGE = re.compile(r"!\[[^\]]*\]\(([^)\s]+)")
# <https://...> 자동 링크.
AUTOLINK = re.compile(r"<(https?://[^>\s]+)>")
# 산문 안의 맨 URL. 뒤따르는 구두점은 뒤에서 벗겨낸다.
BARE_URL = re.compile(r"https?://[^\s)>\]\"'`]+")
# 인라인 코드 스팬 `...` (단일 백틱). 추출 전에 공백으로 지운다.
INLINE_CODE = re.compile(r"`[^`]*`")
# 산문 URL 끝에 흔히 붙는 구두점.
TRAILING_PUNCT = ".,;:!?\"'»)]}>"
# IPv4 사설/루프백/링크로컬 대역(172.16~31. 은 별도 패턴).
PRIVATE_IPV4 = re.compile(r"^(127\.|10\.|192\.168\.|169\.254\.|0\.0\.0\.0$)")
PRIVATE_IPV4_172 = re.compile(r"^172\.(1[6-9]|2\d|3[01])\.")
BROWSER_UA = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0 Safari/537.36"
)
# 호스트는 살아 있으나 확인 방법이 서버 정책에 막힌 상태 코드(깨짐 아님).
RESTRICTED_CODES = {401, 403, 405, 429}
ALLOWLIST_PATH = os.path.join("scripts", "external-links-allowlist.txt")
def load_allowlist(root: str) -> set:
"""우리가 고칠 수 없는 알려진 DOWN URL 집합을 읽는다(없으면 빈 집합).
한 줄에 URL 하나. `#` 뒤는 주석, 빈 줄·주석 전용 줄은 무시한다.
"""
path = os.path.join(root, ALLOWLIST_PATH)
allow = set()
if not os.path.exists(path):
return allow
with open(path, encoding="utf-8") as fh:
for line in fh:
line = line.split("#", 1)[0].strip()
if line:
allow.add(line)
return allow
def strip_code(line: str) -> str:
"""fence 밖 한 줄에서 인라인 코드 스팬을 지운다(그 안의 URL 은 참조가 아니다)."""
return INLINE_CODE.sub(" ", line)
def is_checkable(url: str) -> bool:
"""예약·플레이스홀더 호스트를 걸러, 실제 점검할 가치가 있는 외부 URL 만 남긴다."""
parts = urlsplit(url)
if parts.scheme not in ("http", "https"):
return False
host = parts.hostname
if not host:
return False
host = host.lower()
if host == "localhost" or host.endswith(".localhost"):
return False
if host == "::1":
return False
if PRIVATE_IPV4.match(host) or PRIVATE_IPV4_172.match(host):
return False
if host in ("example.com", "example.org", "example.net", "example.edu"):
return False
if host.endswith((".example.com", ".example.org", ".example.net", ".example")):
return False
if host.endswith((".test", ".invalid", ".local", ".localdomain", ".tld")):
return False
if "." not in host: # FQDN 이 아닌 맨 이름(예: target)
return False
return True
def extract(root: str, md_files: list) -> dict:
"""추적 .md 에서 점검할 외부 URL → [(파일, 줄번호), ...] 사전을 만든다."""
found = defaultdict(list)
for md in md_files:
with open(os.path.join(root, md), encoding="utf-8", errors="ignore") as fh:
lines = fh.readlines()
in_fence = False
for lineno, raw in enumerate(lines, 1):
stripped = raw.lstrip()
if stripped.startswith("```") or stripped.startswith("~~~"):
in_fence = not in_fence
continue
if in_fence:
continue
line = strip_code(raw)
candidates = []
for pattern in (MD_LINK, MD_IMAGE, AUTOLINK):
candidates.extend(m.group(1) for m in pattern.finditer(line))
for m in BARE_URL.finditer(line):
candidates.append(m.group(0).rstrip(TRAILING_PUNCT))
for url in candidates:
url = url.strip().rstrip(TRAILING_PUNCT)
if is_checkable(url):
where = (md, lineno)
if where not in found[url]:
found[url].append(where)
return found
def probe(url: str, timeout: float, retries: int, delay: float):
"""URL 에 브라우저 UA 로 GET 을 보내 (분류, 상세) 를 돌려준다.
분류는 "OK" · "RESTRICTED" · "DOWN". 네트워크 오류·5xx·429 는 재시도한다.
"""
req = urllib.request.Request(
url,
method="GET",
headers={
"User-Agent": BROWSER_UA,
"Accept": "*/*",
"Accept-Language": "ko,en;q=0.8",
},
)
last = ""
for attempt in range(retries + 1):
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
code = resp.getcode()
if code in RESTRICTED_CODES:
return "RESTRICTED", f"HTTP {code}"
return "OK", f"HTTP {code}"
except urllib.error.HTTPError as exc:
code = exc.code
if code in RESTRICTED_CODES:
return "RESTRICTED", f"HTTP {code}"
if code >= 500 or code == 408:
last = f"HTTP {code}" # 일시적일 수 있어 재시도
else:
return "DOWN", f"HTTP {code}" # 404·410 등은 확정, 재시도 불요
except (
urllib.error.URLError,
http.client.HTTPException,
ssl.SSLError,
socket.timeout,
ConnectionError,
TimeoutError,
OSError,
) as exc:
reason = getattr(exc, "reason", exc)
last = f"{type(exc).__name__}: {reason}"
if attempt < retries:
time.sleep(delay * (attempt + 1))
return "DOWN", last
def main() -> int:
ap = argparse.ArgumentParser(description="추적 마크다운의 외부 링크 생존 점검")
ap.add_argument("--strict", action="store_true",
help="DOWN 이 하나라도 있으면 종료 코드 1 (주기/릴리스 점검용)")
ap.add_argument("--list", action="store_true",
help="네트워크 호출 없이 점검 대상 URL 과 출처만 출력")
ap.add_argument("--timeout", type=float, default=15.0, help="요청 타임아웃(초)")
ap.add_argument("--retries", type=int, default=2, help="네트워크 오류·5xx·429 재시도 횟수")
ap.add_argument("--delay", type=float, default=0.5, help="호출 간·재시도 간 기본 지연(초)")
args = ap.parse_args()
root = subprocess.check_output(
["git", "rev-parse", "--show-toplevel"], text=True
).strip()
tracked = subprocess.check_output(["git", "ls-files"], cwd=root, text=True).splitlines()
md_files = [f for f in tracked if f.endswith(".md")]
allow = load_allowlist(root)
found = extract(root, md_files)
urls = sorted(found)
print(f"추적 마크다운 {len(md_files)}개에서 점검 대상 외부 URL {len(urls)}개 수집")
if args.list:
for url in urls:
where = ", ".join(f"{f}:{ln}" for f, ln in found[url])
tag = " [allowlist]" if url in allow else ""
print(f" {url}{tag} ({where})")
return 0
results = {"OK": [], "RESTRICTED": [], "ALLOWED": [], "DOWN": []}
for i, url in enumerate(urls):
if i:
time.sleep(args.delay) # 호출 간 소폭 지연(샌드박스 조절 회피)
verdict, detail = probe(url, args.timeout, args.retries, args.delay)
if verdict == "DOWN" and url in allow:
verdict = "ALLOWED" # 고칠 수 없는 알려진 상류 상속 DOWN
results[verdict].append((url, detail))
print(f" [{verdict:10}] {url} — {detail}")
print(
f"\n요약: OK {len(results['OK'])} · RESTRICTED {len(results['RESTRICTED'])} · "
f"ALLOWED {len(results['ALLOWED'])} · DOWN {len(results['DOWN'])}"
)
if results["RESTRICTED"]:
print("RESTRICTED(호스트 생존·확인 방법이 막힘, 깨진 링크 아님):")
for url, detail in results["RESTRICTED"]:
print(f" {url} — {detail}")
if results["ALLOWED"]:
print("ALLOWED(allowlist 에 적힌 알려진 DOWN — 우리가 고칠 수 없어 strict 제외):")
for url, detail in results["ALLOWED"]:
where = ", ".join(f"{f}:{ln}" for f, ln in found[url])
print(f" {url} — {detail} (참조: {where})")
if results["DOWN"]:
print("DOWN(깨졌을 가능성 높음 — 확인 필요):")
for url, detail in results["DOWN"]:
where = ", ".join(f"{f}:{ln}" for f, ln in found[url])
print(f" {url} — {detail} (참조: {where})")
if args.strict and results["DOWN"]:
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""정적 내보내기한 웹 UI(`web/out`)의 HTML 에 중국어(한자)가 새어 나왔는지 검사한다.
이 포크의 핵심 성과이자 차별점은 "사용자에게 보이는 화면에 중국어가 없다"는 것이다
(백로그 B4 가 수립한 `out` HTML 한자 0 속성). 그 속성을 손검증에만 맡기면, 기여자가
중국어 문자열을 하드코딩하거나 번역이 누락되는 회귀가 생겨도 다음 사람이 눈으로
발견할 때까지 조용히 깨진 채 남는다. 이 스크립트는 그 회귀를 머지 게이트에서 막는다.
검사 방식
---------
`web/out` 아래 모든 `.html` 파일의 **원시 내용**(렌더된 마크업뿐 아니라 인라인
스크립트·React Server Components 플라이트 데이터까지)을 훑어 한자가 하나라도 있으면
종료 코드 1 로 끝난다. 원시 내용을 그대로 보는 이유는, 사용자에게 보이는 텍스트가
플라이트 데이터(JSON 꼴 문자열)에 실려 HTML 에 함께 내려오기 때문이다. 태그만
벗겨 내면 그 경로의 누출을 놓친다.
검사 범위 밖 (정직한 한계)
-----------------------
이 게이트는 정적 내보내기 결과물만 본다. 그래서 **MOCK 모드(`NEXT_PUBLIC_MOCK=1`,
공개 데모가 쓰는 모드)에서 브라우저가 실행 중에 그려 내는 내용**까지는 검사하지 못한다.
`web/src/lib/mock/data.ts`·`handler.ts` 와 각 페이지의 하드코딩 샘플은 하이드레이션
뒤 `useEffect` 가 `mockHandle()` 로 가져와 React 상태로 렌더하므로, `out` HTML 이
아니라 런타임 JS 번들에만 실린다. 즉 이 경로로 들어온 중국어는 이 게이트를 통과한다
(과거 공개 데모의 `/system/logs` 샘플 로그에 중국어가 그렇게 조용히 남아 있었고,
백로그 G114 에서 한국어로 고쳤다).
그 표면은 소스 검토로 지킨다. 렌더 대상 파일(`web/src` 의 `.tsx`·클라이언트 데이터)에
남는 중국어는 반드시 다음 중 하나여야 한다: 코드 주석, `t.has(...)` 로 막혀 실제로는
쓰이지 않는 i18n 폴백, 백엔드 와이어 포맷 토큰(챗 멘션 분류 라벨·`[模型]` 결정 출처
센티넬), locale 가 `zh` 일 때만 보이는 분기 리터럴, 또는 성능 보존을 위해 번역하지
않는 에이전트 두뇌(段 [A]) 프롬프트를 그대로 비추는 미러. 이 가운데 어디에도 속하지
않고 "그냥 화면에 보이는 중국어"라면 한국어로 번역한다(백로그 G115 가 이 분류를
전수 확인했다).
허용·탐지 범위
-------------
- **한글(`가-힣` 등)은 허용**한다. 한자 블록과 한글 블록은 유니코드에서 겹치지 않으므로,
한자 블록만 탐지하면 한글은 자동으로 통과한다.
- **한자(CJK 한자)만 탐지**한다: 통합 한자와 확장 A(`㐀`–`鿿`), 호환 한자
(`豈`–`﫿`), 그리고 보충 평면의 확장 B 이상(`𠀀`–). 가나·전각 문장부호는
"한자"가 아니므로 이 게이트의 대상이 아니다(원문 보존 코드 블록이나 증거 인용에
섞여 들어와 거짓 양성을 내는 것을 막기 위해 범위를 한자로 좁힌다).
상류 보존이 필요한 의도적 한자
-----------------------------
현재 `out` HTML 에는 한자가 한 글자도 없어 허용 목록이 비어 있다. 앞으로 성능 보존을
위해 원문(중국어)을 일부러 화면에 남겨야 하는 경우(예: 언어 스위처의 `中文` 라벨)가
생기면, 그 글자를 아래 `ALLOWED_HAN` 에 추가하고 왜 남기는지 주석으로 적는다. 그래야
게이트는 엄격하게 유지되면서도 의도된 예외만 좁게 통과시킬 수 있다.
표준 라이브러리만 쓰고 네트워크에 접속하지 않는다.
실행: `python3 -I scripts/check-web-cjk.py [out 디렉터리]`
(기본값은 저장소 루트의 `web/out`. CI 는 `web` 작업 디렉터리에서
`python3 -I ../scripts/check-web-cjk.py` 로 부른다.)
"""
import os
import re
import subprocess
import sys
# CJK 한자 블록만. 한글(AC00–D7A3)·가나·전각 문장부호는 들어 있지 않다.
# 범위: 확장 A + 통합 한자(U+3400–U+9FFF = 㐀–鿿), 호환 한자(U+F900–U+FAFF = 豈–﫿),
# 보충 평면 확장 B 이상(U+20000–U+2FFFF).
HAN = re.compile(r"[㐀-鿿豈-﫿\U00020000-\U0002ffff]")
# 화면에 일부러 남기는 한자(예: 언어 스위처 `中文`). 지금은 비어 있다 —
# 비우면 어떤 한자든 게이트에 걸린다. 예외를 더할 때는 글자와 사유를 함께 적는다.
# 예) ALLOWED_HAN = {"中", "文"} # 언어 스위처 '中文' 라벨(상류 대조용)
ALLOWED_HAN: set = set()
def find_out_dir() -> str:
"""검사할 `out` 디렉터리를 정한다.
인자로 경로를 주면 그것을, 없으면 git 저장소 루트의 `web/out` 를 쓴다.
"""
if len(sys.argv) > 1:
return os.path.abspath(sys.argv[1])
root = subprocess.check_output(
["git", "rev-parse", "--show-toplevel"], text=True
).strip()
return os.path.join(root, "web", "out")
def main() -> int:
out_dir = find_out_dir()
if not os.path.isdir(out_dir):
print(
f"오류: 검사할 디렉터리가 없습니다 — {out_dir}\n"
"먼저 `npm run build:static` 로 정적 내보내기를 만든 뒤 실행하세요."
)
return 2
checked = 0
offenders = [] # (상대경로, 한자 개수, 미리보기)
for dirpath, _dirnames, filenames in os.walk(out_dir):
for name in filenames:
if not name.endswith(".html"):
continue
checked += 1
path = os.path.join(dirpath, name)
with open(path, encoding="utf-8", errors="ignore") as fh:
text = fh.read()
found = [ch for ch in HAN.findall(text) if ch not in ALLOWED_HAN]
if found:
rel = os.path.relpath(path, out_dir)
kinds = "".join(sorted(set(found)))
offenders.append((rel, len(found), kinds))
print(f"검사한 HTML {checked}개 (대상: {out_dir})")
if offenders:
print(f"중국어(한자) 누출 {len(offenders)}개 파일 — 사용자 노출 HTML 에 한자가 있습니다:")
for rel, count, kinds in offenders:
preview = kinds if len(kinds) <= 30 else kinds[:30] + "…"
print(f" out/{rel}: 한자 {count}개 · 종류 {preview}")
print(
"\n한글로 번역하거나, 상류 보존이 꼭 필요한 의도적 한자라면 "
"scripts/check-web-cjk.py 의 ALLOWED_HAN 에 사유와 함께 추가하세요."
)
return 1
if checked == 0:
print("경고: 검사한 HTML 이 없습니다 — 빌드가 제대로 되었는지 확인하세요.")
return 2
print("중국어(한자) 누출 0 — 사용자 노출 HTML 이 전부 한국어/비한자입니다.")
return 0
if __name__ == "__main__":
sys.exit(main())
+13
View File
@@ -0,0 +1,13 @@
# check-external-links.py 가 읽는 "알려진 DOWN" allowlist.
#
# 여기 적힌 URL 이 생존 점검에서 DOWN 으로 나오면 ALLOWED 로 분류하고 --strict 종료
# 코드에 넣지 않는다. 우리가 소유하지 않아 고칠 수 없는, 상류 원문 보존 파일이 물려받은
# 죽은 링크만 넣는다(우리 저장소가 큐레이션한 링크가 깨지면 allowlist 가 아니라 고친다).
# 새 항목을 넣을 때는 반드시 "왜 고칠 수 없는가"를 주석으로 남긴다.
#
# 형식: 한 줄에 URL 하나. `#` 뒤는 주석, 빈 줄·주석 전용 줄은 무시.
# 상류(Autumn-27/ARTEX) CHANGELOG.zh.md:539 가 크레딧한 기여자 GitHub 프로필.
# 계정이 개명·삭제돼 404. 상류 원문 보존 방침(BRIEF 경계 #1·MAINTAINING 2절)상
# CHANGELOG.zh.md 는 상류 그대로 두므로 우리가 고치지 않는다. 2026-10-07 확인.
https://github.com/begininvoke