Skip to content

Commit 8bc5172

Browse files
DanMeonclaude
andcommitted
fix(lint+scripts): code-reviewer 7개 이슈 일괄 fix + .gitignore
검증 에이전트 보고 7건 모두 재검증 후 CONFIRMED → 적용. scripts/_doc_lint.py: - Major: validate_broken_link / validate_cross_link 의 code-fence false positive — _strip_code() helper 추가 (```...``` + 인라인 백틱 제거). 본 fix 없이는 향후 spec 의 예시 link 가 violation 으로 오인식 - Minor: parse_frontmatter 가 trailing inline comment ('foo: bar # x') 를 value 로 흡수 → quote 시작 아닌 경우 ' #' 로 split - Minor: _validate_supersede_chain 의 path depth 가정 (vX.Y.Z 2-level 하드코딩) → _supersede_base() 도입, meta-level 1-level 평면도 지원 scripts/generate_spec_trace.py: - Minor: ast.walk 가 class 컨텍스트 평탄화 → class TestFoo: def test_bar 의 nodeid 가 ::test_bar 만 출력. _SpecMarkerVisitor (NodeVisitor + class_stack) 로 정확히 ::TestFoo::test_bar 출력 scripts/update_upstream_pin.py: - Minor: _previous_pin 의 SemVer 매치 실패 시 (0,0,0) silent fallback (전역 fail-fast 정책 위반) → ValueError raise .github/workflows/docs.yml: - Minor: paths 의 '**.md' 가 lint 무관 .md (README/CHANGELOG/PR template 등) 변경에도 fire — 'docs/**' 가 이미 docs 하위 .md 포괄하므로 제거 .gitignore: - .claude/scheduled_tasks.lock 추가 — ScheduleWakeup hook 의 PID/세션ID lock 파일 (런타임 머신-로컬, repo 추적 무의미) 검증: 합성 케이스 4종 (fence false positive / class method / 잘못된 pin key / meta-level supersede) 모두 expected behavior. 기존 lint 0 violation 유지. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8df7be7 commit 8bc5172

5 files changed

Lines changed: 85 additions & 24 deletions

File tree

.github/workflows/docs.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ name: Docs lint
44
on:
55
push:
66
branches: [main]
7+
# ^ 'docs/**' 가 docs 안 모든 .md 포괄. README/CHANGELOG 등 lint 무관 .md
8+
# 변경에 fire 안 하려고 '**.md' 제외. tests/** 는 spec-trace 검증 때문에 유지.
79
paths:
8-
- '**.md'
910
- 'docs/**'
1011
- 'tests/**'
1112
- 'scripts/_doc_lint.py'
@@ -16,7 +17,6 @@ on:
1617
pull_request:
1718
branches: [main]
1819
paths:
19-
- '**.md'
2020
- 'docs/**'
2121
- 'tests/**'
2222
- 'scripts/_doc_lint.py'

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ coverage.xml
4040

4141
# * Claude Code — settings.json 은 팀 공유 (hooks 등록), local override 만 ignore
4242
.claude/settings.local.json
43+
# ^ ScheduleWakeup / 백그라운드 task lock (런타임 PID/세션ID, 머신-로컬)
44+
.claude/scheduled_tasks.lock
4345

4446
# * Examples 산출물
4547
render_output/

scripts/_doc_lint.py

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,27 @@ def parse_frontmatter(text: str) -> dict[str, str] | None:
6666
continue
6767
k, _, v = line.partition(":")
6868
v = v.strip()
69+
# ^ trailing inline comment ('foo: bar # note') strip — quoted value 안의
70+
# '#' 는 보호 (flat key:value 가정상 quote 처리 후 안전)
71+
if not (v.startswith(("'", '"'))):
72+
v = v.split(" #", 1)[0].rstrip()
6973
# ^ 양쪽 동일 quote 만 unwrap (mismatched 는 그대로)
7074
if len(v) >= 2 and v[0] == v[-1] and v[0] in ("'", '"'):
7175
v = v[1:-1]
7276
meta[k.strip()] = v
7377
return meta
7478

7579

