빌드 파이프라인이 터미널을 열고 make tangle을 입력하지 않으면 실행될 수 없다면, 당신은 리터레이트 프로그램을 가지고 있지 않다. 컴파일러가 달린 일기장을 가지고 있는 것이다.

리터레이트 프로그래밍의 전체 목적은 산문과 코드가 단일 진실 공급원을 공유하는 것이다. Markdown 파일이 아티팩트다. 그 외 모든 것 — 실행 가능한 소스, 렌더링된 문서, 테스트 파일 — 은 파생된 것이다. 파생 아티팩트는 작업 기억이 아닌 CI에 속한다.

문제는 CI가 weave와 tangle을 할 수 있는지 여부가 아니다. 물론 할 수 있다. Python과 pandoc을 실행할 수 있는 모든 기계가 할 수 있다. 문제는 리포지토리 구조가 CI가 파생 파일을 소유하도록 허용하는지, 아니면 여전히 .py 파일이 실제로는 빌드 출력임에도 불구하고 소스 코드인 척하고 있는지 여부다.

weave와 tangle이 실제로 하는 일

Donald Knuth은 두 연산을 weavetangle이라고 불렀는데, 이름이 너무 혼란스러워서 사람들이 이 주제를 아예 피한다.

tangle은 서사적 문서 — .md 또는 .w 파일 — 을 가져와 컴파일러가 기대하는 순서로 코드 블록을 추출한다. 실행 가능한 소스 파일을 생성한다. weave는 그 반대다. 같은 문서를 가져와 pretty-print된 코드, 교차 참조, 목차를 갖춘 사람이 읽을 수 있는 문서를 생성한다.

둘 다 결정론적 변환이다. 하나의 입력을 받아 일관된 출력을 생성한다. 이것이 CI가 처리해야 할 것의 정의다.

왜 대부분의 팀이 파이프라인을 거꾸로 구축하는가

전형적인 리터레이트 프로그래밍 워크플로우는 이렇게 생겼다. algorithm.md를 작성한다. algorithm.py를 추출하기 위해 로컬 스크립트를 실행한다. algorithm.py에 대해 테스트를 실행한다. algorithm.mdalgorithm.py 모두를 Git에 커밋한다. 그런 다음 풀 리퀘스트를 연다.

이것은 이미 망가졌다. 버전 관리에 동일한 로직의 두 복사본이 있다. 리뷰어가 algorithm.py의 변경을 제안하면, algorithm.md로 백포트하는 것을 기억해야 한다. algorithm.md를 편집하고 re-tangle 하는 것을 잊으면, 커밋된 .py 파일은 stale 해진다. 단일 진실 공급원은 허구다.

올바른 워크플로우는 더 간단하다. .md 파일만 커밋한다. CI는 .py 파일을 생성하기 위해 tangle을 실행한 다음, 그에 대해 테스트 스위트를 실행한다. 테스트를 통과하면, CI는 선택적으로 weave를 실행하여 문서를 게시한다. .py 파일은 버전 관리에 절대 닿지 않는다. 그것은 .o 파일이나 Docker 이미지와 같은 빌드 출력이다.

Markdown-to-Python을 위한 작동하는 CI 파이프라인

다음은 Markdown 파일을 유일한 진실의 원천으로 취급하는 완전한 GitHub Actions 워크플로우다. .github/workflows/literate.yml로 저장하라:

name: Tangle and Test

on:
  push:
    paths:
      - "**/*.md"
  pull_request:
    paths:
      - "**/*.md"

