580 lines
24 KiB
Python
580 lines
24 KiB
Python
#!/usr/bin/env python3
|
|
"""Shell/CLI 예제에서 교육성 검토가 필요한 신호를 결정론적으로 찾는다.
|
|
|
|
이 모듈의 finding은 금지 규칙이 아니다. planner/editor/reviewer가 볼 범위를 좁히는
|
|
신호다. 기술적으로 정당한 compact command는 문맥에 따라 그대로 남을 수 있다.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import re
|
|
from typing import Any
|
|
|
|
|
|
SHELL_FENCE_RE = re.compile(
|
|
r"^```[ \t]*(?P<language>bash|sh|shell|zsh|console)"
|
|
r"(?P<info>(?:[ \t]+[^\n]*)?)\n"
|
|
r"(?P<body>.*?)^```[ \t]*$",
|
|
re.MULTILINE | re.DOTALL | re.IGNORECASE,
|
|
)
|
|
FENCE_LABEL_RE = re.compile(
|
|
r"(?:^|\s)label\s*=\s*(?:\"(?P<double>[^\"]*)\"|'(?P<single>[^']*)')",
|
|
re.IGNORECASE,
|
|
)
|
|
LABEL_MODE_RE = re.compile(
|
|
r"\[(tutorial|operator|diagnostic|automation|reference)\]",
|
|
re.IGNORECASE,
|
|
)
|
|
LABEL_EXECUTION_CONTEXT_RE = re.compile(
|
|
r"(?:\blab\s+host\b|\bkc-lab-[\w-]+\b|\btest-server\b|\bdev\b|"
|
|
r"\bworkstation\b|\bhost\b|\bserver\b|\bguest\b|\bcontainer\b|\bpod\b|"
|
|
r"워크스테이션|호스트|서버|게스트|컨테이너|파드)",
|
|
re.IGNORECASE,
|
|
)
|
|
RAW_SSH_IP_RE = re.compile(
|
|
r"(?m)^\s*(?:ssh|scp)\b[^\n]*?(?P<ip>(?:\d{1,3}\.){3}\d{1,3})(?=[:\s]|$)"
|
|
)
|
|
REMOTE_COMMAND_RE = re.compile(r"(?m)^\s*(?:ssh|scp)\b")
|
|
EXECUTION_CONTEXT_RE = re.compile(
|
|
r"(?:실행\s*위치|로컬|원격|호스트|서버|게스트|컨테이너|\bVM\b|"
|
|
r"\blocal\b|\bremote\b|\bhost\b|\bserver\b|\bguest\b|"
|
|
r"\bcontainer\b|\bworkstation\b)",
|
|
re.IGNORECASE,
|
|
)
|
|
REMOTE_COMPOUND_RE = re.compile(
|
|
r"(?m)^\s*ssh\s+[^\n]+?(['\"])(?:(?!\1).)*(?:;|&&|\|\|)(?:(?!\1).)*\1"
|
|
)
|
|
CLEANUP_CHAIN_RE = re.compile(
|
|
r"(?m)^.*(?:schema|verify|check|test|validate)[^\n]*(?:;|&&)\s*rm\b",
|
|
re.IGNORECASE,
|
|
)
|
|
PIPE_OPERATOR_RE = re.compile(r"(?:^|\s)\|(?!\|)\s+", re.MULTILINE)
|
|
TEXT_FENCE_RE = re.compile(
|
|
r"^```text\s*\n(?P<body>.*?)^```[ \t]*$",
|
|
re.MULTILINE | re.DOTALL | re.IGNORECASE,
|
|
)
|
|
COMMAND_MODE_MARKER_RE = re.compile(
|
|
r"<!--\s*command-mode:\s*(tutorial|operator|diagnostic|automation|reference)\s*-->",
|
|
re.IGNORECASE,
|
|
)
|
|
COMMAND_MODE_MARKER_BEFORE_BLOCK_RE = re.compile(
|
|
r"<!--\s*command-mode:\s*(tutorial|operator|diagnostic|automation|reference)\s*-->\s*$",
|
|
re.IGNORECASE,
|
|
)
|
|
COMMAND_LIKE_LINE_RE = re.compile(
|
|
r"(?i)(?:\./gradlew|\b(?:docker|kubectl|git|npm|pnpm|yarn|npx|python3?|node|"
|
|
r"gradle|ssh|scp|curl|helm|virsh|systemctl|journalctl)\b)"
|
|
)
|
|
COMMAND_MODES = {"tutorial", "operator", "diagnostic", "automation", "reference"}
|
|
COMPACT_ALLOWED_MODES = {"automation", "reference"}
|
|
|
|
|
|
def sha256_text(text: str) -> str:
|
|
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def _finding(
|
|
*, block_id: str, code: str, severity: str, evidence: str, instruction: str
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"block_id": block_id,
|
|
"code": code,
|
|
"severity": severity,
|
|
"evidence": evidence[:240],
|
|
"instruction": instruction,
|
|
}
|
|
|
|
|
|
def _block_findings(block_id: str, body: str, mode: str) -> list[dict[str, Any]]:
|
|
findings: list[dict[str, Any]] = []
|
|
compact_allowed = mode in COMPACT_ALLOWED_MODES
|
|
if not compact_allowed and re.search(
|
|
r"(?m)^\s*sed\s+(?:-[A-Za-z]*i[A-Za-z]*|--in-place(?:=[^\s]+)?)(?:\s|$)",
|
|
body,
|
|
):
|
|
findings.append(
|
|
_finding(
|
|
block_id=block_id,
|
|
code="in-place-text-rewrite",
|
|
severity="minor",
|
|
evidence="sed -i",
|
|
instruction=(
|
|
"튜토리얼/운영 흐름에서는 명시적 편집 단계를 우선하고, 자동화 경계라면 "
|
|
"in-place rewrite가 필요한 이유를 설명한다."
|
|
),
|
|
)
|
|
)
|
|
if not compact_allowed and "$(" in body:
|
|
findings.append(
|
|
_finding(
|
|
block_id=block_id,
|
|
code="command-substitution",
|
|
severity="minor",
|
|
evidence="$(...)",
|
|
instruction=(
|
|
"값을 먼저 확인하거나 명시적으로 입력할 수 있으면 command substitution을 "
|
|
"첫 학습 경로로 두지 않는다."
|
|
),
|
|
)
|
|
)
|
|
if not compact_allowed and re.search(r"\$\([^)]*\$\(", body, re.DOTALL):
|
|
findings.append(
|
|
_finding(
|
|
block_id=block_id,
|
|
code="nested-command-substitution",
|
|
severity="minor",
|
|
evidence="$(...$(...)...)",
|
|
instruction=(
|
|
"중첩된 command substitution은 값을 단계별로 확인하거나 별도 스크립트로 분리한다."
|
|
),
|
|
)
|
|
)
|
|
raw_ssh = RAW_SSH_IP_RE.search(body)
|
|
if raw_ssh:
|
|
findings.append(
|
|
_finding(
|
|
block_id=block_id,
|
|
code="raw-ssh-ip",
|
|
severity="minor",
|
|
evidence=raw_ssh.group(0).strip(),
|
|
instruction=(
|
|
"같은 관리 호스트를 반복해서 쓴다면 SSH alias를 먼저 정의하고 주 흐름에서는 "
|
|
"alias를 사용한다."
|
|
),
|
|
)
|
|
)
|
|
compound = REMOTE_COMPOUND_RE.search(body)
|
|
if compound:
|
|
findings.append(
|
|
_finding(
|
|
block_id=block_id,
|
|
code="compound-remote-shell",
|
|
severity="minor",
|
|
evidence=compound.group(0).strip(),
|
|
instruction=(
|
|
"복합 원격 셸은 검토 신호다. 읽기 전용 확인이나 검증 성공 뒤 변경하는 "
|
|
"안전 조건이면 유지할 수 있고, 서로 다른 효과를 숨기면 관찰 가능한 단계로 분리한다."
|
|
),
|
|
)
|
|
)
|
|
cleanup = CLEANUP_CHAIN_RE.search(body)
|
|
if cleanup:
|
|
findings.append(
|
|
_finding(
|
|
block_id=block_id,
|
|
code="cleanup-chained-with-verification",
|
|
severity="minor" if mode == "reference" else "major",
|
|
evidence=cleanup.group(0).strip(),
|
|
instruction=(
|
|
"reference block이면 위험한 historical form을 그대로 보존하되 따라 하는 절차와 "
|
|
"분리해 설명한다. 실행 절차라면 검증과 파괴적 정리를 별도 명령으로 분리해 "
|
|
"삭제 전에 실패를 관찰할 수 있게 한다."
|
|
),
|
|
)
|
|
)
|
|
if not compact_allowed and re.search(r"(?m)^\s*printf\b[^\n]*>\s*[^\s]+", body):
|
|
findings.append(
|
|
_finding(
|
|
block_id=block_id,
|
|
code="opaque-file-generation",
|
|
severity="minor",
|
|
evidence="printf ... > file",
|
|
instruction=(
|
|
"작은 설정/학습 파일은 생성 자동화보다 파일 내용을 먼저 직접 보여준다."
|
|
),
|
|
)
|
|
)
|
|
if re.search(r"(?:2>|&>)\s*/dev/null|2>&1", body):
|
|
findings.append(
|
|
_finding(
|
|
block_id=block_id,
|
|
code="hidden-stderr",
|
|
severity="minor",
|
|
evidence="stderr redirection",
|
|
instruction=(
|
|
"주 진단 경로에서 stderr를 숨기지 말고, 원래 출력을 본 뒤 필요한 경우에만 "
|
|
"필터링한다."
|
|
),
|
|
)
|
|
)
|
|
if not compact_allowed and len(PIPE_OPERATOR_RE.findall(body)) >= 2:
|
|
findings.append(
|
|
_finding(
|
|
block_id=block_id,
|
|
code="compressed-pipeline",
|
|
severity="minor",
|
|
evidence="multi-stage shell pipeline",
|
|
instruction=(
|
|
"중간 출력이 학습에 필요하면 파이프라인을 나누거나 각 단계를 설명한 뒤 "
|
|
"compact form을 제시한다."
|
|
),
|
|
)
|
|
)
|
|
|
|
if not compact_allowed:
|
|
cd_match = re.search(r"(?m)^\s*cd\s+\S+.*$", body)
|
|
if cd_match and re.search(r"(?m)^\s*#\s*\S.*$", body[cd_match.end():]):
|
|
findings.append(
|
|
_finding(
|
|
block_id=block_id,
|
|
code="cwd-transition-crosses-command-group",
|
|
severity="minor",
|
|
evidence=cd_match.group(0).strip(),
|
|
instruction=(
|
|
"cwd를 바꾼 뒤 다른 명령 그룹이 이어지면 실행 위치가 숨는다. "
|
|
"그룹을 나누거나 이후 명령의 cwd를 다시 명시한다."
|
|
),
|
|
)
|
|
)
|
|
return findings
|
|
|
|
|
|
def _explicit_block_mode(text: str, start: int) -> str | None:
|
|
"""블록과 공백만 사이에 둔 command-mode marker만 해당 블록에 결속한다."""
|
|
nearby = text[max(0, start - 320):start]
|
|
marker = COMMAND_MODE_MARKER_BEFORE_BLOCK_RE.search(nearby)
|
|
return marker.group(1).lower() if marker else None
|
|
|
|
|
|
def _block_mode(text: str, start: int, default_mode: str) -> str:
|
|
"""명시 mode가 있으면 쓰고, 없으면 문서 기본 mode를 쓴다."""
|
|
return _explicit_block_mode(text, start) or default_mode
|
|
|
|
|
|
def _label_mode(label: str | None) -> str | None:
|
|
"""Studio가 허용하는 단일 label 안의 [reference] 같은 mode marker를 읽는다."""
|
|
if not label:
|
|
return None
|
|
match = LABEL_MODE_RE.search(label)
|
|
return match.group(1).lower() if match else None
|
|
|
|
|
|
def _fence_label(info: str) -> str | None:
|
|
"""Markdown info string의 label 값을 반환한다.
|
|
|
|
첫 토큰(language) 뒤의 metadata는 Markdown renderer용 정보이므로 command body와
|
|
섞지 않는다. 다만 `[lab host]`처럼 실행 위치를 적은 label은 사람이 보는 문맥과
|
|
동일하게 execution-context 판정에 사용할 수 있다.
|
|
"""
|
|
match = FENCE_LABEL_RE.search(info)
|
|
if not match:
|
|
return None
|
|
value = (
|
|
match.group("double")
|
|
if match.group("double") is not None
|
|
else match.group("single")
|
|
)
|
|
value = value.strip()
|
|
return value or None
|
|
|
|
|
|
def _command_like_text_blocks(text: str) -> list[dict[str, Any]]:
|
|
"""실행 흐름처럼 보이지만 text fence로 적힌 블록을 낮은 신뢰도의 신호로 남긴다."""
|
|
out: list[dict[str, Any]] = []
|
|
for ordinal, match in enumerate(TEXT_FENCE_RE.finditer(text), start=1):
|
|
body = match.group("body")
|
|
explicit_mode = _explicit_block_mode(text, match.start())
|
|
if explicit_mode in {"reference", "automation"}:
|
|
continue
|
|
command_hits = COMMAND_LIKE_LINE_RE.findall(body)
|
|
arrow_hits = len(re.findall(r"(?:→|->)", body))
|
|
if len(command_hits) < 2 and not (command_hits and arrow_hits >= 2):
|
|
continue
|
|
source = match.group(0)
|
|
out.append(
|
|
{
|
|
"id": f"text-command-like-{ordinal:03d}",
|
|
"start": match.start(),
|
|
"end": match.end(),
|
|
"source_sha256": sha256_text(source),
|
|
"evidence": body[:240],
|
|
"instruction": (
|
|
"개념 흐름도라면 text fence로 둬도 된다. 복사해 실행할 절차라면 shell fence와 "
|
|
"실행 위치·전제조건·검증 결과를 명시한다."
|
|
),
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
def analyze_commands(
|
|
section_id: str, text: str, *, mode: str = "operator"
|
|
) -> dict[str, Any]:
|
|
"""Markdown의 shell fence를 찾아 command-pedagogy 신호를 반환한다.
|
|
|
|
문서 기본 mode와 별개로 블록 직전의 command-mode marker가 있으면 그 블록만
|
|
해당 mode로 분석한다.
|
|
"""
|
|
if mode not in COMMAND_MODES:
|
|
raise ValueError(f"unsupported command pedagogy mode: {mode}")
|
|
blocks: list[dict[str, Any]] = []
|
|
findings: list[dict[str, Any]] = []
|
|
raw_hosts: dict[str, list[str]] = {}
|
|
for ordinal, match in enumerate(SHELL_FENCE_RE.finditer(text), start=1):
|
|
source = match.group(0)
|
|
body = match.group("body")
|
|
info = match.group("info") or ""
|
|
label = _fence_label(info)
|
|
block_id = f"command-{ordinal:03d}"
|
|
explicit_mode = _label_mode(label) or _explicit_block_mode(text, match.start())
|
|
block_mode = explicit_mode or mode
|
|
blocks.append(
|
|
{
|
|
"id": block_id,
|
|
"ordinal": ordinal,
|
|
"language": match.group("language").lower(),
|
|
"info_string": info.strip(),
|
|
"label": label,
|
|
"mode": block_mode,
|
|
"mode_explicit": explicit_mode is not None,
|
|
"start": match.start(),
|
|
"end": match.end(),
|
|
"source_sha256": sha256_text(source),
|
|
"body_sha256": sha256_text(body),
|
|
"source": source,
|
|
"body": body,
|
|
}
|
|
)
|
|
findings.extend(_block_findings(block_id, body, block_mode))
|
|
|
|
if REMOTE_COMMAND_RE.search(body):
|
|
prose_before = text[max(0, match.start() - 320) : match.start()]
|
|
label_has_context = bool(
|
|
label and LABEL_EXECUTION_CONTEXT_RE.search(label)
|
|
)
|
|
if not EXECUTION_CONTEXT_RE.search(prose_before) and not label_has_context:
|
|
findings.append(
|
|
_finding(
|
|
block_id=block_id,
|
|
code="execution-context-implicit",
|
|
severity="minor",
|
|
evidence=REMOTE_COMMAND_RE.search(body).group(0).strip(),
|
|
instruction=(
|
|
"원격 명령 앞에서 이 명령을 어느 host/session에서 실행하는지 명시한다."
|
|
),
|
|
)
|
|
)
|
|
|
|
for remote in RAW_SSH_IP_RE.finditer(body):
|
|
raw_hosts.setdefault(remote.group("ip"), []).append(block_id)
|
|
|
|
for host, block_ids in sorted(raw_hosts.items()):
|
|
if len(block_ids) < 2:
|
|
continue
|
|
findings.append(
|
|
_finding(
|
|
block_id=block_ids[0],
|
|
code="repeated-raw-ssh-host",
|
|
severity="minor",
|
|
evidence=f"{host} repeated {len(block_ids)} times",
|
|
instruction=(
|
|
"같은 관리 호스트를 반복하면 SSH alias를 사전 설정으로 둘 수 있는지 검토한다."
|
|
),
|
|
)
|
|
)
|
|
|
|
return {
|
|
"schema_version": "1.0",
|
|
"authority": "deterministic",
|
|
"gate": "command-pedagogy-signals",
|
|
"section_id": section_id,
|
|
"mode": mode,
|
|
"source_sha256": sha256_text(text),
|
|
"result": "WARN" if findings else "PASS",
|
|
"requires_editor": bool(findings),
|
|
"blocks": blocks,
|
|
"findings": findings,
|
|
"extensions": {
|
|
"command_like_text_blocks": _command_like_text_blocks(text),
|
|
},
|
|
}
|
|
|
|
|
|
def _require_non_empty_string(value: Any, field: str) -> str:
|
|
if not isinstance(value, str) or not value.strip():
|
|
raise ValueError(f"{field} must be a non-empty string")
|
|
return value
|
|
|
|
|
|
def _require_sha256(value: Any, field: str) -> str:
|
|
value = _require_non_empty_string(value, field)
|
|
if not re.fullmatch(r"[0-9a-f]{64}", value):
|
|
raise ValueError(f"{field} must be a lowercase sha256")
|
|
return value
|
|
|
|
|
|
def validate_command_plan(
|
|
plan: dict[str, Any], *, analysis: dict[str, Any] | None = None
|
|
) -> None:
|
|
"""CommandPlan의 구조와 frozen analysis 결속을 검증한다."""
|
|
if not isinstance(plan, dict):
|
|
raise ValueError("CommandPlan must be an object")
|
|
if plan.get("schema_version") != "1.0":
|
|
raise ValueError("CommandPlan schema_version must be 1.0")
|
|
_require_non_empty_string(plan.get("section_id"), "CommandPlan section_id")
|
|
source_sha = _require_sha256(plan.get("source_sha256"), "CommandPlan source_sha256")
|
|
mode = plan.get("mode")
|
|
if mode not in COMMAND_MODES:
|
|
raise ValueError(f"CommandPlan mode is unsupported: {mode!r}")
|
|
groups = plan.get("command_groups")
|
|
if not isinstance(groups, list) or not groups:
|
|
raise ValueError("CommandPlan command_groups must be a non-empty list")
|
|
|
|
known_blocks: dict[str, dict[str, Any]] = {}
|
|
if analysis is not None:
|
|
if analysis.get("source_sha256") != source_sha:
|
|
raise ValueError("CommandPlan source_sha256 does not match analysis")
|
|
known_blocks = {
|
|
str(block.get("id")): block
|
|
for block in analysis.get("blocks", [])
|
|
if isinstance(block, dict)
|
|
}
|
|
|
|
seen_ids: set[str] = set()
|
|
seen_blocks: set[str] = set()
|
|
for group in groups:
|
|
if not isinstance(group, dict):
|
|
raise ValueError("CommandPlan command group must be an object")
|
|
group_id = _require_non_empty_string(group.get("id"), "CommandPlan command group id")
|
|
if group_id in seen_ids:
|
|
raise ValueError("CommandPlan contains duplicate command group id")
|
|
seen_ids.add(group_id)
|
|
block_id = _require_non_empty_string(group.get("block_id"), "CommandPlan block_id")
|
|
if block_id in seen_blocks:
|
|
raise ValueError("CommandPlan contains duplicate block_id")
|
|
seen_blocks.add(block_id)
|
|
block_sha = _require_sha256(group.get("source_sha256"), "CommandPlan group source_sha256")
|
|
if known_blocks:
|
|
block = known_blocks.get(block_id)
|
|
if block is None:
|
|
raise ValueError("CommandPlan references an unknown block")
|
|
if block.get("source_sha256") != block_sha:
|
|
raise ValueError("CommandPlan block source_sha256 does not match analysis")
|
|
_require_non_empty_string(group.get("goal"), "CommandPlan goal")
|
|
context = group.get("execution_context")
|
|
if not isinstance(context, dict):
|
|
raise ValueError("CommandPlan execution_context must be an object")
|
|
_require_non_empty_string(context.get("host"), "CommandPlan execution_context.host")
|
|
_require_non_empty_string(context.get("cwd"), "CommandPlan execution_context.cwd")
|
|
prerequisites = group.get("prerequisites")
|
|
if not isinstance(prerequisites, list) or not all(
|
|
isinstance(item, str) and item.strip() for item in prerequisites
|
|
):
|
|
raise ValueError("CommandPlan prerequisites must be a string list")
|
|
steps = group.get("steps")
|
|
if not isinstance(steps, list) or not steps:
|
|
raise ValueError("CommandPlan steps must be a non-empty list")
|
|
for step in steps:
|
|
if not isinstance(step, dict):
|
|
raise ValueError("CommandPlan step must be an object")
|
|
_require_non_empty_string(step.get("command"), "CommandPlan step.command")
|
|
_require_non_empty_string(step.get("reason"), "CommandPlan step.reason")
|
|
_require_non_empty_string(
|
|
step.get("expected_result"), "CommandPlan step.expected_result"
|
|
)
|
|
cleanup = group.get("cleanup")
|
|
if not isinstance(cleanup, list):
|
|
raise ValueError("CommandPlan cleanup must be a list")
|
|
for step in cleanup:
|
|
if not isinstance(step, dict):
|
|
raise ValueError("CommandPlan cleanup step must be an object")
|
|
_require_non_empty_string(step.get("command"), "CommandPlan cleanup.command")
|
|
_require_non_empty_string(step.get("reason"), "CommandPlan cleanup.reason")
|
|
|
|
|
|
def validate_command_patch_set(
|
|
patch_set: dict[str, Any], *, analysis: dict[str, Any] | None = None
|
|
) -> None:
|
|
"""CommandPatchSet의 구조와 frozen analysis 결속을 검증한다."""
|
|
if not isinstance(patch_set, dict):
|
|
raise ValueError("CommandPatchSet must be an object")
|
|
if patch_set.get("schema_version") != "1.0":
|
|
raise ValueError("CommandPatchSet schema_version must be 1.0")
|
|
source_sha = _require_sha256(
|
|
patch_set.get("source_sha256"), "CommandPatchSet source_sha256"
|
|
)
|
|
patches = patch_set.get("patches")
|
|
if not isinstance(patches, list):
|
|
raise ValueError("CommandPatchSet patches must be a list")
|
|
|
|
known_blocks: dict[str, dict[str, Any]] = {}
|
|
if analysis is not None:
|
|
if analysis.get("source_sha256") != source_sha:
|
|
raise ValueError("CommandPatchSet source_sha256 does not match analysis")
|
|
known_blocks = {
|
|
str(block.get("id")): block
|
|
for block in analysis.get("blocks", [])
|
|
if isinstance(block, dict)
|
|
}
|
|
|
|
seen: set[str] = set()
|
|
for patch in patches:
|
|
if not isinstance(patch, dict):
|
|
raise ValueError("CommandPatchSet patch must be an object")
|
|
block_id = _require_non_empty_string(patch.get("block_id"), "CommandPatchSet block_id")
|
|
if block_id in seen:
|
|
raise ValueError("CommandPatchSet contains duplicate block ids")
|
|
seen.add(block_id)
|
|
expected = _require_sha256(
|
|
patch.get("expected_sha256"), "CommandPatchSet expected_sha256"
|
|
)
|
|
if known_blocks:
|
|
block = known_blocks.get(block_id)
|
|
if block is None:
|
|
raise ValueError("CommandPatchSet references an unknown block")
|
|
if block.get("source_sha256") != expected:
|
|
raise ValueError("CommandPatchSet expected block hash is stale")
|
|
_require_non_empty_string(
|
|
patch.get("replacement_markdown"), "CommandPatchSet replacement_markdown"
|
|
)
|
|
|
|
|
|
def apply_command_patch_set(
|
|
*, text: str, analysis: dict[str, Any], patch_set: dict[str, Any]
|
|
) -> str:
|
|
"""분석 당시의 shell fence span 안에서만 replacement를 적용한다.
|
|
|
|
editor가 Markdown 전체를 직접 다시 쓰지 않게 하는 경계다. patch는 분석에 존재하는
|
|
block_id와 그때의 source hash를 함께 제시해야 한다.
|
|
"""
|
|
validate_command_patch_set(patch_set, analysis=analysis)
|
|
source_sha256 = sha256_text(text)
|
|
if analysis.get("source_sha256") != source_sha256:
|
|
raise ValueError("command analysis source hash is stale")
|
|
if patch_set.get("source_sha256") != source_sha256:
|
|
raise ValueError("CommandPatchSet source hash is stale")
|
|
|
|
blocks = {item["id"]: item for item in analysis.get("blocks", [])}
|
|
patches = patch_set.get("patches")
|
|
if not isinstance(patches, list):
|
|
raise ValueError("CommandPatchSet patches must be a list")
|
|
|
|
seen: set[str] = set()
|
|
replacements: list[tuple[int, int, str]] = []
|
|
for patch in patches:
|
|
if not isinstance(patch, dict):
|
|
raise ValueError("CommandPatchSet patch must be an object")
|
|
block_id = str(patch.get("block_id") or "")
|
|
if block_id in seen:
|
|
raise ValueError("CommandPatchSet contains duplicate block ids")
|
|
seen.add(block_id)
|
|
block = blocks.get(block_id)
|
|
if block is None:
|
|
raise ValueError("CommandPatchSet references an unknown block")
|
|
|
|
expected = str(patch.get("expected_sha256") or "")
|
|
if expected != block["source_sha256"]:
|
|
raise ValueError("CommandPatchSet expected block hash is stale")
|
|
source = text[block["start"] : block["end"]]
|
|
if sha256_text(source) != block["source_sha256"]:
|
|
raise ValueError("command block span is stale")
|
|
replacement = patch.get("replacement_markdown")
|
|
if not isinstance(replacement, str) or not replacement:
|
|
raise ValueError("CommandPatchSet replacement_markdown must be non-empty")
|
|
replacements.append((block["start"], block["end"], replacement))
|
|
|
|
repaired = text
|
|
for start, end, replacement in sorted(replacements, reverse=True):
|
|
repaired = repaired[:start] + replacement + repaired[end:]
|
|
return repaired
|