Если ваш пайплайн сборки не может работать без того, чтобы вы открыли терминал и набрали make tangle, у вас нет литературной программы. У вас есть дневник с прикреплённым компилятором.

Весь смысл литературного программирования в том, что проза и код делят единый источник правды. Markdown-файл — это артефакт. Всё остальное — исполняемый исходник, отрендеренная документация, тестовые файлы — является производным. Производные артефакты принадлежат CI, а не вашей оперативной памяти.

Вопрос не в том, может ли CI выполнять weave и tangle. Конечно, может. Любая машина с Python и pandoc справится. Вопрос в том, позволяет ли структура вашего репозитория CI владеть производными файлами, или вы всё ещё притворяетесь, что .py-файлы — это исходный код, хотя на самом деле это результат сборки.

Что на самом деле делают weave и tangle

Donald Knuth назвал две операции weave и tangle, и названия настолько запутанные, что люди предпочитают обходить эту тему стороной.

tangle берёт нарративный документ — файл .md или .w — и извлекает блоки кода в порядке, ожидаемом компилятором. Он создаёт исполняемые исходные файлы. weave делает наоборот. Берёт тот же документ и производит человекочитаемую документацию с pretty-printed кодом, перекрёстными ссылками и оглавлением.

Обе операции — детерминированные преобразования. Они получают один вход и выдают согласованный выход. Это как раз то, чем должен заниматься CI.

Почему большинство команд строят пайплайн наоборот

Типичный workflow литературного программирования выглядит так. Вы пишете algorithm.md. Запускаете локальный скрипт для извлечения algorithm.py. Запускаете тесты для algorithm.py. Коммитите и algorithm.md, и algorithm.py в Git. Затем открываете pull request.

Это уже сломано. У вас две копии одной логики под версионным контролем. Если ревьюер предложит изменить algorithm.py, вам придётся вспомнить и перенести изменения в algorithm.md. Если вы отредактируете algorithm.md и забудете перезапустить tangle, закоммиченный .py-файл устареет. Единый источник правды — вымысел.

Правильный workflow проще. Вы коммитите только .md-файл. CI запускает tangle, чтобы получить .py-файл, а затем прогоняет тестовый набор. Если тесты проходят, CI по желанию запускает weave для публикации документации. .py-файл никогда не попадает под версионный контроль. Это результат сборки, как .o-файл или Docker-образ.

Рабочий CI-пайплайн для Markdown-в-Python

Вот полный workflow GitHub Actions, который рассматривает Markdown-файл как единственный источник правды. Сохраните как .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 важен. Этот workflow запускается только при изменении Markdown-файла, потому что Markdown — единственный вход, который имеет значение.

Скрипт tangle.py — тот же тридцатистрочный экстрактор, который вы бы запустили локально. Вот версия, которая обрабатывает несколько файлов и выходную директорию:

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 запускает его в чистом окружении, поэтому устаревший .py-файл, спрятанный в вашем рабочем каталоге, не может обмануть тестовый набор.

Ткачество документации в том же пайплайне

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 тоже является производным артефактом. Вы можете публиковать его на GitHub Pages, Netlify или в S3-бакет при каждом merge в main. Markdown-файл остаётся каноническим. HTML — это то, что видят читатели.

Компромисс между коммитом и генерацией

Некоторые команды возражают против этого. Они хотят .py-файлы в Git, потому что так работает git grep, и потому что интерфейс code review на GitHub лучше понимает Python-синтаксис, чем Markdown.

Это реальные проблемы, но они проблемы инструментария, а не архитектуры. Коммитить сгенерированный код, потому что ваш инструмент code review плохой — это как коммитить node_modules, потому что ваш пакетный менеджер медленный. Работает, но накапливает долг.

Если вам абсолютно необходимы .py-файлы в репозитории, генерируйте их в CI и коммитьте обратно с бот-аккаунта. Вот паттерн:

  1. Разработчик пушит изменение в algorithm.md.
  2. CI запускает tangle.py и производит algorithm.py.
  3. Если извлечённый код отличается от того, что в main, CI открывает pull request с обновлённым .py-файлом.
  4. Человек просматривает сгенерированный diff и мержит его.

Это позволяет держать сгенерированные файлы под версионным контролем, не давая им расходиться. Бот становится единственным источником правды для сгенерированного вывода, и бот меняет только то, что требует Markdown-файл.

Когда автоматизация CI стоит затрат, а когда нет

Автоматизированный weave и tangle блестяще работают, когда проза длинная, код сложный, а документ редактируют несколько человек. Исследовательский код, документация библиотек и реализации алгоритмов — все выигрывают от канонического Markdown-файла, который CI валидирует при каждом пуше.

Это не стоит усилий для маленьких скриптов или кода приложений с большим количеством шаблонного кода. Если ваш Markdown-файл — два абзаца и функция в двадцать строк, накладные расходы на CI, минуты раннера и когнитивная нагрузка перевешивают решаемую проблему синхронизации. Просто напишите хороший docstring.

Другой случай, когда это не работает — когда инструментарий вашего языка считает .py-файлы первичными. Дебаггеры, профилировщики и инструменты покрытия обычно хотят указывать на сгенерированный исходник, а не на Markdown. Это можно обойти, генерируя файлы локально перед отладкой, но это дополнительное трение. Если ваша команда живёт в дебаггере, литературное программирование может быть неподходящим выбором вне зависимости от качества CI-пайплайна.

FAQ

Стоит ли коммитить сгенерированные .py-файлы или добавлять их в .gitignore?

Добавлять в .gitignore. Если они генерируются детерминированно из Markdown, это артефакты сборки. Единственное исключение — если внешняя система, например реестр пакетов, не может выполнить ваш шаг tangle. В этом случае используйте паттерн коммита ботом, описанный выше.

Что если мой литературный документ смешивает несколько языков?

Скрипт tangle.py не зависит от языка. Используйте # filename.rs для блоков Rust, # filename.go для блоков Go и так далее. CI запускает тот же скрипт для каждого языка. Вам нужен только отдельный шаг тестирования на язык в workflow.

Работает ли это с именованными блоками кода, как в WEB Кнута?

Современные инструменты вроде Entangled поддерживают именованные блоки и отслеживание зависимостей. Если вам нужен такой уровень выразительности, используйте Entangled в CI вместо кастомного скрипта. Структура пайплайна та же. Меняется только инструмент извлечения.

Как обрабатывать импорты, охватывающие несколько Markdown-файлов?

Каждый Markdown-файл обрабатывается tangle в собственную выходную директорию. Если math.md определяет add, а geometry.md импортирует его, либо обработайте оба файла tangle в одну директорию сборки, либо структурируйте код как пакет с относительными импортами. CI-джоб может обработать все файлы tangle перед запуском тестов, поэтому межфайловые зависимости разрешаются естественным образом.

Сделайте ваш Markdown-файл единственным значимым файлом

Настройте пайплайн один раз. Закоммитьте .md-файл. Пусть CI разберётся с остальным. Если тест падает, ошибка указывает на строку в Markdown, которая породила код, а не на сгенерированный файл, который в любом случае не должен существовать под версионным контролем.

Цель литературного программирования — единый источник правды. Источник правды, который требует, чтобы человек помнил о ручном шаге, — это не источник правды. Это рекомендация.