doc-refs bare-path 비대칭 보강
요약scripts/check-doc-refs.mjs는 markdown cross-ref 무결성을 정적 검증한다(Sprint 154 도입). 세 함수가 협력한다: validateRef(경로 resolve + 존재 검증), extractMarkdownLinks(text 추출), extractBareDocPaths(평문 노출 bare 경로 추출).
목표
scripts/check-doc-refs.mjs는 markdown cross-ref 무결성을 정적 검증한다(Sprint 154 도입). 세 함수가 협력한다:validateRef(경로 resolve + 존재 검증),extractMarkdownLinks([text](path)추출),extractBareDocPaths(평문 노출 bare 경로 추출).- 비대칭:
validateRef의 repo-root resolve 분기는 8개 top-level prefix(docs/,scripts/,blog/,frontend/,services/,infra/,.claude/,.github/)를 지원하고extractMarkdownLinks는 prefix 무관하게 모든.md링크를 추출한다. 그런데extractBareDocPaths의 정규식은docs/만 매칭 → markdown link가 아닌 평문으로 노출된frontend/README.md·services/gateway/X.md같은 bare 참조는 깨져도 미검출되는 갭이 있었다. - 이 비대칭을 해소해 게이트의 검증 범위를 세 함수에서 일관되게 정렬하고, 비대칭 재발을 구조적으로 차단한다.
결정
D1. prefix를 단일 SSOT로 통합
validateRef에 하드코딩돼 있던 prefix || 체인(8개)을 모듈 레벨 상수 REPO_ROOT_PREFIXES로 추출했다. validateRef의 resolve 분기(some(p => decoded.startsWith(\${p}/`)))와 extractBareDocPaths`의 bare 매칭 정규식이 동일 출처를 공유한다. 신규 top-level 디렉토리 추가 시 본 배열만 갱신하면 양쪽 룰에 동시 반영되어, "한쪽만 docs/ 만 매칭하던" 비대칭이 구조적으로 재발 불가능해진다.
D2. bare 정규식은 .md 확장자로 한정
extractBareDocPaths 정규식을 SSOT 배열에서 동적 생성하되(.claude/.github의 .은 정규식 이스케이프) 확장자는 .md로 한정했다. validateRef가 실제 검증하는 범위는 .md 경로(또는 docs/ prefix)이므로, bare 추출을 .md로 맞춰 추출-검증 계약을 정렬한다. 부수 효과로 services/ 레이어·infra/ 디렉토리 같은 비-.md 평문 언급은 매칭되지 않아 false-positive를 차단한다.
D3. 소비처 자동 일관 적용
extractBareDocPaths는 check-staging-integrity.mjs(staged/untracked .md 무결성 검사)에서도 재사용된다. 본 보강은 함수 한 곳을 고쳐 두 게이트(check-doc-refs + check-staging-integrity)에 동시 반영된다 — 별도 동기화 코드 없이 단일 SSOT 함수가 소비처 전반의 동작을 일관 갱신한다.
구현
PR #317 (단일 작업 브랜치 feat/sprint-182-doc-refs-bare-path, 1 commit → squash)
f1286cbfix —REPO_ROOT_PREFIXESSSOT 신설,validateRef하드코딩 체인 →some()치환,extractBareDocPaths정규식 SSOT 동적 생성(8 prefix), JSDoc 정정. self-test fixture 5→8종(non-docs prefix dogfood). runbook §2.2/§6/§9 갱신.
핵심 변경 (extractBareDocPaths):
const alt = REPO_ROOT_PREFIXES.map((p) => p.replace(/\./g, '\\.')).join('|');
const re = new RegExp(`(?<![[(\\w/.-])((?:${alt})\\/[\\w./-]+\\.md(?:#[\\w-]+)?)`, 'g');
부정 lookbehind (?<![[(\w/.-])로 markdown link 경로(앞에 ()와의 중복 매칭을 차단하고, prefix 앞 word-char(v2.github/... 같은 비-경로)를 배제한다.
Critic 사이클
codex review --base main 1라운드.
- R1 (session
019e47f0): 0건 통과 — "repo-root prefix를 중앙화하고 bare markdown path 검출을 기존 resolver와 일관되게 확장한다. 갱신된 self-test가 통과하며 조치 가능한 회귀는 식별되지 않았다." 머지 가능.
검증
로컬
node scripts/check-doc-refs.mjs: self-test 8/8 broken 검출(5종 docs 슬러그 회귀 기준선 + 3종 non-docs prefix dogfood) + 325 files 0 broken.node scripts/check-staging-integrity.mjs: self-test 2/2 무회귀.node scripts/check-regex-robustness.mjs: 통과.- 엣지 케이스 검증(인라인 import): 유효 bare 참조(
frontend/README.md) 추출·통과 / markdown link[x](.github/foo.md)중복 미매칭 / standalone.github/...md매칭 / nestedservices/gateway/src/x.md매칭 / 비-.md평문(services/·infra/) 미매칭 / word-char 앞(v2.github/x.md) 미매칭.
dry-run (보강 영향 측정)
- 넓힌 정규식을 전체 tracked .md corpus에 dry-run한 결과 신규 매칭 0건 / 신규 깨진 참조 0건. 즉 보강 시점 corpus에는 고칠 평문 bare 비-docs 참조가 없어 본 작업은 예방적·구조적 정합(latent 게이트 불일치 차단 + future 참조 자동 커버)이다. self-test fixture가 새 커버리지의 증명 역할을 한다.
CI
- 작업 PR #317 전체 37 checks green. ADR PR은
sprints/**트리거로 Build Blog 포함 green.
결과
- 머지: origin/main
e3d0983→6c92415(PR #317 squash merge, 작업 브랜치 삭제). - 순변경:
scripts/check-doc-refs.mjs(+34/-17),docs/runbook/doc-ref-lint.md(+14/-5). 신규 파일 없음. - ADR sprint-182(KR+EN) + README sprint ADR count 120→121·범위 62~182 (별도 ADR PR).
신규 패턴
- 추출-검증 계약 정렬(extractor↔validator symmetry): 정적 검증 게이트가 "추출(무엇을 검사 후보로 볼 것인가)"과 "검증(후보가 유효한가)"을 분리하면, 둘의 커버 범위가 어긋날 때 검증기는 넓은데 추출기가 좁아 일부 결함이 후보에서 누락되는 silent gap이 생긴다. validator가 지원하는 입력 범위를 extractor도 동일하게 커버하도록 SSOT로 묶어야 게이트가 의도대로 동작한다.
- 하드코딩 목록의 SSOT화로 비대칭을 구조적 차단: 동일한 prefix 목록이 두 함수에 따로 존재하면 한쪽만 확장돼 비대칭이 생긴다(본 사례). 단일 배열로 추출하면 비대칭이 "갱신을 잊는 실수"가 아니라 "애초에 표현 불가능한 상태"가 된다.