diff --git a/scripts/check-core-support.py b/scripts/check-core-support.py index db81ece..519d1a6 100644 --- a/scripts/check-core-support.py +++ b/scripts/check-core-support.py @@ -114,9 +114,30 @@ def verify(project: str) -> tuple[techlog.Report, str | None]: def main() -> int: ap = argparse.ArgumentParser(description="핵심이 측정을 주장하는데 근거가 있는지 본다.") ap.add_argument("projects", nargs="*") + ap.add_argument("--file", action="append", default=[], + help="프로젝트 대신 기록 .md 를 직접 준다") ap.add_argument("--samples", type=int, default=3) args = ap.parse_args() + if args.file: + rep = techlog.Report("파일") + rep.facts["기록"] = len(args.file) + for f in args.file: + if not os.path.isfile(f): + print(f"대상이 성립하지 않는다 — 그런 파일이 없다: {f}", file=sys.stderr) + return 2 + check_record(os.path.abspath(f), rep) + reports = [rep] + for r in reports: + print(f"\n[{r.project}] " + " · ".join(f"{k}={v}" for k, v in r.facts.items())) + for rule, details in sorted(r.errors.items(), key=lambda kv: -len(kv[1])): + print(f" ✗ error {len(details):>4} {rule}") + for d in details[:args.samples]: + print(f" · {d}") + e = sum(r.error_count for r in reports) + print(f"\nCORE SUPPORT: {'FAIL' if e else 'PASS'} — 기록 {len(args.file)} · error {e}") + return 1 if e else 0 + projects = args.projects or sorted( os.path.basename(os.path.dirname(p)) for p in glob.glob(os.path.join(ROOT, "docs/*/tech-log-studio")) diff --git a/scripts/check-preservation.py b/scripts/check-preservation.py index 4c4c3e9..ca72429 100644 --- a/scripts/check-preservation.py +++ b/scripts/check-preservation.py @@ -112,6 +112,29 @@ SCOPE_MARKS = ( "모든 경우", "전부 그렇다", "예외 없이", "무조건", "반드시", "절대", ) +def _core_measurement(text: str): + """`문제`·`결론` 칸이 측정을 주장하나. (성능 명사, 비교어) 또는 None. + + `check-core-support.py` 의 판정을 그대로 쓴다 — 목록을 두 곳에 두면 갈린다. + """ + import importlib.util + path = os.path.join(ROOT, "scripts", "check-core-support.py") + spec = importlib.util.spec_from_file_location("check_core_support", path) + if spec is None or spec.loader is None: + return None + m = importlib.util.module_from_spec(spec) + spec.loader.exec_module(m) + secs = SECTION_BODY.findall(text) + core = " ".join(body for name, body in secs if name.strip() in ("문제", "결론")) + if not core.strip(): + return None + perf, cmpw = m.PERF.search(core), m.COMPARE.search(core) + return (perf.group(0), cmpw.group(0)) if (perf and cmpw) else None + + +# `## 이름` 의 이름과 그 아래 내용. 편집 전후의 핵심 칸을 견주는 데 쓴다 +SECTION_BODY = re.compile(r"^##[ \t]+(.+?)[ \t]*$\n(.*?)(?=^##[ \t]|\Z)", re.M | re.S) + # `## 이름` 절. 통째로 사라진 절은 그 자체를 낸다 — 안에 보호 구간이 없으면 # 다른 어떤 계수도 안 움직인다. 「확인하지 못한 것」 절을 지우는 편집이 그 모양이다 SECTION = re.compile(r"^##\s+(.+?)\s*$", re.M) @@ -171,6 +194,24 @@ def compare(before: str, after: str) -> dict: "note": "절이 통째로 없어졌다. 덜어 낸 것인지 한계를 지운 것인지는 " "근거를 읽어야 안다"}) + # 편집이 **측정 주장을 새로 더했나.** 있던 것은 안 본다 — 있던 것을 보면 저장소의 + # 정상 기록 다수에 걸리고, 그것은 「모든 기록에 걸리는 경고는 어느 기록에 대해서도 + # 아무 말을 하지 않는다」로 뺀 것과 같은 모양이 된다. + # + # `check-core-support.py` 는 「근거 목록이 **비었는데** 측정을 주장한다」를 본다. + # 근거가 **차 있는** 기록에 그 근거가 지지하지 않는 결론을 더하는 편집은 그 규칙 밖이다 + # — **출처가 있다 ≠ 그 출처가 그 주장을 지지한다.** 그 자리를 여기서 검토로 보낸다. + before_claim, after_claim = _core_measurement(before), _core_measurement(after) + if after_claim and not before_claim: + warnings.append({ + "kind": "핵심 주장", "change": "새로생김", "count": 1, + "value": f"{after_claim[0]} · {after_claim[1]}", + "sentence": _sentence_of(after, after_claim[0]), + "note": "편집이 핵심 칸에 측정 주장을 더했다. 그 기록이 대는 근거가 이 주장을 " + "지지하는지는 근거를 읽어야 안다 — 출처가 있다는 것과 그 출처가 이 " + "주장을 지지한다는 것은 다르다", + }) + # 적용 범위가 넓어졌나. 늘어난 것만 본다 — 좁히는 것은 이 규범에서 안전한 쪽이다 for mark in SCOPE_MARKS: gained = after.count(mark) - before.count(mark) diff --git a/scripts/tests/test_core_support.py b/scripts/tests/test_core_support.py index c7e21f2..bd5a9b5 100644 --- a/scripts/tests/test_core_support.py +++ b/scripts/tests/test_core_support.py @@ -127,3 +127,27 @@ class CoreSupportTest(unittest.TestCase): if __name__ == "__main__": unittest.main() + + +class FileModeTest(unittest.TestCase): + """`--file` 로 기록을 직접 준다. 다른 검사기들이 R13 에서 받은 것과 맞춘다.""" + + def _cli(self, *args): + return subprocess.run(["python3", TOOL, *args], cwd=ROOT, + capture_output=True, text=True) + + def setUp(self): + self.tmp = tempfile.mkdtemp() + + def test_a_record_given_directly_is_checked(self): + bad = _record(self.tmp, "운영 피드 응답시간의 p99 가 절반이 됐다.") + self.assertEqual(1, self._cli("--file", bad).returncode) + + def test_a_record_with_evidence_passes_in_file_mode(self): + ok = _record(self.tmp, "운영 피드 응답시간의 p99 가 절반이 됐다.", evidence=True) + self.assertEqual(0, self._cli("--file", ok).returncode) + + def test_a_missing_file_is_not_reported_as_clean(self): + p = self._cli("--file", os.path.join(self.tmp, "nope.md")) + self.assertEqual(2, p.returncode) + self.assertIn("대상이 성립하지 않는다", p.stderr) diff --git a/scripts/tests/test_preservation.py b/scripts/tests/test_preservation.py index 1860062..88c8ba3 100644 --- a/scripts/tests/test_preservation.py +++ b/scripts/tests/test_preservation.py @@ -247,3 +247,76 @@ class WidenedScopeTest(unittest.TestCase): after = self.BEFORE.replace("가져온다", "돌려준다") self.assertEqual([], [w for w in cp.compare(self.BEFORE, after)["warnings"] if w["kind"] == "적용 범위"]) + + +class AddedCoreClaimTest(unittest.TestCase): + """편집이 핵심 칸에 측정 주장을 **새로 더했나.** + + `check-core-support.py` 는 「근거 목록이 **비었는데** 측정을 주장한다」를 본다. + 근거가 **차 있는** 기록에 그 근거가 지지하지 않는 결론을 더하는 편집은 그 규칙 밖이다 — + **출처가 있다 ≠ 그 출처가 그 주장을 지지한다.** + + 있던 주장은 안 본다. 보면 저장소의 정상 기록 다수에 걸리고, 그것은 「모든 기록에 걸리는 + 경고는 어느 기록에 대해서도 아무 말을 하지 않는다」로 뺀 것과 같은 모양이 된다. + """ + + HEAD = """--- +kind: CASE +slug: x +title: x +evidence: + - ../../../final/evidence/raw/explain.txt +--- + +# x + +요약이다. + +## 문제 + +피드 조회가 느렸다. + +## 결론 + +{c} + +## 검증 환경 + +python 3.12.3 +""" + PLAIN = "실행계획에서 Nested Loop 가 사라진 것을 확인했다." + CLAIM = "운영 피드 응답시간의 p99 가 절반이 됐다." + + def _hits(self, before, after): + return [w for w in cp.compare(before, after)["warnings"] + if w["kind"] == "핵심 주장"] + + def test_an_edit_that_adds_a_measurement_claim_is_surfaced(self): + before = self.HEAD.format(c=self.PLAIN) + after = self.HEAD.format(c=f"{self.PLAIN} 그래서 {self.CLAIM}") + hits = self._hits(before, after) + self.assertEqual(1, len(hits), hits) + self.assertIn("응답시간", hits[0]["value"]) + + def test_a_claim_that_was_already_there_is_not_reported(self): + """대조군. 원래 있던 주장을 다듬기만 하면 조용해야 한다.""" + before = self.HEAD.format(c=self.CLAIM) + after = self.HEAD.format(c="운영 피드 응답시간의 p99 가 절반으로 줄었다.") + self.assertEqual([], self._hits(before, after)) + + def test_editing_outside_the_core_fields_is_not_reported(self): + """대조군. 핵심 칸을 안 건드리면 조용해야 한다.""" + before = self.HEAD.format(c=self.PLAIN) + after = before.replace("python 3.12.3", "python 3.12.4") + self.assertEqual([], self._hits(before, after)) + + def test_removing_a_claim_is_not_reported(self): + """지우는 것은 이 규범에서 안전한 쪽이다.""" + before = self.HEAD.format(c=f"{self.PLAIN} 그래서 {self.CLAIM}") + after = self.HEAD.format(c=self.PLAIN) + self.assertEqual([], self._hits(before, after)) + + def test_it_does_not_fire_on_texts_without_core_fields(self): + """`문제`·`결론` 이 없는 글에는 안 건다 — 채택 편집 100쌍이 그 모양이다.""" + self.assertEqual([], self._hits("응답시간이 느리다.", + "운영 피드 응답시간의 p99 가 절반이 됐다."))