80+
# * code fence stripper — fence 안 예시 link / 백틱 인라인 link 를 lint 대상에서 배제
81+
def _strip_code(text: str) -> str:
82+
"""Remove ```...``` 블록 + 인라인 `...` 백틱. lint regex 는 raw text 가
83+
아니라 본 출력에 적용 — fence 안 예시 link 가 broken/cross-link 위반으로
84+
오인식되는 false positive 방지."""
85+
text = re.sub(r"```.*?```", "", text, flags=re.DOTALL)
86+
text = re.sub(r"`[^`\n]+`", "", text)
87+
return text
88+
89+
7690
# * Rule 1+2: frontmatter schema + supersede chain
7791
def validate_frontmatter(rel_str: str, meta: dict[str, str], repo: Path) -> list[str]:
7892
errors: list[str] = []
@@ -138,8 +152,12 @@ def _validate_supersede_chain(rel_str: str, meta: dict[str, str], repo: Path) ->
138152
rel = Path(rel_str)
139153

140154
superseded_by = meta.get("superseded_by")
155+
# ^ supersede 경로 base: vX.Y.Z 하위면 docs/<kind>/, meta-level 평면이면 docs/<kind>/
156+
# format: vX.Y.Z 파일은 '<vX.Y.Z>/<topic>.md', meta-level 은 '<topic>.md'.
157+
base, expected = _supersede_base(rel)
158+
141159
if superseded_by:
142-
target_rel = rel.parent.parent / superseded_by
160+
target_rel = base / superseded_by
143161
target = repo / target_rel
144162
if not target.exists():
145163
errors.append(
@@ -151,18 +169,16 @@ def _validate_supersede_chain(rel_str: str, meta: dict[str, str], repo: Path) ->
151169
errors.append(
152170
f"frontmatter: superseded_by target {superseded_by!r} lacks frontmatter"
153171
)
154-
else:
155-
expected = str(rel.relative_to(rel.parent.parent))
156-
if target_meta.get("supersedes") != expected:
157-
errors.append(
158-
f"frontmatter: supersede chain broken — target's "
159-
f"'supersedes' is {target_meta.get('supersedes')!r}, "
160-
f"expected {expected!r}"
161-
)
172+
elif target_meta.get("supersedes") != expected:
173+
errors.append(
174+
f"frontmatter: supersede chain broken — target's "
175+
f"'supersedes' is {target_meta.get('supersedes')!r}, "
176+
f"expected {expected!r}"
177+
)
162178

163179
supersedes = meta.get("supersedes")
164180
if supersedes:
165-
target_rel = rel.parent.parent / supersedes
181+
target_rel = base / supersedes
166182
if not (repo / target_rel).exists():
167183
errors.append(
168184
f"frontmatter: supersedes {supersedes!r} not found (resolved: {target_rel})"
@@ -171,6 +187,19 @@ def _validate_supersede_chain(rel_str: str, meta: dict[str, str], repo: Path) ->
171187
return errors
172188

173189

190+
def _supersede_base(rel: Path) -> tuple[Path, str]:
191+
"""supersede chain 의 base 디렉토리 + 본 파일의 expected 역참조 ID.
192+
193+
vX.Y.Z 파일 (`docs/<kind>/<vX.Y.Z>/<file>.md`) → base=`docs/<kind>/`,
194+
expected=`<vX.Y.Z>/<file>.md`.
195+
Meta-level 평면 (`docs/<kind>/<file>.md`) → base=`docs/<kind>/`,
196+
expected=`<file>.md`.
197+
"""
198+
if re.fullmatch(r"v\d+\.\d+\.\d+", rel.parent.name):
199+
return rel.parent.parent, str(rel.relative_to(rel.parent.parent))
200+
return rel.parent, rel.name
201+
202+
174203
# * Rule 3+4: filename kebab-case + vX.Y.Z directory SemVer
175204
def validate_filename(rel_str: str) -> list[str]:
176205
errors: list[str] = []
@@ -242,7 +271,7 @@ def validate_cross_link(rel_str: str, text: str) -> list[str]:
242271
self_link = f"{base}.md"
243272

244273
errors: list[str] = []
245-
for link in re.findall(r"\]\(([^)]+\.md)[^)]*\)", text):
274+
for link in re.findall(r"\]\(([^)]+\.md)[^)]*\)", _strip_code(text)):
246275
link_target = link.split("#")[0]
247276
if "/" in link_target:
248277
continue
@@ -262,7 +291,7 @@ def validate_cross_link(rel_str: str, text: str) -> list[str]:
262291
def validate_broken_link(rel_str: str, text: str, repo: Path) -> list[str]:
263292
target_dir = (repo / rel_str).parent
264293
errors: list[str] = []
265-
for link in re.findall(r"\]\(([^)]+\.md)[^)]*\)", text):
294+
for link in re.findall(r"\]\(([^)]+\.md)[^)]*\)", _strip_code(text)):
266295
link_target = link.split("#")[0].split("?")[0]
267296
if not link_target or link_target.startswith("http"):
268297
continue

scripts/generate_spec_trace.py

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ def main(
5656

5757

5858
def _collect_spec_markers(tests_dir: Path) -> dict[str, list[str]]:
59-
"""spec_id → list of pytest nodeids."""
59+
"""spec_id → list of pytest nodeids. ``class TestFoo`` 안의 메서드도 정확히
60+
`tests/x.py::TestFoo::test_bar` 형식으로 출력 (ast.walk 평탄화 회피)."""
6061
mapping: dict[str, list[str]] = defaultdict(list)
6162
if not tests_dir.is_dir():
6263
return mapping
@@ -67,17 +68,44 @@ def _collect_spec_markers(tests_dir: Path) -> dict[str, list[str]]:
6768
except SyntaxError:
6869
continue
6970
rel = py_file.relative_to(REPO)
70-
for node in ast.walk(tree):
71-
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
72-
if not node.name.startswith("test_"):
73-
continue
74-
for decorator in node.decorator_list:
75-
spec_id = _extract_spec_id(decorator)
76-
if spec_id and SPEC_ID_RE.match(spec_id):
77-
mapping[spec_id].append(f"{rel}::{node.name}")
71+
visitor = _SpecMarkerVisitor(rel)
72+
visitor.visit(tree)
73+
for spec_id, nodeids in visitor.mapping.items():
74+
mapping[spec_id].extend(nodeids)
7875
return mapping
7976

8077

78+
class _SpecMarkerVisitor(ast.NodeVisitor):
79+
"""class 컨텍스트 stack 을 유지하며 @pytest.mark.spec(...) 추출."""
80+
81+
def __init__(self, file_rel: Path) -> None:
82+
self.file_rel = file_rel
83+
self.class_stack: list[str] = []
84+
self.mapping: dict[str, list[str]] = defaultdict(list)
85+
86+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
87+
self.class_stack.append(node.name)
88+
self.generic_visit(node)
89+
self.class_stack.pop()
90+
91+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
92+
self._maybe_add(node)
93+
self.generic_visit(node)
94+
95+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
96+
self._maybe_add(node)
97+
self.generic_visit(node)
98+
99+
def _maybe_add(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
100+
if not node.name.startswith("test_"):
101+
return
102+
for decorator in node.decorator_list:
103+
spec_id = _extract_spec_id(decorator)
104+
if spec_id and SPEC_ID_RE.match(spec_id):
105+
parts = [*self.class_stack, node.name]
106+
self.mapping[spec_id].append(f"{self.file_rel}::{'::'.join(parts)}")
107+
108+
81109
def _extract_spec_id(node: ast.AST) -> str | None:
82110
"""``@pytest.mark.spec("vX.Y.Z/...")`` 또는 ``@mark.spec("...")`` 매칭."""
83111
if not isinstance(node, ast.Call):

scripts/update_upstream_pin.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,9 @@ def _previous_pin(pins: dict, current_version: str) -> dict | None:
7474

7575
def key(v: str) -> tuple[int, int, int]:
7676
m = re.match(r"v(\d+)\.(\d+)\.(\d+)", v)
77-
return (int(m.group(1)), int(m.group(2)), int(m.group(3))) if m else (0, 0, 0)
77+
if not m:
78+
raise ValueError(f"malformed pin key {v!r} — expected vX.Y.Z")
79+
return (int(m.group(1)), int(m.group(2)), int(m.group(3)))
7880

7981
target = key(current_version)
8082
candidates = sorted(

0 commit comments

Comments
 (0)