~13 min · failure, tooling, silent-failure, design
Level 0흩어진 부품
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
뭐가 나갔나
소비자 하나가 템플릿 원본의 대상 목록에 추가됐어. 변수 블록은 같이 안 들어갔고. 흔한 누락이야. 매니페스트 한 줄, 어디서도 에러 없음.
치환은 뻔한 방식으로 구현돼 있었어. 변수 맵의 키마다 텍스트에서 바꿔치기. 맵이 없으면 반복문이 돌 게 없으니까 아무것도 안 바꾸고 원본을 그대로 돌려줬어. 그다음 배달이 서비스 워커를 하나 썼는데, 캐시 이름이 글자 그대로 치환 안 된 토큰이었고, 셸 목록은 값이 있어야 할 자리에 맨 자리표시자 토큰을 그대로 싣고 있었어. 두 번째가 문법 오류보다 나빠. 문법 오류가 아니거든. 맨 토큰은 유효한 식별자라서 파싱 때 아무도 불평 안 하고, 실패는 코드가 돌면서 그 이름이 아무것도 안 가리킬 때까지 기다려.
그 아래로는 전부 깨끗했어. 파일이 써졌고, 머리말도 맞고, 커밋이 들어갔고, 리뷰는 다른 서비스 워커랑 똑같이 생긴 서비스 워커를 봤어. 깨졌을 유일한 경로는 네트워크 없이 앱을 여는 거고, 그게 바로 아무도 일부러 안 밟는 경로야.
왜 이 부류가 보이는 것보다 고약한가
이 실패엔 진짜 위험한 가족의 일원이 되게 하는 성질이 셋 있어.
나쁜 산출물이 형태가 멀쩡해. 맞는 종류의 파일이, 맞는 자리에, 맞는 머리말을 달고 있어. 의심을 부르는 구석이 없어.
도구의 기본 동작이 그걸 만들었어. 아무것도 덮어쓰지 않았고 어떤 경고도 안 눌렀어. 키가 없다는 게 그냥 사건이 아니었어.
피해 반경이 늦고 좁아. 몇 주 뒤에, 마침 오프라인인 사람한테, 오프라인 경로만 깨져. 책상 하나에서 쓰는 앱 가족이면 한참 동안 아무도 아닐 수 있고.
대안 실패랑 비교해봐. 배달이 거부하면서 어느 자리표시자가 살아남았는지, 어느 매니페스트 키가 그 값을 주는지를 찍는 거. 같은 결함, 몇 주 대신 십 초, 그리고 그걸 만든 사람이 그걸 보는 사람이야.
치환 안 된 자리표시자는 결함이고, 도구가 그걸 내보낼 수 있으면 안 돼. 템플릿 단계는 치환 뒤에 자리표시자 토큰이 안 남았다고 단언해야 해. 패턴 탐지는 시시해. 자리표시자는 애초에 grep 되라고 튀는 모양으로 고르니까. 그 단언 하나가 조용하고 늦고 형태 멀쩡한 오염 한 부류 전체를, 처방이 딸린 즉각적인 거부로 바꿔.
처방, 그리고 그 메시지의 모양
수리는 자리표시자 문법에 대한 정규식 하나를 쓰기 전 렌더링된 본문에 돌리는 것, 그리고 세 가지를 이름 대는 거부였어. 이 파일을 받았을 소비자와 대상, 어느 자리표시자가 살아남았는지, 그리고 그 값이 들어갈 매니페스트의 정확한 자리.
세 번째가 이걸 그냥 시끄러운 실패가 아니라 좋은 실패로 만들어. "치환 안 된 변수 발견"이라고만 하면 독자가 치환이 어떻게 설정되는지 알아내러 가야 해. 더할 매니페스트 키를 이름 대는 메시지는 배달 스크립트를 한 번도 안 읽어본 사람이 이 분 만에 고칠 수 있는 일로 바꿔.
Code
조용한 판본, 그리고 그걸 대체하는 거부·python
import re
TEMPLATE_VAR = re.compile(r"__KIT_[A-Z0-9_]+__")
def substitute(content: str, vars_map: dict[str, str] | None) -> str:
"""The original. With vars_map=None the loop body never runs, so
the placeholders survive and the caller writes them to disk."""
if vars_map:
for key, value in vars_map.items():
content = content.replace(key, value)
return content
def unsubstituted(content: str) -> list[str]:
"""Placeholders still standing after substitution.
Deliberately distinctive syntax: a placeholder must be impossible
to confuse with real code, so that this check can be a plain
regex over the rendered text with no parsing at all.
"""
return sorted(set(TEMPLATE_VAR.findall(content)))
# At the deploy site, before writing THIS target:
expected_body = substitute(raw, (entry.get("vars") or {}).get(repo))
leftover = unsubstituted(expected_body)
if leftover:
print(
f"unsubstituted template var(s) for {repo}:{target_rel}"
f" - {', '.join(leftover)}."
f' Add them to "vars"."{repo}" for {entry["source"]}'
" in the manifest.",
file=sys.stderr,
)
return 2 # abort the RUN. And be honest about what that is
# NOT: targets written earlier in the walk stay
# written, because each is written as it is
# reached. Refusing late is not a rollback - it
# only stops the remaining writes.
# What the bad output looked like on disk - well-formed, wrong:
#
# const CACHE = "__KIT_APP_SLUG__-shell-v1"; // a real cache,
# // named nonsense
# const SHELL = [__KIT_SHELL_LIST__]; // NOT a syntax error.
# // A bare token is a
# // valid identifier: it
# // parses, then is
# // undefined at RUNTIME
네 프로젝트의 템플릿이나 코드 생성 단계를 하나 골라서 질문 하나에 답해. 값이 없으면 뭘 해? 해봐. 변수를 하나 지우고 생성기를 돌려. 산출물이 자리표시자를 품은 파일이거나, 값이 있어야 할 자리에 빈 문자열이거나, 'None' 이라는 단어면, 치환 뒤 단언을 붙이고 그 메시지가 값을 정의할 자리를 이름 대게 만들어.
Hint
제일 위험한 결과는 크래시도 자리표시자도 아냐. 빈 문자열이야. 빈 문자열은 보통 유효한 문법이거든. 이름이 '' 인 캐시나 '' 인 URL 은 배달되고, 돌아가고, 템플릿 얘기는 한 번도 안 꺼내면서 깨져.
Progress
Progress is local-only — sign in to sync across devices.