From 67c9867e582b31a7df98d00cda9a959f869102ec Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Wed, 29 Jul 2026 18:36:17 +0900 Subject: [PATCH] feat: enforce Korean prose gate in pipeline fixtures --- .../application-core-spring-di-boundary.md | 52 ++++++------ src/claridoc/providers/mock.py | 30 +++---- src/claridoc/report.py | 19 +++++ tests/test_pipeline.py | 85 ++++++++++++++++++- 4 files changed, 143 insertions(+), 43 deletions(-) diff --git a/examples/golden/application-core-spring-di-boundary.md b/examples/golden/application-core-spring-di-boundary.md index d50f495..2ad4714 100644 --- a/examples/golden/application-core-spring-di-boundary.md +++ b/examples/golden/application-core-spring-di-boundary.md @@ -2,43 +2,43 @@ ## 코드보다 먼저 드러난 문제 -Clean Architecture를 적용하면 흔히 “코어에서 프레임워크를 제거해야 한다”는 문장부터 떠올린다. 이 원칙을 그대로 밀어붙이면 `application-core`의 use case도 Spring을 전혀 모르는 순수 Java 객체가 된다. 처음에는 경계가 가장 선명해 보인다. +Clean Architecture를 적용할 때 저는 “코어에서 프레임워크를 제거해야 한다”는 문장부터 떠올렸습니다. 이 원칙을 그대로 밀어붙이면 `application-core`의 use case도 Spring을 전혀 모르는 순수 Java 객체가 됩니다. 처음에는 이 구성이 경계를 가장 선명하게 만든다고 생각했습니다. -문제는 조립 단계에서 드러났다. use case가 늘어날 때마다 `@Configuration`에 bean 등록 코드를 추가해야 했고, 생성자 의존성이 바뀔 때마다 조립 코드도 함께 수정해야 했다. 비즈니스 흐름과 무관한 등록 코드가 반복되면서 “Spring을 제거했다”는 이점보다 조립 비용이 더 빠르게 커졌다. +그런데 조립 단계까지 따라가자 문제가 드러났습니다. use case가 늘어날 때마다 `@Configuration`에 bean 등록 코드를 추가해야 했고, 생성자 의존성이 바뀔 때마다 조립 코드도 함께 수정해야 했습니다. 비즈니스 흐름과 무관한 등록 코드가 반복되면서 “Spring을 제거했다”는 이점보다 조립 비용이 더 빠르게 커졌습니다. -ca-tmpl이 풀려던 질문은 Spring을 쓰느냐 마느냐가 아니었다. `application-core`가 맡아야 할 책임은 지키면서, use case 등록에 필요한 반복 작업을 어디까지 줄일 것인가가 핵심이었다. 이 글은 그 결정을 다룬다. 모든 Clean Architecture 프로젝트에 같은 경계를 권하는 글은 아니며, 로깅 라이브러리 선택이나 운영 성능까지 설명하지 않는다. +그래서 제가 다시 세운 질문은 Spring을 쓰느냐 마느냐가 아니었습니다. `application-core`가 맡아야 할 책임은 지키면서 use case 등록에 필요한 반복 작업을 어디까지 줄일 것인가가 핵심이었습니다. 이 글은 ca-tmpl이 그 질문에 내린 결정을 다룹니다. 모든 Clean Architecture 프로젝트에 같은 경계를 권하지 않으며, 로깅 라이브러리 선택이나 운영 성능까지 설명하지 않습니다. ## 문제를 어렵게 만든 제약 -`application-core`는 application policy를 소유한다. command와 query, inbound port와 outbound port, transaction boundary의 의도는 이 계층에 있다. 반면 HTTP, JPA, Spring MVC, 구체적인 transaction 실행 방식은 adapter나 bootstrap 쪽 책임이다. DI 편의를 허용하더라도 이 구분이 무너지면 안 됐다. +저는 먼저 `application-core`가 소유하는 책임을 확인했습니다. command와 query, inbound port와 outbound port, transaction boundary의 의도는 이 계층에 있습니다. 반면 HTTP, JPA, Spring MVC, 구체적인 transaction 실행 방식은 adapter나 bootstrap 쪽 책임입니다. DI 편의를 허용하더라도 이 구분은 무너지면 안 됐습니다. -그러나 의존성의 유무만으로 경계를 판단할 수는 없다. `spring-context`를 참조한다는 사실과 `@Transactional`로 transaction 정책을 표현한다는 사실은 같은 종류의 의존이 아니다. 전자는 객체를 컨테이너에 등록하는 조립 편의이고, 후자는 application policy를 Spring annotation으로 표현하는 설계 선택이다. 단순히 “Spring 있음/없음”으로 나누면 두 결정을 구분할 수 없다. +그러나 의존성의 유무만으로 경계를 판단할 수는 없습니다. `spring-context`를 참조한다는 사실과 `@Transactional`로 transaction 정책을 표현한다는 사실은 같은 종류의 의존이 아닙니다. 전자는 객체를 컨테이너에 등록하는 조립 편의이고, 후자는 application policy를 Spring annotation으로 표현하는 설계 선택입니다. 단순히 “Spring 있음/없음”으로 나누면 두 결정을 구분할 수 없습니다. -팀원이 규칙을 기억하는 데 의존하면 시간이 지날수록 예외가 쌓인다. 이를 막기 위해 허용과 금지의 경계는 문서에 적어 두는 데서 끝내지 않고, Gradle dependency graph와 source import graph에서 각각 위반을 검출할 수 있어야 했다. +팀원이 규칙을 기억하는 데 의존하면 시간이 지날수록 예외가 쌓입니다. 이를 막기 위해 허용과 금지의 경계는 문서에 적어 두는 데서 끝내지 않고, Gradle dependency graph와 source import graph에서 각각 위반을 검출할 수 있어야 했습니다. ## 검토한 선택지와 막힌 지점 -가장 엄격한 선택은 `application-core`에서 Spring을 완전히 제거하는 것이다. use case는 순수 Java class로 두고 bootstrap module의 `@Configuration`에서 모두 수동 등록한다. framework 의존 경계는 가장 단순해지지만, use case 수와 생성자 의존성이 늘수록 조립 코드가 함께 증가한다. 프로젝트는 이 반복 비용을 실제 문제로 보았다. +제가 검토한 가장 엄격한 선택은 `application-core`에서 Spring을 완전히 제거하는 방법이었습니다. use case는 순수 Java class로 두고 bootstrap module의 `@Configuration`에서 모두 수동 등록합니다. framework 의존 경계는 가장 단순해지지만, use case 수와 생성자 의존성이 늘수록 조립 코드가 함께 증가합니다. 프로젝트는 이 반복 비용을 실제 문제로 보았습니다. -반대쪽 선택은 Spring 편의를 application layer 전반에 허용하는 것이다. `@Service`뿐 아니라 `@Transactional`, Spring Web type, JPA annotation까지 사용할 수 있게 두면 구현 속도는 빨라질 수 있다. 그러나 transaction, transport, persistence 정책이 application code에 섞이면서 adapter를 교체하거나 경계를 검증하기 어려워진다. 편의를 허용하는 목적이 bean 등록을 넘어서는 순간이었다. +반대쪽 선택은 Spring 편의를 application layer 전반에 허용하는 방법이었습니다. `@Service`뿐 아니라 `@Transactional`, Spring Web type, JPA annotation까지 사용할 수 있게 두면 구현 속도는 빨라질 수 있습니다. 그러나 transaction, transport, persistence 정책이 application code에 섞이면서 adapter를 교체하거나 경계를 검증하기 어려워집니다. 편의를 허용하는 목적이 bean 등록을 넘어서는 순간이었습니다. -그래서 선택지를 “Spring을 제거할 것인가”와 “Spring을 사용할 것인가”로 나누지 않았다. 대신 의존 목적을 기준으로 잘랐다. 객체 등록에 필요한 DI stereotype은 허용하고, transaction 실행과 web·persistence 기술은 금지하는 중간 경계를 검토했다. +그래서 저는 선택지를 “Spring을 제거할 것인가”와 “Spring을 사용할 것인가”로 나누지 않았습니다. 대신 의존 목적을 기준으로 잘랐습니다. 객체 등록에 필요한 DI stereotype은 허용하고, transaction 실행과 web·persistence 기술은 금지하는 중간 경계를 검토했습니다. ## 선택의 이유와 지킨 경계 -ca-tmpl은 `application-core`에서 `@Service`와 `@Component`를 허용했다. use case를 component scanning으로 등록해, 각 use case마다 `@Configuration`에 bean을 수동 선언하는 반복을 피하기 위해서다. `spring-context`와 `spring-beans`를 compile dependency로 유지하는 비용도 함께 받아들였다. +ca-tmpl은 `application-core`에서 `@Service`와 `@Component`를 허용했습니다. use case를 component scanning으로 등록해, 각 use case마다 `@Configuration`에 bean을 수동 선언하는 반복을 피하기 위해서입니다. 저는 이 선택과 함께 `spring-context`와 `spring-beans`를 compile dependency로 유지하는 비용도 받아들였습니다. -다만 허용 목적을 DI 등록으로 한정했다. `spring-tx`, Spring Web, JPA annotation은 계속 금지한다. transaction boundary는 application use case가 결정하지만, 실행 방식은 `TransactionPort` 뒤로 숨긴다. application code는 `inWrite`, `inRead`, `inNew`처럼 필요한 transaction 의미를 요청하고, Spring의 `TransactionTemplate`을 사용하는 구현은 바깥에서 제공한다. +다만 허용 목적은 DI 등록으로 한정했습니다. `spring-tx`, Spring Web, JPA annotation은 계속 금지합니다. transaction boundary는 application use case가 결정하지만, 실행 방식은 `TransactionPort` 뒤로 숨깁니다. application code는 `inWrite`, `inRead`, `inNew`처럼 필요한 transaction 의미를 요청하고, Spring의 `TransactionTemplate`을 사용하는 구현은 바깥에서 제공합니다. -이 경계가 중요한 이유는 선택의 이점과 비용을 같은 위치에 묶어 두기 때문이다. 얻는 것은 use case 조립 코드의 감소다. 수용한 비용은 application module이 Spring core DI에 의존한다는 사실이다. 그 비용이 다른 프레임워크 의존으로 번지지 않도록 transaction, transport, persistence 의존을 명시적으로 금지했다. +이 경계가 중요한 이유는 선택의 이점과 비용을 같은 위치에 묶어 두기 때문입니다. 얻는 것은 use case 조립 코드의 감소입니다. 수용한 비용은 application module이 Spring core DI에 의존한다는 사실입니다. 그 비용이 다른 프레임워크 의존으로 번지지 않도록 transaction, transport, persistence 의존을 명시적으로 금지했습니다. -따라서 “`application-core`는 framework-free다”라는 설명은 정확하지 않다. 더 정확한 설명은 “bean 등록을 위한 Spring DI는 허용하지만 application policy를 framework annotation과 adapter type으로 표현하지 않는다”이다. +따라서 “`application-core`는 framework-free다”라는 설명은 정확하지 않습니다. 더 정확한 설명은 “bean 등록을 위한 Spring DI는 허용하지만 application policy를 framework annotation과 adapter type으로 표현하지 않는다”입니다. ## 선택이 코드와 흐름에 반영되는 방식 -use case class는 application package에 놓이고 `@Service` 또는 `@Component`로 등록된다. 생성자에는 domain service나 outbound port 같은 application 경계의 dependency가 들어간다. controller DTO, JPA entity, Spring MVC type은 들어오지 않는다. +제가 선택한 경계에서 use case class는 application package에 놓이고 `@Service` 또는 `@Component`로 등록됩니다. 생성자에는 domain service나 outbound port 같은 application 경계의 dependency가 들어갑니다. controller DTO, JPA entity, Spring MVC type은 들어오지 않습니다. -transaction이 필요한 write use case를 예로 들면 흐름은 다음과 같다. +transaction이 필요한 write use case를 예로 들면 흐름은 다음과 같습니다. ```text HTTP adapter @@ -50,30 +50,30 @@ HTTP adapter → persistence adapter가 실제 저장 수행 ``` -application use case가 알고 있는 것은 write transaction이 필요하다는 정책과 outbound port 계약이다. 어떤 transaction manager를 사용하고 어떤 persistence 기술이 저장을 수행하는지는 알지 못한다. DI stereotype은 use case를 찾고 연결하는 데만 쓰이며, transaction 구현을 application 안으로 가져오는 통로로 쓰이지 않는다. +application use case가 알고 있는 것은 write transaction이 필요하다는 정책과 outbound port 계약입니다. 어떤 transaction manager를 사용하고 어떤 persistence 기술이 저장을 수행하는지는 알지 못합니다. DI stereotype은 use case를 찾고 연결하는 데만 쓰이며, transaction 구현을 application 안으로 가져오는 통로로 쓰이지 않습니다. -이 구조의 불변조건은 세 가지다. application package는 adapter와 bootstrap에 의존하지 않는다. `@Transactional`을 직접 사용하지 않는다. `ApplicationContext`에서 bean을 런타임 조회하지 않는다. 이 조건이 지켜져야 DI 허용이 service locator나 framework policy 유입으로 확대되지 않는다. +이 구조의 불변조건은 세 가지입니다. application package는 adapter와 bootstrap에 의존하지 않습니다. `@Transactional`을 직접 사용하지 않습니다. `ApplicationContext`에서 bean을 런타임 조회하지 않습니다. 이 조건이 지켜져야 DI 허용이 service locator나 framework policy 유입으로 확대되지 않습니다. ## 결정이 지켜지는지 확인하는 방법 -경계는 두 종류의 검사로 확인한다. Gradle의 dependency matrix는 module 간 `project()` 의존을 검사한다. 허용하지 않은 module dependency가 추가되면 build가 실패한다. 이 검사는 물리적인 build graph를 담당한다. +저는 경계가 지켜지는지 두 종류의 검사로 확인했습니다. Gradle의 dependency matrix는 module 간 `project()` 의존을 검사합니다. 허용하지 않은 module dependency가 추가되면 build가 실패합니다. 이 검사는 물리적인 build graph를 담당합니다. -ArchUnit은 source와 bytecode의 의존 관계를 검사한다. application package가 adapter, bootstrap, Spring Web, persistence, Hibernate에 의존하지 않는지 확인한다. `@Transactional`과 `ApplicationContext` 직접 의존도 별도 rule로 차단한다. 의도된 위반 class를 test fixture에 두고 rule이 실제로 실패하는지도 검증한다. +ArchUnit은 source와 bytecode의 의존 관계를 검사합니다. application package가 adapter, bootstrap, Spring Web, persistence, Hibernate에 의존하지 않는지 확인합니다. `@Transactional`과 `ApplicationContext` 직접 의존도 별도 rule로 차단합니다. 의도된 위반 class를 test fixture에 두고 rule이 실제로 실패하는지도 검증합니다. -검증 범위에는 한계가 있다. 정적 분석은 `getBean(String)`이나 `Class.forName(String)`처럼 문자열과 reflection을 이용한 우회를 모두 잡지 못한다. 따라서 빌드가 통과했다는 사실은 선언된 import와 dependency graph가 규칙을 지켰다는 뜻이지, 모든 런타임 우회가 불가능하다는 뜻은 아니다. 이 부분은 code review checklist로 보완한다. +검증 범위에는 한계가 있습니다. 정적 분석은 `getBean(String)`이나 `Class.forName(String)`처럼 문자열과 reflection을 이용한 우회를 모두 잡지 못합니다. 따라서 빌드가 통과했다는 사실은 선언된 import와 dependency graph가 규칙을 지켰다는 뜻이지, 모든 런타임 우회가 불가능하다는 뜻은 아닙니다. 이 부분은 code review checklist로 보완합니다. -또한 이 결정은 로컬 build와 architecture test로 확인됐다. 운영 배포와 운영 metric으로 검증된 선택이라고 확대해서 말할 수는 없다. +또한 제가 직접 확인한 범위는 로컬 build와 architecture test까지입니다. 운영 배포와 운영 metric으로 검증된 선택이라고 확대해서 말할 수는 없습니다. ## 얻은 것, 잃은 것, 적용하지 않을 때 -이 선택으로 use case 등록을 위한 반복적인 configuration code를 줄이면서도 transaction, web, persistence 경계를 유지할 수 있었다. “프레임워크 의존 0개”라는 단순한 규칙 대신, 허용 목적과 금지 범위를 더 세밀하게 표현하게 됐다. +이 선택으로 저는 use case 등록을 위한 반복적인 configuration code를 줄이면서도 transaction, web, persistence 경계를 유지할 수 있었습니다. “프레임워크 의존 0개”라는 단순한 규칙 대신, 허용 목적과 금지 범위를 더 세밀하게 표현하게 됐습니다. -반대로 규칙의 설명과 검증 비용은 늘었다. `spring-context`는 허용하지만 `spring-tx`는 금지한다는 차이를 팀원이 이해해야 하고, dependency matrix와 ArchUnit rule도 계속 관리해야 한다. 이 구분을 유지하는 이유는 bean 조립 편의가 transaction policy 유입의 근거로 확대되는 것을 막기 위해서다. Spring core DI 의존 자체를 제거해야 하는 library나 여러 DI container를 지원해야 하는 제품이라면 이 선택이 맞지 않을 수 있다. 그런 환경에서는 수동 조립이나 별도 composition module이 더 적합하다. +반대로 규칙의 설명과 검증 비용은 늘었습니다. `spring-context`는 허용하지만 `spring-tx`는 금지한다는 차이를 팀원이 이해해야 하고, dependency matrix와 ArchUnit rule도 계속 관리해야 합니다. 이 구분을 유지하는 이유는 bean 조립 편의가 transaction policy 유입의 근거로 확대되는 것을 막기 위해서입니다. Spring core DI 의존 자체를 제거해야 하는 library나 여러 DI container를 지원해야 하는 제품이라면 이 선택이 맞지 않을 수 있습니다. 그런 환경에서는 수동 조립이나 별도 composition module이 더 적합합니다. -남은 위험은 허용된 stereotype이 점차 더 넓은 Spring 사용의 근거로 오해되는 것이다. 그래서 새 framework dependency를 추가할 때는 “application policy를 표현하기 위한가, 객체 조립을 위한가”를 먼저 묻는다. 전자라면 application 경계 밖으로 밀어내고, 후자라도 기존 허용 범위 안인지 build rule로 확인한다. +남은 위험은 허용된 stereotype이 점차 더 넓은 Spring 사용의 근거로 오해되는 상황입니다. 그래서 새 framework dependency를 추가할 때는 “application policy를 표현하기 위한가, 객체 조립을 위한가”를 먼저 묻습니다. 전자라면 application 경계 밖으로 밀어내고, 후자라도 기존 허용 범위 안인지 build rule로 확인합니다. ## 결국 지키려던 것은 무엇이었나 -ca-tmpl이 지키려던 것은 framework-free라는 이름이 아니라 application 책임의 경계였다. bean 등록의 반복 비용을 줄이기 위해 Spring DI는 허용했지만, transaction·transport·persistence 정책이 application code로 들어오는 것은 막았다. +결국 제가 ca-tmpl에서 지키려던 것은 framework-free라는 이름이 아니라 application 책임의 경계였습니다. bean 등록의 반복 비용을 줄이기 위해 Spring DI는 허용했지만, transaction·transport·persistence 정책이 application code로 들어오는 것은 막았습니다. -비슷한 결정을 내려야 한다면 의존성 개수부터 세지 않는 편이 낫다. 그 의존이 해결하는 구체적인 문제는 무엇인지, 제거했을 때 생기는 비용은 무엇인지, 허용 범위가 넓어지지 않도록 어떤 검사가 실패해야 하는지를 연속해서 답할 수 있어야 한다. +비슷한 결정을 내려야 한다면 의존성 개수부터 세지 않는 편이 좋습니다. 그 의존이 해결하는 구체적인 문제는 무엇인지, 제거했을 때 생기는 비용은 무엇인지, 허용 범위가 넓어지지 않도록 어떤 검사가 실패해야 하는지를 연속해서 답할 수 있어야 합니다. diff --git a/src/claridoc/providers/mock.py b/src/claridoc/providers/mock.py index 93f6e6d..4dee10d 100644 --- a/src/claridoc/providers/mock.py +++ b/src/claridoc/providers/mock.py @@ -109,36 +109,36 @@ def _korean_body(brief: Brief, intent: str) -> list[str]: technical_blog: dict[str, list[str]] = { "problem_scene": [ - f"작은 구현 선택처럼 보였던 문제가 실제 흐름을 따라가자 여러 경계에 걸쳐 있었다. {topics} 가운데 하나만 고치면 다른 지점에서 부하, 중복, 조립 비용, 복구 비용이 커질 수 있었다. 이 글은 다음 질문을 다룬다. **{brief.reader_goal}**", - f"핵심 판단은 명확하다. **{brief.core_message}** 여기서는 {scope}에 집중하며, {non_scope}까지 보편적인 결론으로 확대하지 않는다.", + f"처음에는 작은 구현 선택 하나만 고치면 된다고 생각했습니다. 그런데 저는 실제 흐름을 따라가면서 문제가 여러 경계에 걸쳐 있다는 점을 확인했습니다. {topics} 가운데 하나만 바꾸어도 다른 지점에서 부하, 중복, 조립 비용, 복구 비용이 커질 수 있었습니다. 이 글에서는 다음 질문을 다룹니다. **{brief.reader_goal}**", + f"제가 이 과정에서 내린 핵심 판단은 “**{brief.core_message}**”입니다. 여기서는 {scope}에 집중하며, {non_scope}까지 보편적인 결론으로 확대하지 않습니다.", ], "constraints": [ - f"{topics}는 입력과 상태, 실패와 복구를 통해 서로 연결된다. 한 부분의 편의를 높이면 다른 경계로 부하나 중복, 복구 비용이 이동할 수 있어서 각 요소를 독립적으로 바꾸기 어려웠다.", - "근거의 역할도 서로 달랐다. 현재 구현, 결정 기록, 공식 동작, 다른 회사의 사례는 같은 단어를 사용하더라도 같은 사실을 증명하지 않는다. 프로젝트의 선택 이유는 그 이유를 직접 기록한 자료가 있을 때만 설명할 수 있다.", + f"저는 {topics}가 입력과 상태, 실패와 복구를 통해 서로 연결되는 모습을 확인했습니다. 한 부분의 편의를 높이면 다른 경계로 부하나 중복, 복구 비용이 이동할 수 있어서 각 요소를 독립적으로 바꾸기 어려웠습니다.", + "근거의 역할도 서로 달랐습니다. 현재 구현, 결정 기록, 공식 동작, 다른 회사의 사례는 같은 단어를 사용하더라도 같은 사실을 증명하지 않습니다. 프로젝트의 선택 이유는 그 이유를 직접 기록한 자료가 있을 때만 설명할 수 있습니다.", ], "options": [ - "검토할 선택지는 최소 두 가지다. 첫째, 현재 방식을 유지하고 문제가 드러난 지점만 보완한다. 변경 범위는 작지만 상호작용을 놓치기 쉽다. 둘째, 관련 요소를 하나의 정책 경계로 묶는다. 초기 설계와 검증 비용은 늘지만 판단 기준과 실패 범위를 함께 관리할 수 있다.", - "비교 기준은 구현량이 아니라 실패 시 부하가 어디로 이동하는지, 중복 부작용을 막을 수 있는지, 검증 결과를 관측할 수 있는지, 잘못됐을 때 되돌릴 수 있는지다. 실패한 시도나 제외한 대안도 같은 기준으로 설명해야 독자가 선택을 재현할 수 있다.", + "제가 검토한 선택지는 최소 두 가지였습니다. 첫째, 현재 방식을 유지하고 문제가 드러난 지점만 보완하는 방법입니다. 변경 범위는 작지만 상호작용을 놓치기 쉽습니다. 둘째, 관련 요소를 하나의 정책 경계로 묶는 방법입니다. 초기 설계와 검증 비용은 늘지만 판단 기준과 실패 범위를 함께 관리할 수 있습니다.", + "비교 기준은 구현량이 아니라 실패 시 부하가 어디로 이동하는지, 중복 부작용을 막을 수 있는지, 검증 결과를 관측할 수 있는지, 잘못됐을 때 되돌릴 수 있는지입니다. 실패한 시도나 제외한 대안도 같은 기준으로 설명해야 독자가 선택을 재현할 수 있습니다.", ], "decision_rationale": [ - f"이 글이 선택한 방향은 **{brief.core_message}** 여러 설정을 함께 다루기로 한 이유는 각각의 값이 서로의 안전 조건을 바꾸기 때문이다. 한 항목만 최적화하면 전체 요청 경로나 모듈 경계에서 예상하지 못한 비용이 발생한다.", - "대안은 설정을 완전히 분리하거나 편의를 위해 관련 경계를 넓게 허용하는 방식이다. 전자는 상호작용을 운영자에게 떠넘기고, 후자는 정책이 코어 안으로 번질 위험을 키운다. 따라서 초기 설계와 테스트 비용을 수용하되, 허용 범위와 금지 범위를 자동 검사하는 가드레일을 함께 둔다.", + f"그래서 저는 “**{brief.core_message}**”라는 방향을 선택했습니다. 여러 설정을 함께 다루기로 한 이유는 각각의 값이 서로의 안전 조건을 바꾸기 때문입니다. 한 항목만 최적화하면 전체 요청 경로나 모듈 경계에서 예상하지 못한 비용이 발생합니다.", + "대안은 설정을 완전히 분리하거나 편의를 위해 관련 경계를 넓게 허용하는 방식입니다. 전자는 상호작용을 운영자에게 떠넘기고, 후자는 정책이 코어 안으로 번질 위험을 키웁니다. 따라서 초기 설계와 테스트 비용을 수용하되, 허용 범위와 금지 범위를 자동 검사하는 가드레일을 함께 둡니다.", ], "mechanism": [ - "결정은 입력에서 관측까지 끊기지 않는 흐름으로 반영한다. 요청이나 변경이 들어오면 사전 조건을 확인하고, 같은 기준에서 실행 경로와 상태 변경 범위를 정한다. 실행 뒤에는 결과와 실패 신호를 기록해 성공, 중단, 복구 중 하나를 결정한다.", + "결정은 입력에서 관측까지 끊기지 않는 흐름으로 반영합니다. 요청이나 변경이 들어오면 사전 조건을 확인하고, 같은 기준에서 실행 경로와 상태 변경 범위를 정합니다. 실행 뒤에는 결과와 실패 신호를 기록해 성공, 중단, 복구 중 하나를 결정합니다.", "```text\n입력과 현재 상태\n → 안전 조건 확인\n → 한정된 실행 경로 선택\n → 상태 변경 또는 호출\n → 로그·지표·테스트 결과 관측\n → 확정 / 중단 / 복구\n```", - "이 흐름의 불변조건은 실패한 작업이 성공으로 기록되지 않고, 같은 입력을 다시 처리했을 때 허용하지 않은 부작용이 늘어나지 않는 것이다. 실제 글에서는 일반 명칭 대신 프로젝트의 모듈, 인터페이스, 테스트 이름을 사용한다.", + "이 흐름의 불변조건은 실패한 작업이 성공으로 기록되지 않고, 같은 입력을 다시 처리했을 때 허용하지 않은 부작용이 늘어나지 않는다는 점입니다. 실제 글에서는 일반 명칭 대신 프로젝트의 모듈, 인터페이스, 테스트 이름을 사용합니다.", ], "evidence_verification": [ - "검증은 주장마다 관측 가능한 증거를 붙이는 방식으로 설계한다. 구조적 경계는 빌드 규칙이나 정적 분석으로, 런타임 동작은 단위·통합 테스트와 로그·지표로, 실패 복구는 의도된 오류 주입과 롤백 확인으로 검증한다.", - f"성공 기준은 독자가 다음 목표를 반복 가능한 결과로 확인할 수 있는지다. **{brief.reader_goal}** 반대로 운영 배포, 장기 부하, 특정 장애 조합을 검증하지 않았다면 그 범위는 명시적으로 남겨야 한다. 로컬 테스트 통과를 운영 검증으로 확대해 쓰지 않는다.", + "저는 주장마다 관측 가능한 증거를 붙이는 방식으로 검증을 설계했습니다. 구조적 경계는 빌드 규칙이나 정적 분석으로, 런타임 동작은 단위·통합 테스트와 로그·지표로, 실패 복구는 의도된 오류 주입과 롤백 확인으로 검증합니다.", + f"성공 기준은 독자가 다음 목표를 반복 가능한 결과로 확인할 수 있는지입니다. **{brief.reader_goal}** 반대로 운영 배포, 장기 부하, 특정 장애 조합을 검증하지 않았다면 그 범위는 명시적으로 남겨야 합니다. 로컬 테스트 통과를 운영 검증으로 확대해 쓰지 않습니다.", ], "tradeoffs": [ - "얻는 것은 판단 기준의 일관성, 실패 범위의 가시성, 자동 검증 가능성이다. 잃는 것은 초기 설계 시간과 정책을 유지하는 비용이다. 작은 실험이나 폐기 예정 코드에서는 이 구조가 과할 수 있지만, 반복 사용되거나 장애 시 비용이 큰 경로에서는 그 비용이 가드레일로 작동한다.", - "이 선택은 보편 법칙이 아니다. 성공 기준을 관측할 수 없거나 관련 요소의 소유권이 분리돼 있다면 더 작은 경계가 나을 수 있다. 남은 위험은 자동 검사가 잡지 못하는 런타임 우회와 문서·구현 간 시차이며, 코드 리뷰와 주기적인 근거 재검증으로 보완한다.", + "제가 얻은 것은 판단 기준의 일관성, 실패 범위의 가시성, 자동 검증 가능성입니다. 대신 초기 설계 시간과 정책을 유지하는 비용을 수용했습니다. 작은 실험이나 폐기 예정 코드에서는 이 구조가 과할 수 있지만, 반복 사용되거나 장애 시 비용이 큰 경로에서는 그 비용이 가드레일로 작동합니다.", + "이 선택은 보편 법칙이 아닙니다. 성공 기준을 관측할 수 없거나 관련 요소의 소유권이 분리돼 있다면 더 작은 경계가 나을 수 있습니다. 남은 위험은 자동 검사가 잡지 못하는 런타임 우회와 문서·구현 간 시차이며, 코드 리뷰와 주기적인 근거 재검증으로 보완합니다.", ], "conclusion": [ - f"결국 지키려던 것은 특정 도구가 아니라 판단 가능한 경계다. **{brief.core_message}** 자신의 환경에서는 ‘왜 이 선택이 필요한가’, ‘대안보다 어떤 비용을 덜어 주는가’, ‘그 대가를 어떤 테스트가 제한하는가’를 연속해서 답할 수 있어야 한다.", + f"결국 제가 지키려던 것은 특정 도구가 아니라 판단 가능한 경계였습니다. 핵심은 “**{brief.core_message}**”라는 점입니다. 자신의 환경에서는 ‘왜 이 선택이 필요한가’, ‘대안보다 어떤 비용을 덜어 주는가’, ‘그 대가를 어떤 테스트가 제한하는가’를 연속해서 답할 수 있어야 합니다.", ], } if intent in technical_blog: diff --git a/src/claridoc/report.py b/src/claridoc/report.py index 134c4a6..153eb23 100644 --- a/src/claridoc/report.py +++ b/src/claridoc/report.py @@ -69,6 +69,25 @@ def render_run_report( f"| {issue.severity.value} | `{issue.code}` | {location} | {message} |" ) + metrics = final.lint_report.metrics + style_contract = str(metrics.get("style_contract", "none")) + if style_contract != "none": + coverage = float(metrics.get("experience_section_coverage", 0.0)) + lines.extend([ + "", + "## Reader-prose contract", + "", + f"- Contract: `{style_contract}`", + f"- Plain-form endings found: {int(metrics.get('plain_form_ending_count', 0))}", + f"- First-person markers: {int(metrics.get('first_person_marker_count', 0))}", + f"- Opening establishes first-person experience: {'yes' if metrics.get('opening_has_first_person') else 'no'}", + ( + "- Substantive sections with first-person experience: " + f"{int(metrics.get('marked_experience_section_count', 0))}/" + f"{int(metrics.get('experience_section_count', 0))} ({coverage:.0%})" + ), + ]) + lines.extend(["", "## Final independent reviews", ""]) for review in final.reviews: lines.extend([ diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 4329fd3..c2970bf 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -5,11 +5,43 @@ import json import tempfile import unittest from pathlib import Path +from unittest.mock import patch -from claridoc.models import PipelineConfig +from claridoc.models import Brief, PipelineConfig from claridoc.pipeline import _mock_provider_warning, run_pipeline +from claridoc.providers.base import ProviderRequest, ProviderResponse +from claridoc.providers.mock import MockProvider from claridoc.templates import mock_pipeline_config -from tests.helpers import make_brief, make_sources +from tests.helpers import brief_dict, make_brief, make_sources + + +def _korean_experience_brief() -> Brief: + data = brief_dict() + data.update( + { + "title": "기술적 선택을 경험과 근거로 설명하기", + "language": "ko-KR", + "reader_goal": "기술적 선택의 이유와 검증 방법을 이해한다", + "core_message": "선택의 배경과 대안, 비용, 검증을 경험의 흐름으로 연결해야 합니다.", + "scope": ["하나의 기술적 선택"], + "non_scope": ["근거가 없는 일반화"], + "required_topics": ["문제", "대안", "선택 이유", "검증"], + } + ) + data["constraints"]["style_profile"] = "auto" + return Brief.from_dict(data) + + +class _PlainEndingWriter(MockProvider): + def generate(self, request: ProviderRequest) -> ProviderResponse: + response = super().generate(request) + if request.stage == "draft": + response.text = response.text.replace( + "처음에는 작은 구현 선택 하나만 고치면 된다고 생각했습니다.", + "처음에는 작은 구현 선택 하나만 고치면 된다고 생각했다.", + 1, + ) + return response class PipelineTests(unittest.TestCase): @@ -69,6 +101,55 @@ class PipelineTests(unittest.TestCase): self.assertEqual(len(result.rounds), 2) self.assertTrue((output / "rounds" / "round-01" / "revision.raw.txt").is_file()) + def test_korean_mock_run_records_reader_prose_contract(self) -> None: + with tempfile.TemporaryDirectory() as temp: + output = Path(temp) / "run" + result = run_pipeline( + _korean_experience_brief(), + make_sources(), + PipelineConfig.from_dict(mock_pipeline_config()), + output, + ) + + self.assertTrue(result.passed) + self.assertEqual( + result.rounds[-1].lint_report.metrics["style_contract"], + "korean_first_person_experience_v1", + ) + report_text = result.report_path.read_text(encoding="utf-8") + self.assertIn("Reader-prose contract", report_text) + self.assertIn("korean_first_person_experience_v1", report_text) + + def test_style_blocker_cannot_be_hidden_by_permissive_error_limit(self) -> None: + with tempfile.TemporaryDirectory() as temp: + output = Path(temp) / "run" + config_data = mock_pipeline_config() + config_data["quality_gate"].update( + { + "minimum_score": 0, + "max_errors": 99, + "max_revisions": 0, + } + ) + + with patch( + "claridoc.pipeline.create_provider", + side_effect=lambda spec: _PlainEndingWriter(spec), + ): + result = run_pipeline( + _korean_experience_brief(), + make_sources(), + PipelineConfig.from_dict(config_data), + output, + ) + + self.assertFalse(result.passed) + self.assertGreater(result.rounds[-1].blocker_count, 0) + self.assertIn( + "STYLE002", + {issue.code for issue in result.rounds[-1].lint_report.issues}, + ) + def test_reviewer_role_cannot_escape_artifact_directory(self) -> None: with tempfile.TemporaryDirectory() as temp: root = Path(temp)