commitgate 0.9.5 → 0.9.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,7 +12,10 @@ import { resolve, join, relative } from 'node:path'
12
12
  import { pathToFileURL } from 'node:url'
13
13
  import { createHash } from 'node:crypto'
14
14
  import { parseStatusZ, entryPaths, formatStatusEntry, STATUS_Z_ARGS, type StatusEntry } from './lib/porcelain'
15
- import { isArchiveFileName, isAllowedResponsesScratch, reviewScratchPaths } from './lib/scratch'
15
+ import { isAllowedResponsesScratch, reviewScratchPaths } from './lib/scratch'
16
+ // REQ-2026-048 phase-1: confinement 술어는 leaf `lib/evidence.ts`가 정본 — 여기서 재수출(기존 경로 보존).
17
+ import { isConfinedArchivePath } from './lib/evidence'
18
+ export { isConfinedArchivePath } from './lib/evidence'
16
19
  import {
17
20
  loadState,
18
21
  validateVerdict,
@@ -20,6 +23,7 @@ import {
20
23
  findUnstagedOrUntracked,
21
24
  captureDesignBinding,
22
25
  designDocPaths,
26
+ REVIEW_CALL_LOG_REL,
23
27
  type WorkflowState,
24
28
  type Verdict,
25
29
  type ApprovalEvidence,
@@ -98,6 +102,13 @@ export interface DoctorInputs {
98
102
  // undefined = 미계산(2-arg/legacy) → OK. [] = 없음/최신/대상없음 → OK. 비어있지 않음 → WARN.
99
103
  // dev/dogfood(packageRootDiffers===false)면 D20처럼 skip. optional이어야 테스트 base 리터럴이 안 깨진다.
100
104
  quickstartMissing?: string[]
105
+ // D22(REQ-2026-047): repo-root 런타임 스크래치 경로 중 **ignore도 tracked도 아닌** 것(= 다음 review가 만들면 D10이 막는 것).
106
+ // 런타임이 소비 repo 루트에 만드는 스크래치(현재 `workflow/.review-calls.jsonl`)는 티켓 밖이라 `/REQ-*/` 앵커에
107
+ // 걸리지 않고 `reviewScratchPaths` 허용목록에도 없다 → **gitignore가 유일한 방어**다. 0.9.6 이하 설치본은 배포
108
+ // 템플릿에 그 규칙이 없어, 첫 리뷰 뒤 D10 FAIL로 커밋이 전부 막혔다.
109
+ // undefined = 미계산(2-arg/legacy) → OK. [] = 전부 보호됨 → OK. 비어있지 않음 → WARN.
110
+ // dev/dogfood(packageRootDiffers===false)면 D20/D21처럼 skip. optional이어야 테스트 base 리터럴이 안 깨진다.
111
+ repoRootScratchUnprotected?: string[]
101
112
  }
102
113
 
103
114
  /**
@@ -190,20 +201,9 @@ export interface ArchiveCheck {
190
201
  structureOk: boolean
191
202
  }
192
203
 
193
- /**
194
- * evidence `response_path`가 **현재 티켓 `responses/` 직계 아카이브**인지(D-016 confinement).
195
- * 절대경로·`..`·다른 티켓·중첩경로·`approvals.jsonl` 등 비아카이브는 거부. ticketRel 미지정 시 false(fail-closed).
196
- */
197
- export function isConfinedArchivePath(p: string, ticketRel: string | undefined): boolean {
198
- if (!ticketRel || typeof p !== 'string' || !p) return false
199
- const norm = p.replace(/\\/g, '/')
200
- if (norm.includes('..') || norm.startsWith('/') || /^[a-zA-Z]:\//.test(norm)) return false
201
- const prefix = `${ticketRel.replace(/\\/g, '/').replace(/\/+$/, '')}/responses/`
202
- if (!norm.startsWith(prefix)) return false
203
- const name = norm.slice(prefix.length)
204
- if (!name || name.includes('/')) return false
205
- return isArchiveFileName(name)
206
- }
204
+ // `isConfinedArchivePath`는 REQ-2026-048 phase-1에서 `lib/evidence.ts`로 이동했다 — 매니페스트 검증이
205
+ // 같은 술어를 쓰는데 여기 두면 `lib/evidence → req-doctor` 런타임 간선이 생겨 순환이 된다. 아래 re-export로
206
+ // 기존 import 경로(`from './req-doctor'`)를 보존한다.
207
207
 
208
208
  /**
209
209
  * 승인 증거(evidence)와 그 아카이브 파일의 정합 문제 목록(순수, A2/D-016-5).
@@ -482,6 +482,32 @@ export function runChecks(inp: DoctorInputs): Check[] {
482
482
  })
483
483
  }
484
484
 
485
+ // D22(REQ-2026-047): repo-root 런타임 스크래치가 ignore도 tracked도 아님 → 다음 review 뒤 D10이 커밋을 막는다.
486
+ //
487
+ // 🔴 **level 상한은 WARN — 절대 FAIL이 아니다**(D19:425-428·D20:443-447·D21과 동일 근거). `req:commit`이 이 doctor를
488
+ // exit≠0에 throw하는 하드 게이트로 spawn하므로, FAIL이면 백필 전까지 소비자의 모든 커밋이 벽돌이 된다.
489
+ // 더구나 이 드리프트는 **이미** D10 FAIL로 발현한다 — 신규 진단이 차단을 만드는 것이 아니라, 불투명한
490
+ // `D10: unstaged/untracked workflow/.review-calls.jsonl`을 **행동 가능한 안내로 번역**하는 것이 역할이다.
491
+ //
492
+ // tracked인 경우는 여기서 경고하지 않는다(이미 커밋된 상태 = 다른 문제). 그 복구는 `git rm --cached` 절차로
493
+ // troubleshooting 문서가 다룬다 — ignore 규칙만 넣어서는 tracked 파일이 빠지지 않기 때문이다.
494
+ if (inp.packageRootDiffers === false) {
495
+ c.push({ id: 'D22', level: 'OK', msg: 'repo-root 스크래치 보호 점검 불요(dev repo/dogfood — packageRoot === config root)' })
496
+ } else if (inp.repoRootScratchUnprotected === undefined) {
497
+ c.push({ id: 'D22', level: 'OK', msg: 'repo-root 스크래치 보호 점검 불요(2-arg/미계산)' })
498
+ } else if (inp.repoRootScratchUnprotected.length === 0) {
499
+ c.push({ id: 'D22', level: 'OK', msg: 'repo-root 런타임 스크래치가 모두 ignore(또는 tracked)됨' })
500
+ } else {
501
+ c.push({
502
+ id: 'D22',
503
+ level: 'WARN',
504
+ msg:
505
+ `${inp.repoRootScratchUnprotected.join(', ')} 이(가) gitignore로 무시되지 않습니다 — ` +
506
+ '다음 review가 이 파일을 만들면 **D10이 FAIL하여 커밋이 막힙니다**. ' +
507
+ '`commitgate sync --gitignore --apply` 로 배포 템플릿의 누락 규칙을 보강하세요(기존 행은 변경하지 않습니다, REQ-2026-047).',
508
+ })
509
+ }
510
+
485
511
  return c
486
512
  }
487
513
 
@@ -583,6 +609,38 @@ export function safeSha256(abs: string): string | null {
583
609
  }
584
610
  }
585
611
 
612
+ /**
613
+ * D22(REQ-2026-047): repo-root 런타임 스크래치 중 **ignore도 tracked도 아닌** 경로.
614
+ *
615
+ * 판정은 **로컬 git 상태 그대로**다(전역 excludes 포함) — D10이 보는 `git status`와 같은 기준이어야
616
+ * "다음 review 뒤 D10이 막는다"는 예측이 맞는다. 파일이 아직 없어도 `check-ignore`는 패턴 매칭이라
617
+ * 동작한다(그래서 **첫 리뷰 전에 미리** 경고할 수 있다 — 이 검사의 존재 이유).
618
+ *
619
+ * 읽기 전용 advisory라 어떤 오류도 삼킨다(조회 실패 = 보호됨으로 간주 → WARN 안 냄. fail-safe: 게이트를 막지 않는다).
620
+ */
621
+ export function unprotectedRepoRootScratch(paths: readonly string[], gitFn: (a: string[]) => string): string[] {
622
+ const out: string[] = []
623
+ for (const p of paths) {
624
+ let ignored = false
625
+ let tracked = false
626
+ try {
627
+ gitFn(['check-ignore', '-q', '--', p])
628
+ ignored = true
629
+ } catch {
630
+ ignored = false
631
+ }
632
+ if (!ignored) {
633
+ try {
634
+ tracked = gitFn(['ls-files', '--', p]).trim() !== ''
635
+ } catch {
636
+ tracked = true // 조회 불가 → 보호됨으로 간주(경고하지 않음)
637
+ }
638
+ }
639
+ if (!ignored && !tracked) out.push(p)
640
+ }
641
+ return out
642
+ }
643
+
586
644
  /** package.json의 version 문자열. 부재·파손 시 null(D20 WARN 메시지용 — 없어도 무해). */
587
645
  function safeReadVersion(pkgAbs: string): string | null {
588
646
  try {
@@ -699,6 +757,9 @@ export function main(argv: string[] = process.argv.slice(2)): void {
699
757
  schemaPathIsDefault: cfg.schemaPathAbs === resolve(cfg.root, DEFAULTS.schemaPath), // 정규화 절대경로 비교(동치 상대경로 포함)
700
758
  installedVersion: safeReadVersion(join(packageRoot(), 'package.json')),
701
759
  quickstartMissing: missingQuickstartFiles(cfg.root),
760
+ // D22(REQ-2026-047): 현재 repo-root 런타임 스크래치 축은 review-call 측정 로그 1건.
761
+ // 새 축이 생기면 이 배열에 추가하고 packed-consumer smoke 단언도 함께 늘린다(docs 인벤토리 표의 유지 규칙).
762
+ repoRootScratchUnprotected: unprotectedRepoRootScratch([REVIEW_CALL_LOG_REL], git),
702
763
  }
703
764
 
704
765
  const checks = runChecks(inp)