jobs:
  tangle:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Tangle code from Markdown
        run: python scripts/tangle.py src/*.md --out build/

      - name: Run tests on extracted code
        run: python -m pytest build/ -v

      - name: Upload generated source as artifact
        uses: actions/upload-artifact@v4
        with:
          name: generated-source
          path: build/

paths 필터가 중요하다. 이 워크플로우는 Markdown 파일이 변경될 때만 실행되는데, Markdown이 유일하게 중요한 입력이기 때문이다.

tangle.py 스크립트는 로컬에서 실행할 것과 동일한 30줄 추출기다. 여러 파일과 출력 디렉터리를 처리하는 버전은 다음과 같다:

import argparse
import re
import sys
from pathlib import Path


def tangle(md_path: Path, out_dir: Path) -> None:
    content = md_path.read_text()
    files: dict[str, list[str]] = {}

    for block in re.findall(r"```python\n(.*?)```", content, re.DOTALL):
        lines = block.strip().split("\n")

        if lines and lines[0].startswith("# "):
            filename = lines[0][2:].strip()
            code = lines[1:]
        else:
            filename = "module.py"
            code = lines

        files.setdefault(filename, []).extend(code)

    out_dir.mkdir(parents=True, exist_ok=True)
    for filename, code_lines in files.items():
        (out_dir / filename).write_text("\n".join(code_lines) + "\n")


def main() -> None:
    parser = argparse.ArgumentParser(description="Extract code from Markdown files.")
    parser.add_argument("inputs", nargs="+", type=Path, help="Markdown files to process")
    parser.add_argument("--out", type=Path, required=True, help="Output directory")
    args = parser.parse_args()

    for md in args.inputs:
        tangle(md, args.out)


if __name__ == "__main__":
    main()

로컬에서 실행하여 확인하라:

$ python scripts/tangle.py src/calculator.md --out build/
$ python -m pytest build/ -v

CI 작업은 정확히 똑같은 것을 한다. 유일한 차이점은 CI가 깨끗한 환경에서 실행하므로, 작업 디렉터리에 숨겨진 stale .py 파일이 테스트 스위트를 속일 수 없다는 점이다.

같은 파이프라인에서 문서를 weave하기

tangle은 코드를 생성한다. weave는 문서를 생성한다. 둘 다 CI에서 실행할 수 있다.

weave를 위해서는 Markdown을 표시 가능한 형식으로 변환하는 도구가 필요하다. Pandoc은 지루하지만 신뢰할 수 있는 선택이다. 교차 참조, 구문 강조, 목차가 필요하다면, 작은 템플릿으로 충분하다:

      - name: Weave documentation
        run: |
          mkdir -p docs/output
          pandoc src/*.md \
            --from markdown \
            --to html5 \
            --standalone \
            --toc \
            --highlight-style=tango \
            --output docs/output/index.html

      - name: Publish to GitHub Pages
        uses: actions/upload-pages-artifact@v3
        with:
          path: docs/output/

렌더링된 HTML 역시 파생 아티팩트다. main에 머지할 때마다 GitHub Pages, Netlify, S3 버킷에 게시할 수 있다. Markdown 파일은 정규 소스로 남는다. HTML은 독자가 보는 것이다.

커밋 대 생성의 트레이드오프

일부 팀은 이에 반발한다. 그들은 .py 파일을 Git에 넣고 싶어한다. git grep이 작동하고, GitHub의 코드 리뷰 UI가 Markdown보다 Python 구문을 더 잘 이해하기 때문이다.

이것들은 실제 문제이지만, 아키텍처 문제가 아니라 도구 문제다. 코드 리뷰 도구가 나쁘다고 생성된 코드를 커밋하는 것은, 패키지 관리자가 느리다고 node_modules를 커밋하는 것과 같다. 작동하지만 부채가 쌓인다.

절대적으로 .py 파일을 리포지토리에 넣어야 한다면, CI에서 생성하고 봇 계정으로 다시 커밋하라. 패턴은 다음과 같다:

  1. 개발자가 algorithm.md의 변경 사항을 푸시한다.
  2. CI가 tangle.py를 실행하여 algorithm.py를 생성한다.
  3. 추출된 코드가 main의 것과 다르면, CI가 업데이트된 .py 파일이 포함된 풀 리퀘스트를 연다.
  4. 사람이 생성된 diff를 검토하고 머지한다.

이것은 생성된 파일이 버전 관리에 남아 있으면서도 어긋나지 않도록 유지한다. 봇은 생성된 출력의 단일 진실 공급원이 되며, 봇은 Markdown 파일이 요구하는 것만 변경한다.

CI 자동화가 가치가 있는 경우와 그렇지 않은 경우

자동화된 weave와 tangle은 산문이 길고, 코드가 복잡하며, 여러 사람이 문서를 편집할 때 빛난다. 연구 코드, 라이브러리 문서, 알고리즘 구현은 모두 푸시할 때마다 CI가 검증하는 정규 Markdown 파일에서 혜택을 받는다.

작은 스크립트나 보일러플레이트가 많은 애플리케이션 코드에는 가치가 없다. Markdown 파일이 두 단락과 스무 줄짜리 함수라면, CI 오버헤드, 러너 시간, 정신적 모델 비용이 해결하려는 동기화 문제보다 더 크다. 그냥 좋은 docstring을 써라.

이것이 실패하는 또 다른 경우는 언어 도구가 .py 파일을 주요 파일로 간주할 때다. 디버거, 프로파일러, 커버리지 도구는 보통 Markdown이 아닌 생성된 소스를 가리키고 싶어한다. 디버깅 전에 로컬로 파일을 생성함으로써 우회할 수 있지만, 그것은 마찰이다. 팀이 디버거에 의존한다면, CI 파이프라인이 아무리 좋아도 리터레이트 프로그래밍은 잘못된 선택일 수 있다.

FAQ

생성된 .py 파일을 커밋해야 하는가, 아니면 .gitignore에 넣어야 하는가?

.gitignore에 넣어라. Markdown에서 결정론적으로 생성된다면, 그것들은 빌드 아티팩트다. 유일한 예외는 패키지 레지스트리와 같은 외부 시스템이 tangle 단계를 실행할 수 없는 경우다. 그 경우에는 위에서 설명한 봇 커밋 패턴을 사용하라.

내 리터레이트 문서가 여러 언어를 혼합하면 어떻게 되는가?

tangle.py 스크립트는 언어에 구애받지 않는다. Rust 블록에는 # filename.rs, Go 블록에는 # filename.go 등을 사용하라. CI는 각 언어에 대해 동일한 스크립트를 실행한다. 워크플로우에서 언어별로 테스트 단계가 필요할 뿐이다.

이것이 Knuth의 WEB과 같은 이름 있는 코드 청크와 작동하는가?

Entangled과 같은 현대적인 도구는 이름 있는 청크와 종속성 추적을 지원한다. 그 수준의 표현력이 필요하다면, 커스텀 스크립트 대신 CI에서 Entangled을 사용하라. 파이프라인 구조는 동일하다. 변하는 것은 추출 도구뿐이다.

여러 Markdown 파일에 걸친 임포트는 어떻게 처리하는가?

각 Markdown 파일은 자체 출력 디렉터리로 tangle된다. math.mdadd를 정의하고 geometry.md가 임포트한다면, 두 파일을 모두 동일한 빌드 디렉터리로 tangle하거나, 상대적 임포트가 있는 패키지로 코드를 구조화하라. CI 작업은 테스트를 실행하기 전에 모든 파일을 tangle할 수 있으므로, 파일 간 종속성은 자연스럽게 해결된다.

Markdown 파일을 유일하게 중요한 파일로 만들어라

파이프라인을 한 번 설정하라. .md 파일을 커밋하라. 나머지는 CI에 맡겨라. 테스트가 실패하면, 실패는 코드를 생성한 Markdown 줄을 가리키며, 어차피 버전 관리에 존재해서는 안 될 생성된 파일을 가리키는 것이 아니다.

리터레이트 프로그래밍의 목표는 단일 진실 공급원이다. 인간이 수동 단계를 기억해야 하는 진실 공급원은 진실 공급원이 아니다. 그것은 제안에 불과하다.