From 298ad02c91c19b53c77bec16b6d9d2209cbf2d20 Mon Sep 17 00:00:00 2001 From: chucoding Date: Fri, 2 Oct 2026 00:10:44 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat(94):=20=EC=84=A4=EA=B3=84=20=EC=9D=98?= =?UTF-8?q?=EB=8F=84=EB=A5=BC=20=EB=AA=A8=EC=9D=80=20LLM=20Wiki=EC=99=80?= =?UTF-8?q?=20=EC=A0=95=EC=A0=81=20=EA=B2=80=EC=82=AC=20CI=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 에이전트가 작업 전에 참조할 설계 의도의 정본이 저장소 안에 없어, Notion 설계를 따라 카파시 LLM Wiki 패턴과 OKF v0.2 번들로 위키를 구축했다. 별도 저장소 대신 이 저장소 wiki/에 둔 것은 코드와 문서를 한 PR에서 함께 고쳐 어긋남을 막기 위해서이고, public 저장소라 비밀 값과 비용 수치는 쓰지 않는 규칙을 AGENTS.md에 함께 두었다. OKF는 type만 필수지만 sources와 stale_after를 처음부터 CI로 강제해, 나중에 도구를 바꿔도 출처와 신뢰 신호를 잃지 않게 했다. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/CONTRIBUTING.md | 1 + .github/workflows/wiki-lint.yml | 38 +++ AGENTS.md | 90 ++++++ CLAUDE.md | 1 + package.json | 7 +- pnpm-lock.yaml | 14 +- scripts/wiki-lint.mjs | 313 +++++++++++++++++++ scripts/wiki-lint.test.mjs | 125 ++++++++ wiki/architecture/ai-call-guard.md | 41 +++ wiki/architecture/firestore-data-boundary.md | 39 +++ wiki/architecture/index.md | 5 + wiki/architecture/system-overview.md | 47 +++ wiki/decisions/index.md | 3 + wiki/decisions/wiki-location.md | 56 ++++ wiki/flashcard/deck-size-budget.md | 39 +++ wiki/flashcard/generation-paths.md | 49 +++ wiki/flashcard/index.md | 5 + wiki/flashcard/pregeneration.md | 60 ++++ wiki/index.md | 23 ++ wiki/log.md | 10 + 20 files changed, 964 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/wiki-lint.yml create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 scripts/wiki-lint.mjs create mode 100644 scripts/wiki-lint.test.mjs create mode 100644 wiki/architecture/ai-call-guard.md create mode 100644 wiki/architecture/firestore-data-boundary.md create mode 100644 wiki/architecture/index.md create mode 100644 wiki/architecture/system-overview.md create mode 100644 wiki/decisions/index.md create mode 100644 wiki/decisions/wiki-location.md create mode 100644 wiki/flashcard/deck-size-budget.md create mode 100644 wiki/flashcard/generation-paths.md create mode 100644 wiki/flashcard/index.md create mode 100644 wiki/flashcard/pregeneration.md create mode 100644 wiki/index.md create mode 100644 wiki/log.md diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 52b1bab..95ad30e 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -28,6 +28,7 @@ pnpm dev # FE (Vite) | `app` | React 18과 Vite 기반 프론트엔드. FSD 구조(`app`, `pages`, `widgets`, `features`, `entities`, `shared`) | | `functions` | Firebase Cloud Functions. GitHub 연동, AI 카드 생성, 알림 스케줄, 결제 | | `design-system` | 디자인 가이드 문서 | +| `wiki` | 설계 의도와 제약을 모은 LLM Wiki. 작성 규칙은 루트 [AGENTS.md](../AGENTS.md) | | `scripts` | 환경 변수 생성 등 보조 스크립트 | ## 코드 규칙 diff --git a/.github/workflows/wiki-lint.yml b/.github/workflows/wiki-lint.yml new file mode 100644 index 0000000..213a5cd --- /dev/null +++ b/.github/workflows/wiki-lint.yml @@ -0,0 +1,38 @@ +name: Wiki Lint + +on: + pull_request: + paths: + - "wiki/**" + - "scripts/wiki-lint*.mjs" + - "AGENTS.md" + - "package.json" + - "pnpm-lock.yaml" + - ".github/workflows/wiki-lint.yml" + push: + branches: [main] + paths: + - "wiki/**" + - "scripts/wiki-lint*.mjs" + # 코드가 그대로여도 stale_after는 날짜만 지나면 만료되므로 주기 실행으로 잡음 + schedule: + - cron: "0 0 * * 1" # 매주 월요일 09시 KST + workflow_dispatch: + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v7 + with: + node-version: 22 + cache: pnpm + # 위키 검사는 루트의 yaml만 쓰므로 app과 functions 의존성은 설치하지 않음 + - run: pnpm install --frozen-lockfile --filter "{.}" + - run: pnpm wiki:test + - run: pnpm wiki:lint diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d7650e7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,90 @@ +# CodeRecall 에이전트 규칙 + +이 파일은 이 저장소에서 일하는 코딩 에이전트의 정본 규칙입니다. 에이전트별 설정 파일(`CLAUDE.md` 등)은 이 파일을 불러오기만 하고 내용을 따로 두지 않습니다. + +## 저장소 + +| 경로 | 내용 | +|------|------| +| `app` | React 18과 Vite 기반 프론트엔드. FSD 구조 | +| `functions` | Firebase Cloud Functions | +| `firestore.rules` | Firestore 접근 경계 | +| `wiki` | LLM Wiki. 코드만 읽어서는 알기 어려운 설계 의도와 제약 | +| `design-system` | 디자인 가이드 | + +로컬 셋팅과 코드 규칙, 브랜치와 커밋 규칙은 [CONTRIBUTING.md](.github/CONTRIBUTING.md)를 따릅니다. + +## 작업 전 + +- 작업과 관련된 문서를 [wiki/index.md](wiki/index.md)에서 먼저 찾아 읽습니다. 원본 코드 전체를 다시 훑기 전에 위키부터 봅니다. +- 위키 내용과 코드가 다르면 코드가 현재 동작의 정본입니다. 어긋남을 작업 보고에 적고, 아래 `Ingest` 절차로 위키를 고칩니다. +- `status: draft`이거나 `verified`가 없는 문서는 사람이 확인하지 않은 내용입니다. 근거(`sources`)를 직접 열어 확인한 뒤 의존합니다. + +## 위키 구조 + +`wiki/`는 [OKF v0.2](https://github.com/GoogleCloudPlatform/open-knowledge-format) 번들입니다. 카파시 LLM Wiki 패턴의 세 계층은 이 저장소에서 아래와 같이 대응합니다. + +| 계층 | 위치 | 누가 쓰는가 | +|------|------|------| +| 원본 | 코드, `firestore.rules`, GitHub 이슈와 PR | 사람과 에이전트가 평소 작업으로 씀. 위키 작업 중에는 읽기만 함 | +| 위키 | `wiki/` | 에이전트가 쓰고 사람이 PR에서 리뷰 | +| 스키마 | 이 파일 | 사람이 정함 | + +- `index.md`는 디렉터리 목록이고 `log.md`는 변경 이력입니다. 두 이름은 개념 문서에 쓰지 않습니다. +- 개념 문서는 주제별 하위 디렉터리에 두고, 같은 디렉터리 `index.md`에 한 줄 설명과 함께 링크합니다. 새 하위 디렉터리를 만들면 상위 `index.md`에 링크합니다. +- 파일 이름은 `kebab-case.md`입니다. + +## 개념 문서 frontmatter + +OKF는 `type`만 필수지만 이 저장소는 신뢰 신호를 처음부터 강제합니다. `pnpm wiki:lint`가 검사합니다. + +```yaml +--- +type: Workflow # 필수. Architecture, Workflow, Data Rule, Security Boundary, Decision 등 +title: 오늘의 플래시카드 사전 생성 # 필수 +description: 한 문장 요약 # 필수 +tags: [flashcard, scheduler] # 선택 +status: draft # 필수. draft, stable, deprecated +sources: # 필수. 하나 이상 + - resource: ../../functions/src/flashcard-pregeneration.ts # 문서 기준 상대 경로. 존재해야 함 + - id: pr-88 + title: "feat(86): ..." + resource: https://github.com/chucoding/code-recall/pull/88 +generated: { by: claude-code/claude-opus-5-5, at: 2026-10-02T00:00:00Z } # 필수. <도구>/<모델> +verified: # 선택. stable이면 human: 항목 필수 + - { by: human:chucoding, at: 2026-10-03T00:00:00Z } +stale_after: 2027-01-02 # 필수. 이 날짜가 지나면 CI가 실패 +--- +``` + +- `stale_after`는 코드에서 파생한 문서는 작성일로부터 3개월, 결정 기록은 6개월을 기본으로 잡습니다. +- 사람이 PR 리뷰로 내용을 확인하면 `verified`에 `human:`를 추가하고 `status`를 `stable`로 올립니다. 에이전트는 사람 확인 없이 `stable`로 올리지 않습니다. +- 더 이상 맞지 않지만 링크와 이력 때문에 남길 문서는 `status: deprecated`로 바꾸고 대체 문서를 본문에 링크합니다. + +## 무엇을 쓰고 무엇을 쓰지 않는가 + +- 씁니다: 코드만 봐서는 알기 어려운 이유, 앱과 서버처럼 여러 곳을 함께 바꿔야 하는 값, 보안과 비용 경계, 되돌리기 어려운 결정 +- 쓰지 않습니다: 코드를 그대로 옮긴 설명, 함수 시그니처 목록, 커밋 이력으로 충분한 변경 내용 +- 저장소가 public이므로 비밀 값, 비용과 매출 수치, 사용자 개인정보, 운영 계정 정보는 쓰지 않습니다. 배경은 [위키를 코드 저장소에 두는 결정](wiki/decisions/wiki-location.md)에 있습니다. + +## 운영 동작 + +### Ingest + +코드나 규칙을 바꾸는 PR에서, 바뀐 사실을 다루는 위키 문서가 있으면 같은 PR에서 함께 고칩니다. + +1. `wiki/index.md`에서 영향받는 문서를 찾습니다. +2. 본문과 `sources`, `generated`를 갱신하고 `stale_after`를 다시 잡습니다. 내용이 바뀌면 기존 `verified`는 지우고 `status`를 `draft`로 내립니다. +3. 새 주제면 개념 문서를 만들고 디렉터리 `index.md`에 링크합니다. +4. `wiki/log.md` 맨 위 날짜 아래에 `**Creation**`, `**Update**`, `**Deprecation**` 중 하나로 시작하는 한 줄을 추가합니다. +5. `pnpm wiki:lint`를 통과시킵니다. + +### Query + +설계 질문에는 위키를 먼저 찾아 답하고, 답에 쓴 문서를 링크합니다. 위키에 없어 코드를 조사해 얻은 답이 다시 쓸 만하면 `Ingest`로 문서를 추가합니다. + +### Lint + +- 정적 검사: `pnpm wiki:lint`. frontmatter 필수 키, `sources` 경로 존재, 깨진 링크, `index.md` 누락, `log.md` 형식, `stale_after` 만료를 봅니다. PR과 매주 월요일 스케줄에서 CI가 실행합니다. +- 의미 검사: 요청을 받으면 문서끼리의 모순, 코드와 어긋난 주장, 고아 개념, 빠진 상호 참조를 점검해 수정 PR을 올립니다. +- `stale_after`가 지난 문서는 `sources`를 다시 읽어 내용을 확인한 뒤 날짜를 갱신합니다. 확인 없이 날짜만 미루지 않습니다. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/package.json b/package.json index 0c5eb8e..18a5b35 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,11 @@ "serve": "pnpm --filter functions build && firebase emulators:start --only functions", "dev": "pnpm --filter app dev", "build": "pnpm -r build", - "push": "firebase deploy" + "push": "firebase deploy", + "wiki:lint": "node scripts/wiki-lint.mjs", + "wiki:test": "node --test scripts/wiki-lint.test.mjs" + }, + "devDependencies": { + "yaml": "^2.9.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d518f7d..43289bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,11 @@ settings: importers: - .: {} + .: + devDependencies: + yaml: + specifier: ^2.9.1 + version: 2.9.1 app: dependencies: @@ -2347,6 +2351,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} @@ -6054,6 +6059,11 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yaml@2.9.1: + resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -13253,6 +13263,8 @@ snapshots: yallist@4.0.0: {} + yaml@2.9.1: {} + yargs-parser@21.1.1: {} yargs@17.7.2: diff --git a/scripts/wiki-lint.mjs b/scripts/wiki-lint.mjs new file mode 100644 index 0000000..94c6696 --- /dev/null +++ b/scripts/wiki-lint.mjs @@ -0,0 +1,313 @@ +import {existsSync, readdirSync, readFileSync, statSync} from "node:fs"; +import {dirname, join, relative, resolve, sep} from "node:path"; +import {fileURLToPath} from "node:url"; +import {parse} from "yaml"; + +/** + * 위키 정적 검사 + * + * `wiki/`는 OKF v0.2 번들이다. OKF 스펙은 `type`만 필수로 두지만, 이 저장소는 나중에 어떤 + * 도구로 옮겨도 신뢰 신호를 잃지 않도록 출처와 수명 필드를 처음부터 강제한다. 모순이나 오래된 + * 주장처럼 의미를 봐야 하는 점검은 LLM Lint가 맡고, 여기서는 기계적으로 판정할 수 있는 것만 본다. + */ + +/** OKF 예약 파일. 개념 문서가 아니라 목록과 이력이므로 frontmatter 검사에서 뺌 */ +const RESERVED_FILES = new Set(["index.md", "log.md"]); + +/** 개념 문서에 반드시 있어야 하는 frontmatter 키 */ +const REQUIRED_KEYS = ["type", "title", "description", "status", "sources", "generated", "stale_after"]; + +/** OKF v0.2 수명 상태 */ +const STATUS_VALUES = new Set(["draft", "stable", "deprecated"]); + +/** OKF 행위자 표기. `<도구>/<버전>`, `human:`, `process:` */ +const ACTOR_PATTERN = /^(human:[^\s]+|process:[^\s]+|[^\s:/]+\/[^\s]+)$/; + +/** `stale_after` 만료 전에 경고를 띄우는 기간 */ +const STALE_WARNING_DAYS = 14; + +/** 마크다운 인라인 링크. 이미지와 코드 안 링크는 호출부에서 걸러냄 */ +const LINK_PATTERN = /!?\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g; + +/** + * 검사 결과 한 건 + * + * @typedef {{level: "error" | "warning", file: string, message: string}} WikiLintIssue + */ + +/** + * 디렉터리 아래 마크다운 파일 전체를 찾음 + * + * @param {string} dir - 탐색할 디렉터리 + * @return {string[]} 마크다운 파일 절대 경로 목록 + */ +function listMarkdownFiles(dir) { + return readdirSync(dir).flatMap((name) => { + const path = join(dir, name); + if (statSync(path).isDirectory()) return listMarkdownFiles(path); + return name.endsWith(".md") ? [path] : []; + }); +} + +/** + * 문서를 frontmatter와 본문으로 나눔 + * + * @param {string} text - 문서 전체 + * @return {{frontmatter: string | null, body: string}} frontmatter가 없으면 null + */ +function splitFrontmatter(text) { + const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text); + if (!match) return {frontmatter: null, body: text}; + return {frontmatter: match[1], body: text.slice(match[0].length)}; +} + +/** + * 코드 블록과 인라인 코드를 지운 본문. 예시로 적은 링크를 검사하지 않기 위함 + * + * @param {string} body - 문서 본문 + * @return {string} 코드를 지운 본문 + */ +function stripCode(body) { + return body.replace(/```[\s\S]*?```/g, "").replace(/`[^`\n]*`/g, ""); +} + +/** + * 값이 날짜 또는 ISO 8601 문자열이면 Date로 바꿈 + * + * @param {unknown} value - frontmatter 값 + * @return {Date | null} 해석할 수 없으면 null + */ +function toDate(value) { + if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value; + if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}/.test(value)) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +/** + * 문서 안 링크가 가리키는 로컬 경로 + * + * `/`로 시작하면 번들 루트 기준, 그 밖은 문서 위치 기준이다(OKF 6.2). + * + * @param {string} target - 링크 대상 + * @param {string} file - 링크가 있는 문서 + * @param {string} bundleRoot - 번들 루트 + * @return {string | null} 외부 링크나 문서 내 앵커면 null + */ +function resolveLocalTarget(target, file, bundleRoot) { + if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("#")) return null; + const path = decodeURI(target.split("#")[0]); + if (!path) return null; + return path.startsWith("/") ? join(bundleRoot, path) : resolve(dirname(file), path); +} + +/** + * 개념 문서 frontmatter 검사 + * + * @param {Record} data - 해석한 frontmatter + * @param {string} file - 문서 경로 + * @param {string} bundleRoot - 번들 루트 + * @param {Date} now - 기준 시각 + * @return {{level: "error" | "warning", message: string}[]} 발견한 문제 + */ +function checkConcept(data, file, bundleRoot, now) { + const problems = []; + const error = (message) => problems.push({level: "error", message}); + + for (const key of REQUIRED_KEYS) { + if (data[key] === undefined || data[key] === null || data[key] === "") { + error(`frontmatter에 \`${key}\`가 없음`); + } + } + + if (data.status !== undefined && !STATUS_VALUES.has(data.status)) { + error(`\`status\`는 draft, stable, deprecated 중 하나여야 함: ${data.status}`); + } + + if (data.sources !== undefined) { + if (!Array.isArray(data.sources) || data.sources.length === 0) { + error("`sources`는 항목이 하나 이상인 목록이어야 함"); + } else { + data.sources.forEach((source, index) => { + const resource = source?.resource; + if (typeof resource !== "string" || !resource) { + error(`\`sources[${index}].resource\`가 없음`); + return; + } + const local = resolveLocalTarget(resource, file, bundleRoot); + if (local && !existsSync(local)) { + error(`\`sources[${index}].resource\` 경로가 없음: ${resource}`); + } + }); + } + } + + if (data.generated !== undefined) { + const {by, at} = data.generated ?? {}; + if (typeof by !== "string" || !ACTOR_PATTERN.test(by)) { + error("`generated.by`는 `<도구>/<버전>` 형식이어야 함"); + } + if (!toDate(at)) error("`generated.at`은 ISO 8601 시각이어야 함"); + } + + const verified = data.verified === undefined ? [] : [data.verified].flat(); + verified.forEach((entry, index) => { + if (typeof entry?.by !== "string" || !ACTOR_PATTERN.test(entry.by)) { + error(`\`verified[${index}].by\`는 \`human:\`, \`process:\`, \`<도구>/<버전>\` 중 하나여야 함`); + } + if (!toDate(entry?.at)) error(`\`verified[${index}].at\`은 ISO 8601 시각이어야 함`); + }); + + // stable은 사람이 확인한 문서에만 붙임. 기계 확인만으로 올리면 신뢰 단계가 섞임 + if (data.status === "stable" && !verified.some((entry) => String(entry?.by).startsWith("human:"))) { + error("`status: stable`은 `verified`에 `human:` 항목이 있어야 함"); + } + + if (data.stale_after !== undefined) { + const staleAfter = toDate(data.stale_after); + if (!staleAfter) { + error("`stale_after`는 ISO 8601 날짜여야 함"); + } else if (data.status !== "deprecated") { + const daysLeft = (staleAfter.getTime() - now.getTime()) / (24 * 60 * 60 * 1000); + if (daysLeft <= 0) { + error(`\`stale_after\`(${toIsoDate(staleAfter)})가 지남. 내용을 다시 확인하고 날짜를 갱신해야 함`); + } else if (daysLeft <= STALE_WARNING_DAYS) { + problems.push({level: "warning", message: `\`stale_after\`(${toIsoDate(staleAfter)})까지 ${Math.ceil(daysLeft)}일 남음`}); + } + } + } + + return problems; +} + +/** + * Date를 `YYYY-MM-DD`로 표기 + * + * @param {Date} date - 날짜 + * @return {string} ISO 날짜 + */ +function toIsoDate(date) { + return date.toISOString().slice(0, 10); +} + +/** + * `log.md` 검사. 날짜 제목이 `YYYY-MM-DD`이고 최신순인지 봄 + * + * @param {string} text - log.md 내용 + * @return {string[]} 발견한 문제 + */ +function checkLog(text) { + const problems = []; + const headings = [...stripCode(text).matchAll(/^##\s+(.+)$/gm)].map((match) => match[1].trim()); + + headings.forEach((heading) => { + if (!/^\d{4}-\d{2}-\d{2}$/.test(heading)) problems.push(`날짜 제목은 \`## YYYY-MM-DD\` 형식이어야 함: ${heading}`); + }); + + for (let i = 1; i < headings.length; i += 1) { + if (headings[i] >= headings[i - 1]) { + problems.push(`날짜 제목은 최신순이어야 함: ${headings[i - 1]} 다음에 ${headings[i]}`); + } + } + + return problems; +} + +/** + * 위키 번들 전체 검사 + * + * @param {string} bundleRoot - 번들 루트(`wiki/`) + * @param {{now?: Date}} [options] - 기준 시각. 테스트에서 고정할 때 씀 + * @return {WikiLintIssue[]} 발견한 문제 + */ +export function lintWiki(bundleRoot, {now = new Date()} = {}) { + /** @type {WikiLintIssue[]} */ + const issues = []; + const report = (level, file, message) => issues.push({level, file: relative(bundleRoot, file).split(sep).join("/"), message}); + + if (!existsSync(bundleRoot)) { + report("error", bundleRoot, "위키 디렉터리가 없음"); + return issues; + } + + const files = listMarkdownFiles(bundleRoot); + /** 각 문서가 링크한 로컬 경로. index 누락 검사에 씀 */ + const linkedFrom = new Map(); + + for (const file of files) { + const name = file.split(sep).pop(); + const text = readFileSync(file, "utf8"); + const {frontmatter, body} = splitFrontmatter(text); + + if (!RESERVED_FILES.has(name)) { + if (frontmatter === null) { + report("error", file, "YAML frontmatter가 없음"); + } else { + let data; + try { + data = parse(frontmatter); + } catch (parseError) { + report("error", file, `frontmatter YAML 해석 실패: ${parseError.message}`); + } + if (data && typeof data === "object") { + checkConcept(data, file, bundleRoot, now).forEach(({level, message}) => report(level, file, message)); + } else if (data !== undefined) { + report("error", file, "frontmatter가 키-값 형식이 아님"); + } + } + } + + if (name === "log.md") checkLog(text).forEach((message) => report("error", file, message)); + + const targets = []; + for (const match of stripCode(body).matchAll(LINK_PATTERN)) { + const local = resolveLocalTarget(match[1], file, bundleRoot); + if (!local) continue; + if (!existsSync(local)) { + report("error", file, `깨진 링크: ${match[1]}`); + continue; + } + targets.push(resolve(local)); + } + linkedFrom.set(resolve(file), targets); + } + + // 각 문서는 같은 디렉터리 index.md에서, 하위 index.md는 상위 index.md에서 찾을 수 있어야 함 + for (const file of files) { + const absolute = resolve(file); + const dir = dirname(absolute); + const name = absolute.split(sep).pop(); + if (name === "log.md" || (name === "index.md" && dir === resolve(bundleRoot))) continue; + + const parentIndex = name === "index.md" ? join(dirname(dir), "index.md") : join(dir, "index.md"); + if (!existsSync(parentIndex)) { + report("error", file, `${relative(bundleRoot, parentIndex).split(sep).join("/")}가 없어 이 문서를 찾을 수 없음`); + continue; + } + if (!(linkedFrom.get(resolve(parentIndex)) ?? []).includes(absolute)) { + report("error", file, `${relative(bundleRoot, parentIndex).split(sep).join("/")}에 이 문서 링크가 없음`); + } + } + + return issues; +} + +/** CLI 진입점. 오류가 하나라도 있으면 종료 코드 1 */ +function main() { + const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + const bundleRoot = resolve(repoRoot, process.argv[2] ?? "wiki"); + const issues = lintWiki(bundleRoot); + + for (const {level, file, message} of issues) { + const line = `${level === "error" ? "✖" : "⚠"} ${file}: ${message}`; + if (level === "error") console.error(line); + else console.warn(line); + } + + const errorCount = issues.filter((issue) => issue.level === "error").length; + const warningCount = issues.length - errorCount; + console.log(`위키 검사: 오류 ${errorCount}건, 경고 ${warningCount}건`); + if (errorCount > 0) process.exitCode = 1; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(); diff --git a/scripts/wiki-lint.test.mjs b/scripts/wiki-lint.test.mjs new file mode 100644 index 0000000..f961bef --- /dev/null +++ b/scripts/wiki-lint.test.mjs @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import {mkdirSync, mkdtempSync, rmSync, writeFileSync} from "node:fs"; +import {tmpdir} from "node:os"; +import {dirname, join} from "node:path"; +import {afterEach, beforeEach, describe, it} from "node:test"; +import {lintWiki} from "./wiki-lint.mjs"; + +/** 테스트 기준 시각 */ +const NOW = new Date("2026-10-02T00:00:00Z"); + +/** 검사를 통과하는 개념 문서 frontmatter */ +const VALID_FRONTMATTER = `--- +type: Architecture +title: 예시 +description: 예시 문서 +status: draft +sources: + - resource: ../code.ts +generated: { by: claude-code/claude-opus-5-5, at: 2026-10-02T00:00:00Z } +stale_after: 2027-01-01 +--- +`; + +describe("lintWiki", () => { + let root; + let bundle; + + /** + * 번들 안에 파일 작성 + * + * @param {string} path - 번들 루트 기준 경로 + * @param {string} content - 파일 내용 + */ + const write = (path, content) => { + const file = join(bundle, path); + mkdirSync(dirname(file), {recursive: true}); + writeFileSync(file, content); + }; + + /** + * 오류 메시지만 모음 + * + * @return {string[]} `파일: 메시지` 목록 + */ + const errors = () => lintWiki(bundle, {now: NOW}) + .filter((issue) => issue.level === "error") + .map((issue) => `${issue.file}: ${issue.message}`); + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "wiki-lint-")); + bundle = join(root, "wiki"); + writeFileSync(join(root, "code.ts"), ""); + write("index.md", "# 위키\n\n- [예시](concept.md)\n"); + write("log.md", "# 로그\n\n## 2026-10-02\n\n- 생성\n\n## 2026-09-30\n\n- 이전\n"); + write("concept.md", `${VALID_FRONTMATTER}\n# 본문\n`); + }); + + afterEach(() => rmSync(root, {recursive: true, force: true})); + + it("규칙을 지킨 번들은 오류가 없음", () => { + assert.deepEqual(errors(), []); + }); + + it("frontmatter 필수 키 누락을 잡음", () => { + write("concept.md", "---\ntype: Architecture\n---\n# 본문\n"); + const found = errors(); + for (const key of ["title", "description", "status", "sources", "generated", "stale_after"]) { + assert.ok(found.some((message) => message.includes(`\`${key}\``)), key); + } + }); + + it("frontmatter 없는 개념 문서를 잡음", () => { + write("concept.md", "# 본문\n"); + assert.deepEqual(errors(), ["concept.md: YAML frontmatter가 없음"]); + }); + + it("만료된 stale_after를 오류로, 임박한 stale_after를 경고로 봄", () => { + write("concept.md", VALID_FRONTMATTER.replace("2027-01-01", "2026-10-01")); + assert.ok(errors().some((message) => message.includes("stale_after") && message.includes("지남"))); + + write("concept.md", VALID_FRONTMATTER.replace("2027-01-01", "2026-10-10")); + const warnings = lintWiki(bundle, {now: NOW}).filter((issue) => issue.level === "warning"); + assert.equal(warnings.length, 1); + }); + + it("deprecated 문서는 stale_after 만료를 보지 않음", () => { + write("concept.md", VALID_FRONTMATTER.replace("2027-01-01", "2026-01-01").replace("draft", "deprecated")); + assert.deepEqual(errors(), []); + }); + + it("사람 확인 없는 stable을 잡음", () => { + write("concept.md", VALID_FRONTMATTER.replace("draft", "stable")); + assert.ok(errors().some((message) => message.includes("human:"))); + + write("concept.md", VALID_FRONTMATTER + .replace("draft", "stable") + .replace("stale_after", "verified:\n - { by: human:chucoding, at: 2026-10-02T00:00:00Z }\nstale_after")); + assert.deepEqual(errors(), []); + }); + + it("없는 source 경로를 잡음", () => { + write("concept.md", VALID_FRONTMATTER.replace("../code.ts", "../missing.ts")); + assert.ok(errors().some((message) => message.includes("missing.ts"))); + }); + + it("깨진 상대 링크와 번들 절대 링크를 잡고 코드 블록 안 링크는 무시함", () => { + write("concept.md", `${VALID_FRONTMATTER}\n[없음](nope.md) [절대](/nope.md)\n\n\`\`\`md\n[예시](ignored.md)\n\`\`\`\n`); + assert.deepEqual(errors(), ["concept.md: 깨진 링크: nope.md", "concept.md: 깨진 링크: /nope.md"]); + }); + + it("index.md에서 찾을 수 없는 문서를 잡음", () => { + write("orphan.md", `${VALID_FRONTMATTER}\n# 고아\n`); + write("sub/child.md", `${VALID_FRONTMATTER.replace("../code.ts", "../../code.ts")}\n# 자식\n`); + const found = errors(); + assert.ok(found.includes("orphan.md: index.md에 이 문서 링크가 없음")); + assert.ok(found.includes("sub/child.md: sub/index.md가 없어 이 문서를 찾을 수 없음")); + }); + + it("log.md 날짜 형식과 순서를 검사함", () => { + write("log.md", "# 로그\n\n## 2026-09-30\n\n## 2026-10-02\n\n## 어제\n"); + const found = errors(); + assert.ok(found.some((message) => message.includes("최신순"))); + assert.ok(found.some((message) => message.includes("어제"))); + }); +}); diff --git a/wiki/architecture/ai-call-guard.md b/wiki/architecture/ai-call-guard.md new file mode 100644 index 0000000..8fa8cd6 --- /dev/null +++ b/wiki/architecture/ai-call-guard.md @@ -0,0 +1,41 @@ +--- +type: Security Boundary +title: 공개 AI 함수 호출 경계 +description: 랜딩 데모 때문에 공개로 열린 AI 함수의 호출자별 한도와 비로그인 전체 상한 +tags: [ai, security, cost] +status: draft +sources: + - resource: ../../functions/src/ai-guard.ts + - resource: ../../functions/src/openai.ts + - resource: ../../functions/src/translateFlashcards.ts +generated: { by: claude-code/claude-opus-5-5, at: 2026-10-02T00:00:00Z } +stale_after: 2027-01-02 +--- + +# 왜 필요한가 + +랜딩 데모는 비로그인 방문자가 써야 해서 `openaiChatCompletions`와 `translateFlashcards`는 `invoker: "public"`을 유지합니다. 함수 URL은 빌드 산출물에 문자열로 들어가고 형식만 알면 조합도 되므로, 주소를 감추는 방식으로는 보호되지 않습니다. 그래서 모델을 호출하기 전에 `consumeAiQuota`가 서버 소유 카운터로 막습니다. + +# 두 겹의 경계 + +| 경계 | 키 | 상한 | +|------|------|------| +| 호출자별 일일 한도 | 로그인은 uid, 비로그인은 IP 해시 | 비로그인 30, Free 100, Pro 300 | +| 비로그인 전체 일일 상한 | 단일 카운터 | 500 | + +- 비로그인 키로 쓰는 IP는 `X-Forwarded-For`에서 읽어 위조할 수 있습니다. 위조하면 호출자별 한도는 우회되지만 전체 상한은 키와 무관해 우회되지 않습니다. 하루 모델 비용의 천장이 전체 상한입니다. +- 로그인 호출에는 전체 상한을 걸지 않습니다. 걸면 비로그인 호출을 쏟아부어 로그인 사용자의 카드 생성까지 멈출 수 있습니다. +- 원본 IP는 저장하지 않고 해시만 문서 ID로 씁니다. +- 날짜 경계는 KST 기준입니다. + +# 입력 길이 상한 + +| 상수 | 값 | 적용 | +|------|------|------| +| `MAX_FLASHCARD_PROMPT_CHARS` | 30,000자 | 카드 생성 프롬프트 본문. 넘으면 거부하지 않고 자름 | +| `MAX_TRANSLATE_CARDS` | 200장 | 번역 요청 한 건 | +| `MAX_TRANSLATE_PROMPT_CHARS` | 120,000자 | 번역 프롬프트 본문 | + +# 남은 것 + +위조 요청을 말단에서 거부하려면 App Check가 필요합니다. Firebase 콘솔의 reCAPTCHA 등록과 enforcement 설정이 함께 있어야 해서 이 경계와 별도 작업입니다. diff --git a/wiki/architecture/firestore-data-boundary.md b/wiki/architecture/firestore-data-boundary.md new file mode 100644 index 0000000..4e8af12 --- /dev/null +++ b/wiki/architecture/firestore-data-boundary.md @@ -0,0 +1,39 @@ +--- +type: Security Boundary +title: Firestore 데이터 경계 +description: 클라이언트가 쓸 수 있는 users 필드와 Admin SDK만 쓰는 서버 전용 컬렉션을 나눈 이유 +tags: [firestore, security, rules] +status: draft +sources: + - resource: ../../firestore.rules + - resource: ../../functions/src/ai-guard.ts + - resource: ../../functions/src/regenerateQuestion.ts + - id: pr-81 + title: "fix(77): users 문서 쓰기를 소유 필드로 한정하고 사용량 카운터 분리" + resource: https://github.com/chucoding/code-recall/pull/81 +generated: { by: claude-code/claude-opus-5-5, at: 2026-10-02T00:00:00Z } +stale_after: 2027-01-02 +--- + +# 규칙 요약 + +- `users/{uid}`는 본인만 읽고, 쓰기는 `userWritableFields()` 목록 안의 필드만 허용합니다. +- 구독 등급(`subscriptionTier`, `subscriptionPeriodEnd`, `stripeCustomerId`)은 서버가 한도를 고를 때 쓰는 값이라 Stripe 웹훅만 씁니다. +- 갱신 검사는 이번 쓰기가 바꾸는 키(`affectedKeys`)만 봅니다. 문서 전체를 검사하면 과거 버전이 남긴 값 하나 때문에 그 사용자의 모든 쓰기가 막히기 때문입니다. +- `updatedAt`이 문자열과 timestamp를 모두 받는 이유는 PWA 서비스 워커 캐시로 과거 번들이 배포 뒤에도 남기 때문입니다. + +# 서버 전용 컬렉션 + +규칙에 `match` 블록이 없으면 기본 거부라 Admin SDK만 읽고 씁니다. + +| 컬렉션 | 용도 | +|------|------| +| `aiUsage` | AI 호출 일일 카운터. [공개 AI 함수 호출 경계](ai-call-guard.md) | +| `demoRegenerateCounts` | 비로그인 데모의 질문 재생성 카운터 | +| `flashcardPregeneration` | 사용자별 사전 생성 완료 기록 | + +`regenerateCounts`는 남은 횟수 표시에 필요해 본인 읽기만 열고 쓰기는 열지 않습니다. + +# 사용량을 users 문서에 두지 않는 이유 + +`users/{uid}`는 본인 삭제가 열려 있습니다. 카운터를 거기 두면 쓸 수 있는 필드를 좁혀도 문서를 지웠다가 다시 만드는 것만으로 한도가 초기화됩니다. 그래서 사용량은 사용자가 손댈 수 없는 컬렉션에 둡니다. 새 서버 전용 컬렉션을 만들 때도 `firestore.rules`에 `match` 블록을 추가하지 않습니다. diff --git a/wiki/architecture/index.md b/wiki/architecture/index.md new file mode 100644 index 0000000..d62e254 --- /dev/null +++ b/wiki/architecture/index.md @@ -0,0 +1,5 @@ +# 아키텍처 + +- [시스템 구성](system-overview.md): 프론트엔드, Cloud Functions, Firestore가 나눠 맡는 일과 스케줄 작업 목록 +- [Firestore 데이터 경계](firestore-data-boundary.md): 클라이언트가 쓸 수 있는 필드와 서버 전용 컬렉션을 나눈 이유 +- [공개 AI 함수 호출 경계](ai-call-guard.md): 비로그인 방문자에게 열린 AI 함수의 비용 상한 diff --git a/wiki/architecture/system-overview.md b/wiki/architecture/system-overview.md new file mode 100644 index 0000000..f99d972 --- /dev/null +++ b/wiki/architecture/system-overview.md @@ -0,0 +1,47 @@ +--- +type: Architecture +title: 시스템 구성 +description: 프론트엔드, Cloud Functions, Firestore가 나눠 맡는 일과 스케줄 작업 목록 +tags: [architecture, firebase, functions] +status: draft +sources: + - resource: ../../functions/src/index.ts + - resource: ../../firebase.json + - resource: ../../functions/src/schedule.ts + - resource: ../../functions/src/trending.ts + - resource: ../../functions/src/flashcard-pregeneration.ts +generated: { by: claude-code/claude-opus-5-5, at: 2026-10-02T00:00:00Z } +stale_after: 2027-01-02 +--- + +# 구성 + +```mermaid +flowchart LR + Browser["app (React PWA)"] -->|HTTP onRequest| Functions["functions (Cloud Functions v2)"] + Browser -->|읽기, 소유 필드 쓰기| Firestore[(Firestore)] + Functions -->|Admin SDK| Firestore + Functions --> GitHub[GitHub API] + Functions --> OpenAI[OpenAI] + Scheduler[Cloud Scheduler] --> Functions + Functions -->|FCM| Browser +``` + +| 영역 | 경로 | 맡는 일 | +|------|------|------| +| 프론트엔드 | `app` | 랜딩(`/`)과 앱(`/app`)을 별도 HTML 진입점으로 빌드해 Firebase Hosting에 배포 | +| 서버 | `functions` | GitHub 조회, AI 카드 생성과 번역, 질문 재생성, 스케줄 작업. 리전은 `asia-northeast3` | +| 데이터 | `firestore.rules` | 클라이언트 접근 경계. 자세한 내용은 [Firestore 데이터 경계](firestore-data-boundary.md) | + +`functions/src/index.ts`가 `setGlobalOptions({maxInstances: 10})`로 함수별 최대 컨테이너 수를 묶어 비용을 제한합니다. Stripe 결제 함수는 아직 export하지 않습니다. + +# 스케줄 작업 + +| 함수 | 주기 | 하는 일 | +|------|------|------| +| `sendDaily8amPush` | 매시 정각 | 사용자 타임존의 현재 시가 `preferredPushHour`(기본 8)와 같은 사용자에게 FCM 발송. 알림 클릭 시 `/app`을 연다 | +| `scheduleFlashcardPregeneration` | 매시 정각 | 로컬 0시부터 6시 사이 사용자의 오늘 덱 생성 태스크 등록. [오늘의 플래시카드 사전 생성](../flashcard/pregeneration.md) | +| `refreshTrendingRepos` | 매일 09시 KST | GitHub Trending 10곳 수집, 랜딩 데모 카드 사전 생성 | +| `cleanupDeletedUsers` | 매일 12시 KST | 오늘 이전의 탈퇴 기록 삭제. 탈퇴 당일만 재가입을 막음 | + +매시 정각 작업이 전 타임존을 하루 24회로 덮는 방식이라, 사용자별 시각 판정은 각 함수가 타임존을 직접 계산합니다. diff --git a/wiki/decisions/index.md b/wiki/decisions/index.md new file mode 100644 index 0000000..606902b --- /dev/null +++ b/wiki/decisions/index.md @@ -0,0 +1,3 @@ +# 결정 기록 + +- [위키를 코드 저장소에 두는 결정](wiki-location.md): LLM Wiki를 별도 저장소가 아닌 이 저장소 `wiki/`에 둔 이유 diff --git a/wiki/decisions/wiki-location.md b/wiki/decisions/wiki-location.md new file mode 100644 index 0000000..ac04d69 --- /dev/null +++ b/wiki/decisions/wiki-location.md @@ -0,0 +1,56 @@ +--- +type: Decision +title: 위키를 코드 저장소에 두는 결정 +description: LLM Wiki를 별도 저장소가 아닌 이 저장소 wiki/에 OKF v0.2 번들로 둔 이유와 감수한 점 +tags: [wiki, decision, agents] +status: draft +sources: + - id: issue-94 + title: LLM Wiki 구축 + resource: https://github.com/chucoding/code-recall/issues/94 + - id: karpathy-llm-wiki + title: Karpathy, LLM Wiki + resource: https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f + - id: okf-spec + title: Open Knowledge Format v0.2 + resource: https://github.com/GoogleCloudPlatform/open-knowledge-format + - id: openwiki + title: LangChain OpenWiki + resource: https://github.com/langchain-ai/openwiki + - id: docs-as-code-topologies + title: Docs-as-code topologies + resource: https://passo.uno/docs-as-code-topologies/ +generated: { by: claude-code/claude-opus-5-5, at: 2026-10-02T00:00:00Z } +stale_after: 2027-04-02 +--- + +# 결정 + +LLM Wiki를 이 저장소의 `wiki/` 디렉터리에 OKF v0.2 번들로 둡니다. 에이전트 규칙(카파시 패턴의 schema 계층)은 루트 `AGENTS.md`가 정본이고, `CLAUDE.md`는 그 파일을 불러오기만 합니다. + +# 검토한 선택지 + +| 선택지 | 장점 | 단점 | +|------|------|------| +| 이 저장소 `wiki/` (채택) | 코드와 문서를 한 PR에서 고쳐 어긋남을 근원에서 막음. 에이전트가 파일을 직접 읽어 MCP가 필요 없음 | public 저장소라 위키 내용이 공개됨 | +| 별도 private 저장소 | 비공개 기획과 운영 지식을 넣을 수 있음 | 코드 변경과 문서 갱신이 다른 PR로 갈라짐. 에이전트 연결에 경로 지정이나 MCP가 필요 | +| 혼합 | 공개와 비공개를 나눌 수 있음 | 처음부터 관리할 정본이 둘 | + +# 근거 + +- 카파시 원문은 위키를 "마크다운 파일로 된 git 저장소"로 설명하고 개인 지식 베이스를 전제합니다. 위치를 강제하지 않습니다. +- OKF v0.2는 번들의 위치를 정하지 않고, 상대 경로와 번들 루트 기준 경로를 모두 링크로 인정합니다. 코드 파일을 `sources`로 가리킬 수 있습니다. +- OpenWiki는 코드 저장소 안 `openwiki/`에 문서와 근거 메타데이터를 두는 것이 기본입니다. 나중에 3단계로 붙여도 같은 저장소에서 동작합니다. +- 같은 저장소에 문서를 두는 sidecar 방식은 문서가 코드와 함께 버전 관리되어, 변경이 잦은 초기 제품에 맞습니다. +- 이 프로젝트는 혼자 운영해 기획자가 따로 관리하는 정책 문서가 없습니다. 별도 저장소로 나눌 이유였던 "기획자 소유 지식"이 아직 없습니다. + +# 감수한 점과 지킬 규칙 + +- 저장소가 public이므로 비밀 값, 비용과 매출 수치, 사용자 개인정보, 운영 계정 정보는 위키에 쓰지 않습니다. 이런 지식이 필요해지면 그때 별도 private 저장소를 만들고 이 결정을 갱신합니다. +- 학습 기록(TIL)은 이 위키가 아니라 `today-i-learned` 저장소에 둡니다. 이 위키는 CodeRecall 제품 지식만 다룹니다. + +# 다시 검토할 시점 + +- 비공개로 다뤄야 할 정책이나 운영 지식이 생길 때 +- 저장소가 여러 개로 나뉠 때. 그때는 위키 MCP 서버와 코드 그래프 도입을 함께 검토합니다 +- 문서가 수백 개를 넘어 `index.md`만으로 찾기 어려울 때. 그때는 qmd 검색을 붙입니다 diff --git a/wiki/flashcard/deck-size-budget.md b/wiki/flashcard/deck-size-budget.md new file mode 100644 index 0000000..beb9941 --- /dev/null +++ b/wiki/flashcard/deck-size-budget.md @@ -0,0 +1,39 @@ +--- +type: Data Rule +title: 덱 문서 크기 예산 +description: 언어별 덱 두 개를 Firestore 문서 상한 1MB 안에 함께 넣기 위한 크기 규칙 +tags: [flashcard, firestore, limits] +status: draft +sources: + - resource: ../../functions/src/flashcard-deck-size.ts + - resource: ../../app/src/features/flashcard/lib/deck-size.ts + - id: pr-83 + title: "fix(82): 카드 덱을 문서 크기 예산 안으로 줄여 저장" + resource: https://github.com/chucoding/code-recall/pull/83 +generated: { by: claude-code/claude-opus-5-5, at: 2026-10-02T00:00:00Z } +stale_after: 2027-01-02 +--- + +# 규칙 + +`users/{uid}/flashcards/{YYYY-MM-DD}` 문서 하나에 `data_ko`와 `data_en` 두 덱이 함께 들어갑니다. 카드마다 원문 diff를 담으면 큰 커밋 하나로 문서가 Firestore 상한 1MB를 넘어 저장이 실패합니다. + +| 상수 | 값 | 이유 | +|------|------|------| +| `MAX_FLASHCARD_DOC_BYTES` | 900,000 | 1MB 대비 필드 이름과 인덱스 여유 | +| 덱 하나의 예산 | 450,000 | 두 언어 덱이 한 문서에 들어가므로 절반 | +| `MAX_RAW_DIFF_CHARS` | 30,000 | 생성 프롬프트 본문 상한과 같음 | +| `MIN_RAW_DIFF_CHARS` | 1,000 | 이보다 짧게 줄여야 하면 diff를 저장하지 않음 | +| `MAX_FILES_PER_CARD` | 20 | 파일 보기 탭 목록에만 쓰임 | + +# 줄이는 순서 + +1. `files[].patch`는 `rawDiff`와 겹쳐 항상 뺍니다. +2. diff 길이를 30,000자에서 시작해 절반씩 줄이며 예산에 들어오는 첫 길이를 씁니다. +3. 1,000자까지 줄여도 넘치면 diff 없이 저장합니다. + +diff를 자를 때 코드 블록 중간이면 블록을 닫고 `…(truncated)`를 붙여 마크다운이 깨지지 않게 합니다. + +# 주의 + +같은 규칙이 앱(`app/src/features/flashcard/lib/deck-size.ts`)과 서버(`functions/src/flashcard-deck-size.ts`)에 따로 있습니다. 두 경로가 같은 문서에 언어별 덱을 나눠 쓰므로 한쪽 상한을 바꾸면 다른 쪽도 같은 PR에서 바꿉니다. diff --git a/wiki/flashcard/generation-paths.md b/wiki/flashcard/generation-paths.md new file mode 100644 index 0000000..f585a51 --- /dev/null +++ b/wiki/flashcard/generation-paths.md @@ -0,0 +1,49 @@ +--- +type: Architecture +title: 카드 생성 경로 +description: 플래시카드를 만드는 세 경로와 공용 모듈로 묶은 단계 +tags: [flashcard, ai, github] +status: draft +sources: + - resource: ../../functions/src/flashcard-generation.ts + - resource: ../../functions/src/openai.ts + - resource: ../../functions/src/demo-flashcards.ts + - resource: ../../functions/src/flashcard-pregeneration.ts + - resource: ../../app/src/features/flashcard/model/useTodayFlashcards.ts +generated: { by: claude-code/claude-opus-5-5, at: 2026-10-02T00:00:00Z } +stale_after: 2027-01-02 +--- + +# 세 경로 + +| 경로 | 진입점 | 실행 시점 | 저장 위치 | +|------|------|------|------| +| 실시간 생성 | 앱 `useTodayFlashcards`가 `openaiChatCompletions` 호출 | 사용자가 앱을 열었는데 오늘 덱이 없을 때 | `users/{uid}/flashcards/{YYYY-MM-DD}` | +| 사용자 사전 생성 | `pregenerateUserFlashcards` | 사용자 타임존 새벽 | 같은 문서 | +| 랜딩 데모 사전 생성 | `refreshTrendingRepos`가 `pregenerateDemoFlashcards` 호출 | 매일 09시 KST | `demoFlashcards` | + +세 경로는 실행 시점과 커밋 선택, 저장 위치가 달라 그 부분은 각자 둡니다. GitHub 조회, diff 구성, AI 호출처럼 같아야 하는 단계만 `functions/src/flashcard-generation.ts`에 모았습니다. 따로 두면 모델이나 프롬프트를 바꿀 때 한 경로만 바뀐 채 남기 때문입니다. + +# 공용 단계 + +```mermaid +flowchart LR + A[fetchGitHub / fetchCommitDetail] --> B[formatCommitDiff] + B --> C[requestFlashcardCompletion] + C --> D[parseFlashcardItems] +``` + +- `formatCommitDiff`는 앱 `formatCodeDiff`와 같은 마크다운 형식을 만듭니다. 카드 뒷면 Diff 보기와 AI 입력에 함께 씁니다. +- `requestFlashcardCompletion`은 OpenAI Structured Outputs(`FLASHCARD_RESPONSE_SCHEMA`)로 `question`, `answer`, `highlights` 형식을 보장합니다. 이 스키마는 앱 타입과 함께 바꿔야 합니다. +- 추론 강도는 `low`, 출력 상한은 8,192토큰입니다. 실시간 생성은 앱이 응답을 기다리고, 데모 사전 생성은 저장소 10곳을 540초 안에 처리해야 하기 때문입니다. +- GitHub 401은 `GitHubAuthError`로 구분합니다. 토큰 무효는 재시도해도 바뀌지 않아 호출부가 다르게 처리합니다. + +# 앱과 서버가 함께 바꿔야 하는 값 + +app과 functions는 따로 빌드되는 패키지라 코드를 공유하지 못합니다. 아래 값은 양쪽에 복사되어 있어 한쪽만 바꾸면 어긋납니다. + +| 값 | 앱 | 서버 | +|------|------|------| +| 등급별 복습 간격 (Free 1, 7일 전 / Pro 1, 7, 30일 전) | `useTodayFlashcards.ts` | `flashcard-pregeneration.ts` | +| 덱 크기 예산 | `features/flashcard/lib/deck-size.ts` | `flashcard-deck-size.ts`. [덱 문서 크기 예산](deck-size-budget.md) | +| 카드 응답 스키마 | 앱 types | `FLASHCARD_RESPONSE_SCHEMA` | diff --git a/wiki/flashcard/index.md b/wiki/flashcard/index.md new file mode 100644 index 0000000..b7d37dd --- /dev/null +++ b/wiki/flashcard/index.md @@ -0,0 +1,5 @@ +# 플래시카드 + +- [카드 생성 경로](generation-paths.md): 카드를 만드는 세 경로와 공용 모듈로 묶은 단계 +- [오늘의 플래시카드 사전 생성](pregeneration.md): 사용자 자정에 서버가 그날 덱을 미리 만드는 흐름 +- [덱 문서 크기 예산](deck-size-budget.md): 언어별 덱을 Firestore 문서 상한 안에 넣는 규칙 diff --git a/wiki/flashcard/pregeneration.md b/wiki/flashcard/pregeneration.md new file mode 100644 index 0000000..d93b350 --- /dev/null +++ b/wiki/flashcard/pregeneration.md @@ -0,0 +1,60 @@ +--- +type: Workflow +title: 오늘의 플래시카드 사전 생성 +description: 사용자 타임존 새벽에 서버가 그날 덱을 미리 만들어 앱 첫 화면 대기를 없애는 흐름 +tags: [flashcard, scheduler, cloud-tasks] +status: draft +sources: + - resource: ../../functions/src/flashcard-pregeneration.ts + - resource: ../../functions/src/timezone.ts + - id: pr-88 + title: "feat(86): 오늘의 플래시카드를 사용자 자정에 서버에서 미리 생성" + resource: https://github.com/chucoding/code-recall/pull/88 +generated: { by: claude-code/claude-opus-5-5, at: 2026-10-02T00:00:00Z } +stale_after: 2027-01-02 +--- + +# 왜 만들었나 + +앱을 켤 때 브라우저가 저장소와 날짜마다 GitHub 조회와 AI 생성을 순차로 기다려 첫 화면이 느렸습니다. 사용자 타임존의 자정이 지나면 서버가 그날 덱을 미리 만들어 두고, 앱은 그 문서를 읽기만 합니다. 사전 생성이 없거나 실패하면 앱이 기존처럼 [실시간 생성](generation-paths.md)으로 이어갑니다. + +# 흐름 + +```mermaid +sequenceDiagram + participant S as scheduleFlashcardPregeneration (매시 정각) + participant Q as Cloud Tasks 큐 + participant T as pregenerateUserFlashcards + participant F as Firestore + S->>F: users 중 language, timezone, repositories가 있는 사용자 조회 + S->>S: 로컬 0~6시, 미완료, 최근 7일 활동 사용자로 좁힘 + S->>Q: 태스크 등록 (ID: uid-날짜-h시각) + Q->>T: 사용자 한 명씩 실행 (동시 5개) + T->>F: 오늘 덱이 이미 있으면 exists로 기록하고 종료 + T->>T: 저장소 × 복습 간격 병렬 생성 + T->>F: 트랜잭션에서 덱이 없을 때만 저장 + T->>F: flashcardPregeneration에 완료 기록 +``` + +# 설계 판단 + +| 판단 | 이유 | +|------|------| +| 스케줄러와 태스크 두 단계 | 사용자가 늘어도 스케줄러는 대상 선정만 하고, OpenAI 동시 호출은 큐의 동시 실행 상한(5)으로 조절 | +| 대상을 자정 한 시각이 아닌 0시부터 6시 구간으로 잡음 | 스케줄러가 몇 시각 실패해도 사용자가 일어나기 전에 따라잡게 함 | +| 태스크 ID에 시각을 넣음 | 같은 시각의 재시도는 ID가 겹쳐 중복 등록되지 않고, 다음 시각에는 새 ID로 놓친 사용자를 다시 잡음 | +| 최근 7일 활동 사용자만 대상 | 유휴 계정의 AI 비용을 막음. 활동 시각은 Auth의 토큰 갱신 시각(`lastRefreshTime`)을 씀 | +| `timezone`이나 `language`가 없으면 제외 | 새 앱을 한 번도 열지 않은 사용자라 언어를 추정해 틀린 덱을 만들지 않음 | +| 완료 기록 컬렉션을 따로 둠 | 덱 문서만으로는 "커밋이 없어 카드가 없음"과 "아직 안 만듦"을 구분할 수 없음 | +| 저장을 트랜잭션에서 다시 확인 | 생성 중 사용자가 앱을 열어 앱이 먼저 저장할 수 있음 | + +# 완료 사유 + +| 값 | 뜻 | +|------|------| +| `saved` | 덱을 저장함 | +| `exists` | 앱이 먼저 덱을 만들어 둠 | +| `empty` | 해당 날짜 커밋이 없어 만들 카드가 없음 | +| `unavailable` | GitHub 토큰 무효나 설정 없음처럼 재시도해도 바뀌지 않는 문제 | + +일시 오류로 카드를 하나도 못 만들면 완료 기록 없이 예외를 던집니다. 태스크 큐가 최대 3회 재시도하고, 그래도 실패하면 구간 안의 다음 정각에 새 태스크로 다시 시도합니다. 일부 조합만 실패해 덱이 비었을 때도 `empty`로 확정하지 않습니다. 실패한 조합에 커밋이 있었을 수 있기 때문입니다. diff --git a/wiki/index.md b/wiki/index.md new file mode 100644 index 0000000..b18de73 --- /dev/null +++ b/wiki/index.md @@ -0,0 +1,23 @@ +--- +okf_version: "0.2" +--- + +# CodeRecall 위키 + +코드만 읽어서는 알기 어려운 "왜 이렇게 만들었는가"를 모아 두는 지식 위키입니다. 에이전트는 작업 전에 이 목록에서 관련 문서를 먼저 찾고, 작업으로 사실이 바뀌면 같은 PR에서 문서를 고칩니다. 운영 규칙은 저장소 루트의 `AGENTS.md`에 있습니다. + +## 아키텍처 + +- [아키텍처](architecture/index.md): 시스템 구성과 데이터 경계 + +## 플래시카드 + +- [플래시카드](flashcard/index.md): 카드 생성 경로, 서버 사전 생성, 문서 크기 예산 + +## 결정 기록 + +- [결정 기록](decisions/index.md): 되돌리기 어려운 구조 결정과 그 근거 + +## 이력 + +- [log.md](log.md): 위키 변경 이력 diff --git a/wiki/log.md b/wiki/log.md new file mode 100644 index 0000000..cd478e2 --- /dev/null +++ b/wiki/log.md @@ -0,0 +1,10 @@ +# 위키 변경 이력 + +새 항목은 맨 위에 추가합니다. 날짜 제목은 `## YYYY-MM-DD` 형식이고, 항목은 `**Creation**`, `**Update**`, `**Deprecation**` 중 하나로 시작합니다. + +## 2026-10-02 + +- **Creation** 위키 번들 골격과 초기 개념 문서 7건 작성 (#94) + - [시스템 구성](architecture/system-overview.md), [Firestore 데이터 경계](architecture/firestore-data-boundary.md), [공개 AI 함수 호출 경계](architecture/ai-call-guard.md) + - [카드 생성 경로](flashcard/generation-paths.md), [오늘의 플래시카드 사전 생성](flashcard/pregeneration.md), [덱 문서 크기 예산](flashcard/deck-size-budget.md) + - [위키를 코드 저장소에 두는 결정](decisions/wiki-location.md) From fe89313aca916a87fd334c5b658c05d7d41869a4 Mon Sep 17 00:00:00 2001 From: chucoding Date: Fri, 2 Oct 2026 00:48:45 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat(94):=20=EC=BD=94=EB=93=9C=20=ED=8C=8C?= =?UTF-8?q?=EC=83=9D=20=EC=9C=84=ED=82=A4=20=EB=AC=B8=EC=84=9C=EC=9D=98=20?= =?UTF-8?q?=EC=8B=A0=EC=84=A0=EB=8F=84=EB=A5=BC=20=EC=B6=9C=EC=B2=98=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=EC=9C=BC=EB=A1=9C=20=ED=8C=90=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 모든 문서에 3개월 stale_after를 강제하면, 코드와 어긋난 문서는 만료일까지 잡히지 않고 바뀌지 않은 문서는 주기적으로 실패한다. 게다가 PR 트리거가 wiki/ 경로로만 걸려 있어, 코드만 고친 PR에서는 검사가 아예 돌지 않았다. 그래서 저장소 안 출처가 있는 문서는 출처 파일이 문서보다 나중에 커밋됐는지로 판정하고, 커밋 없이 바뀌는 외부 사실 문서에만 stale_after를 남겼다. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/wiki-lint.yml | 16 +-- AGENTS.md | 15 ++- scripts/wiki-lint.mjs | 115 +++++++++++++++++-- scripts/wiki-lint.test.mjs | 74 +++++++++++- wiki/architecture/ai-call-guard.md | 1 - wiki/architecture/firestore-data-boundary.md | 1 - wiki/architecture/system-overview.md | 1 - wiki/flashcard/deck-size-budget.md | 1 - wiki/flashcard/generation-paths.md | 1 - wiki/flashcard/pregeneration.md | 1 - wiki/log.md | 1 + 11 files changed, 191 insertions(+), 36 deletions(-) diff --git a/.github/workflows/wiki-lint.yml b/.github/workflows/wiki-lint.yml index 213a5cd..579f960 100644 --- a/.github/workflows/wiki-lint.yml +++ b/.github/workflows/wiki-lint.yml @@ -1,20 +1,11 @@ name: Wiki Lint on: + # 코드만 고친 PR도 위키 문서의 출처를 바꿀 수 있어 경로 필터를 두지 않음 pull_request: - paths: - - "wiki/**" - - "scripts/wiki-lint*.mjs" - - "AGENTS.md" - - "package.json" - - "pnpm-lock.yaml" - - ".github/workflows/wiki-lint.yml" push: branches: [main] - paths: - - "wiki/**" - - "scripts/wiki-lint*.mjs" - # 코드가 그대로여도 stale_after는 날짜만 지나면 만료되므로 주기 실행으로 잡음 + # 저장소 밖 사실에 기대는 문서는 커밋 없이도 낡으므로 stale_after 만료를 주기 실행으로 잡음 schedule: - cron: "0 0 * * 1" # 매주 월요일 09시 KST workflow_dispatch: @@ -27,6 +18,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + with: + # 출처 변경 감지가 파일별 마지막 커밋 시각을 비교하므로 전체 이력이 필요 + fetch-depth: 0 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v7 with: diff --git a/AGENTS.md b/AGENTS.md index d7650e7..8697b94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,11 +53,18 @@ sources: # 필수. 하나 이상 generated: { by: claude-code/claude-opus-5-5, at: 2026-10-02T00:00:00Z } # 필수. <도구>/<모델> verified: # 선택. stable이면 human: 항목 필수 - { by: human:chucoding, at: 2026-10-03T00:00:00Z } -stale_after: 2027-01-02 # 필수. 이 날짜가 지나면 CI가 실패 +stale_after: 2027-04-02 # 저장소 안 출처가 없는 문서만 필수. 이 날짜가 지나면 CI가 실패 --- ``` -- `stale_after`는 코드에서 파생한 문서는 작성일로부터 3개월, 결정 기록은 6개월을 기본으로 잡습니다. +- 문서가 낡았는지는 출처 종류에 따라 두 신호로 봅니다. + +| 출처 | 신호 | 동작 | +|------|------|------| +| 저장소 안 파일이 하나라도 있음 | 출처 변경 감지 | 출처 파일의 마지막 커밋이 문서의 마지막 커밋보다 나중이면 CI가 실패. `stale_after`는 쓰지 않음 | +| 외부 URL만 있음 (외부 스펙, 서비스 제한, 결정 기록) | `stale_after` | 커밋 없이도 바뀌므로 날짜로 재확인. 작성일로부터 6개월을 기본으로 잡음 | + +- 출처 변경 감지에 걸리면 출처를 다시 읽고, 내용이 달라졌으면 본문을 고칩니다. 내용이 그대로면 `verified`에 확인 기록을 추가합니다. 둘 다 문서 파일을 바꾸므로 다음 검사에서 통과합니다. - 사람이 PR 리뷰로 내용을 확인하면 `verified`에 `human:`를 추가하고 `status`를 `stable`로 올립니다. 에이전트는 사람 확인 없이 `stable`로 올리지 않습니다. - 더 이상 맞지 않지만 링크와 이력 때문에 남길 문서는 `status: deprecated`로 바꾸고 대체 문서를 본문에 링크합니다. @@ -74,7 +81,7 @@ stale_after: 2027-01-02 # 필수. 이 날짜가 지나면 CI가 실패 코드나 규칙을 바꾸는 PR에서, 바뀐 사실을 다루는 위키 문서가 있으면 같은 PR에서 함께 고칩니다. 1. `wiki/index.md`에서 영향받는 문서를 찾습니다. -2. 본문과 `sources`, `generated`를 갱신하고 `stale_after`를 다시 잡습니다. 내용이 바뀌면 기존 `verified`는 지우고 `status`를 `draft`로 내립니다. +2. 본문과 `sources`, `generated`를 갱신합니다. 내용이 바뀌면 기존 `verified`는 지우고 `status`를 `draft`로 내립니다. `stale_after`가 있는 문서는 날짜를 다시 잡습니다. 3. 새 주제면 개념 문서를 만들고 디렉터리 `index.md`에 링크합니다. 4. `wiki/log.md` 맨 위 날짜 아래에 `**Creation**`, `**Update**`, `**Deprecation**` 중 하나로 시작하는 한 줄을 추가합니다. 5. `pnpm wiki:lint`를 통과시킵니다. @@ -85,6 +92,6 @@ stale_after: 2027-01-02 # 필수. 이 날짜가 지나면 CI가 실패 ### Lint -- 정적 검사: `pnpm wiki:lint`. frontmatter 필수 키, `sources` 경로 존재, 깨진 링크, `index.md` 누락, `log.md` 형식, `stale_after` 만료를 봅니다. PR과 매주 월요일 스케줄에서 CI가 실행합니다. +- 정적 검사: `pnpm wiki:lint`. frontmatter 필수 키, `sources` 경로 존재, 출처 변경 감지, 깨진 링크, `index.md` 누락, `log.md` 형식, `stale_after` 만료를 봅니다. CI는 모든 PR과 main 푸시에서 실행하고, `stale_after` 만료를 잡으려고 매주 월요일에도 실행합니다. - 의미 검사: 요청을 받으면 문서끼리의 모순, 코드와 어긋난 주장, 고아 개념, 빠진 상호 참조를 점검해 수정 PR을 올립니다. - `stale_after`가 지난 문서는 `sources`를 다시 읽어 내용을 확인한 뒤 날짜를 갱신합니다. 확인 없이 날짜만 미루지 않습니다. diff --git a/scripts/wiki-lint.mjs b/scripts/wiki-lint.mjs index 94c6696..7b55525 100644 --- a/scripts/wiki-lint.mjs +++ b/scripts/wiki-lint.mjs @@ -1,3 +1,4 @@ +import {execFileSync} from "node:child_process"; import {existsSync, readdirSync, readFileSync, statSync} from "node:fs"; import {dirname, join, relative, resolve, sep} from "node:path"; import {fileURLToPath} from "node:url"; @@ -7,15 +8,19 @@ import {parse} from "yaml"; * 위키 정적 검사 * * `wiki/`는 OKF v0.2 번들이다. OKF 스펙은 `type`만 필수로 두지만, 이 저장소는 나중에 어떤 - * 도구로 옮겨도 신뢰 신호를 잃지 않도록 출처와 수명 필드를 처음부터 강제한다. 모순이나 오래된 + * 도구로 옮겨도 신뢰 신호를 잃지 않도록 출처 필드를 처음부터 강제한다. 모순이나 오래된 * 주장처럼 의미를 봐야 하는 점검은 LLM Lint가 맡고, 여기서는 기계적으로 판정할 수 있는 것만 본다. + * + * 문서가 낡았는지는 두 신호로 본다. + * - 저장소 안 파일을 출처로 둔 문서: 출처 파일이 문서보다 나중에 커밋되면 낡은 것으로 봄 + * - 저장소 안 출처가 없는 문서: 바뀌어도 커밋이 생기지 않으므로 `stale_after` 날짜로만 판정 */ /** OKF 예약 파일. 개념 문서가 아니라 목록과 이력이므로 frontmatter 검사에서 뺌 */ const RESERVED_FILES = new Set(["index.md", "log.md"]); /** 개념 문서에 반드시 있어야 하는 frontmatter 키 */ -const REQUIRED_KEYS = ["type", "title", "description", "status", "sources", "generated", "stale_after"]; +const REQUIRED_KEYS = ["type", "title", "description", "status", "sources", "generated"]; /** OKF v0.2 수명 상태 */ const STATUS_VALUES = new Set(["draft", "stable", "deprecated"]); @@ -108,10 +113,12 @@ function resolveLocalTarget(target, file, bundleRoot) { * @param {string} file - 문서 경로 * @param {string} bundleRoot - 번들 루트 * @param {Date} now - 기준 시각 - * @return {{level: "error" | "warning", message: string}[]} 발견한 문제 + * @return {{problems: {level: "error" | "warning", message: string}[], localSources: string[]}} + * 발견한 문제와 저장소 안 출처의 절대 경로 */ function checkConcept(data, file, bundleRoot, now) { const problems = []; + const localSources = []; const error = (message) => problems.push({level: "error", message}); for (const key of REQUIRED_KEYS) { @@ -135,13 +142,19 @@ function checkConcept(data, file, bundleRoot, now) { return; } const local = resolveLocalTarget(resource, file, bundleRoot); - if (local && !existsSync(local)) { - error(`\`sources[${index}].resource\` 경로가 없음: ${resource}`); - } + if (!local) return; + if (existsSync(local)) localSources.push(resolve(local)); + else error(`\`sources[${index}].resource\` 경로가 없음: ${resource}`); }); } } + // 저장소 안 출처가 없으면 출처 변경 감지가 불가능해 날짜가 유일한 재확인 계기 + if (Array.isArray(data.sources) && data.sources.length > 0 && localSources.length === 0 && + (data.stale_after === undefined || data.stale_after === null || data.stale_after === "")) { + error("저장소 안 출처가 없는 문서는 `stale_after`가 필요함"); + } + if (data.generated !== undefined) { const {by, at} = data.generated ?? {}; if (typeof by !== "string" || !ACTOR_PATTERN.test(by)) { @@ -177,7 +190,47 @@ function checkConcept(data, file, bundleRoot, now) { } } - return problems; + return {problems, localSources}; +} + +/** + * 파일의 마지막 변경 시각 조회 함수 + * + * 반환값은 마지막 커밋의 커미터 시각(초)이다. 커밋하지 않은 변경이 있으면 `Infinity`로 보아 + * 작업 중인 문서와 출처를 가장 최근으로 취급한다. 추적하지 않는 무시 파일은 null이다. + * + * @typedef {(path: string) => number | null} LastChangeLookup + */ + +/** + * git 이력으로 마지막 변경 시각을 조회하는 함수 생성 + * + * @param {string} cwd - 저장소 안의 디렉터리 + * @return {LastChangeLookup | null} git 저장소가 아니거나 얕은 클론이라 이력이 없으면 null + */ +export function createGitLastChangeLookup(cwd) { + const git = (args) => execFileSync("git", args, {cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"]}).trim(); + + try { + if (git(["rev-parse", "--is-shallow-repository"]) === "true") return null; + } catch { + return null; + } + + const cache = new Map(); + return (path) => { + if (!cache.has(path)) { + let value = null; + if (git(["status", "--porcelain", "--", path])) { + value = Infinity; + } else { + const committedAt = git(["log", "-1", "--format=%ct", "--", path]); + if (committedAt) value = Number(committedAt); + } + cache.set(path, value); + } + return cache.get(path); + }; } /** @@ -190,6 +243,32 @@ function toIsoDate(date) { return date.toISOString().slice(0, 10); } +/** + * 출처 변경 감지. 문서보다 나중에 커밋된 저장소 안 출처를 찾음 + * + * 같은 커밋에서 출처와 문서를 함께 고치면 시각이 같아 통과한다. 출처만 고친 커밋이 있으면 + * 문서를 고칠 때까지 계속 실패하므로, 머지 뒤에도 어긋남이 남지 않는다. + * + * @param {string} file - 문서 경로 + * @param {string[]} localSources - 저장소 안 출처의 절대 경로 + * @param {LastChangeLookup} lastChange - 마지막 변경 시각 조회 함수 + * @return {string[]} 발견한 문제 + */ +function checkSourceChanges(file, localSources, lastChange) { + const documentChangedAt = lastChange(file); + if (documentChangedAt === null) return []; + + return localSources.flatMap((source) => { + const sourceChangedAt = lastChange(source); + if (sourceChangedAt === null || sourceChangedAt <= documentChangedAt) return []; + const sourcePath = relative(dirname(file), source).split(sep).join("/"); + return [ + `출처 ${sourcePath}가 이 문서보다 나중에 바뀜. ` + + "내용이 달라졌으면 본문을 고치고, 그대로면 `verified`에 확인 기록을 추가해야 함", + ]; + }); +} + /** * `log.md` 검사. 날짜 제목이 `YYYY-MM-DD`이고 최신순인지 봄 * @@ -217,19 +296,28 @@ function checkLog(text) { * 위키 번들 전체 검사 * * @param {string} bundleRoot - 번들 루트(`wiki/`) - * @param {{now?: Date}} [options] - 기준 시각. 테스트에서 고정할 때 씀 + * @param {{now?: Date, getLastChange?: LastChangeLookup | null}} [options] - 기준 시각과 + * 변경 시각 조회 함수. 테스트에서 고정할 때 넘기고, 생략하면 현재 시각과 git 이력을 씀. + * 조회 함수에 null을 넘기면 출처 변경 감지를 끔 * @return {WikiLintIssue[]} 발견한 문제 */ -export function lintWiki(bundleRoot, {now = new Date()} = {}) { +export function lintWiki(bundleRoot, {now = new Date(), getLastChange} = {}) { /** @type {WikiLintIssue[]} */ const issues = []; - const report = (level, file, message) => issues.push({level, file: relative(bundleRoot, file).split(sep).join("/"), message}); + const toBundlePath = (path) => relative(bundleRoot, path).split(sep).join("/"); + const report = (level, file, message) => issues.push({level, file: toBundlePath(file), message}); if (!existsSync(bundleRoot)) { report("error", bundleRoot, "위키 디렉터리가 없음"); return issues; } + // null을 직접 넘기면 감지를 끈 것으로 보고, 자동 조회가 실패했을 때만 경고 + const lastChange = getLastChange === undefined ? createGitLastChangeLookup(bundleRoot) : getLastChange; + if (getLastChange === undefined && !lastChange) { + report("warning", bundleRoot, "git 이력이 없어 출처 변경 감지를 건너뜀. CI에서는 전체 이력으로 체크아웃해야 함"); + } + const files = listMarkdownFiles(bundleRoot); /** 각 문서가 링크한 로컬 경로. index 누락 검사에 씀 */ const linkedFrom = new Map(); @@ -250,7 +338,12 @@ export function lintWiki(bundleRoot, {now = new Date()} = {}) { report("error", file, `frontmatter YAML 해석 실패: ${parseError.message}`); } if (data && typeof data === "object") { - checkConcept(data, file, bundleRoot, now).forEach(({level, message}) => report(level, file, message)); + const {problems, localSources} = checkConcept(data, file, bundleRoot, now); + problems.forEach(({level, message}) => report(level, file, message)); + if (lastChange && data.status !== "deprecated") { + checkSourceChanges(file, localSources, lastChange) + .forEach((message) => report("error", file, message)); + } } else if (data !== undefined) { report("error", file, "frontmatter가 키-값 형식이 아님"); } diff --git a/scripts/wiki-lint.test.mjs b/scripts/wiki-lint.test.mjs index f961bef..c627cb1 100644 --- a/scripts/wiki-lint.test.mjs +++ b/scripts/wiki-lint.test.mjs @@ -1,9 +1,10 @@ import assert from "node:assert/strict"; +import {execFileSync} from "node:child_process"; import {mkdirSync, mkdtempSync, rmSync, writeFileSync} from "node:fs"; import {tmpdir} from "node:os"; import {dirname, join} from "node:path"; import {afterEach, beforeEach, describe, it} from "node:test"; -import {lintWiki} from "./wiki-lint.mjs"; +import {createGitLastChangeLookup, lintWiki} from "./wiki-lint.mjs"; /** 테스트 기준 시각 */ const NOW = new Date("2026-10-02T00:00:00Z"); @@ -42,7 +43,7 @@ describe("lintWiki", () => { * * @return {string[]} `파일: 메시지` 목록 */ - const errors = () => lintWiki(bundle, {now: NOW}) + const errors = (getLastChange = null) => lintWiki(bundle, {now: NOW, getLastChange}) .filter((issue) => issue.level === "error") .map((issue) => `${issue.file}: ${issue.message}`); @@ -64,7 +65,7 @@ describe("lintWiki", () => { it("frontmatter 필수 키 누락을 잡음", () => { write("concept.md", "---\ntype: Architecture\n---\n# 본문\n"); const found = errors(); - for (const key of ["title", "description", "status", "sources", "generated", "stale_after"]) { + for (const key of ["title", "description", "status", "sources", "generated"]) { assert.ok(found.some((message) => message.includes(`\`${key}\``)), key); } }); @@ -79,7 +80,7 @@ describe("lintWiki", () => { assert.ok(errors().some((message) => message.includes("stale_after") && message.includes("지남"))); write("concept.md", VALID_FRONTMATTER.replace("2027-01-01", "2026-10-10")); - const warnings = lintWiki(bundle, {now: NOW}).filter((issue) => issue.level === "warning"); + const warnings = lintWiki(bundle, {now: NOW, getLastChange: null}).filter((issue) => issue.level === "warning"); assert.equal(warnings.length, 1); }); @@ -116,6 +117,71 @@ describe("lintWiki", () => { assert.ok(found.includes("sub/child.md: sub/index.md가 없어 이 문서를 찾을 수 없음")); }); + it("저장소 안 출처가 있으면 stale_after가 없어도 통과함", () => { + write("concept.md", VALID_FRONTMATTER.replace("stale_after: 2027-01-01\n", "")); + assert.deepEqual(errors(), []); + }); + + it("저장소 안 출처가 없는 문서는 stale_after를 요구함", () => { + const external = VALID_FRONTMATTER.replace("../code.ts", "https://example.com/spec"); + write("concept.md", external.replace("stale_after: 2027-01-01\n", "")); + assert.deepEqual(errors(), ["concept.md: 저장소 안 출처가 없는 문서는 `stale_after`가 필요함"]); + + write("concept.md", external); + assert.deepEqual(errors(), []); + }); + + it("출처가 문서보다 나중에 바뀌면 잡고, 같거나 이전이면 통과함", () => { + const lookup = (times) => (path) => (path.endsWith("code.ts") ? times.source : times.doc); + + assert.equal(errors(lookup({source: 200, doc: 100})).length, 1); + assert.match(errors(lookup({source: 200, doc: 100}))[0], /출처 \.\.\/code\.ts가 이 문서보다 나중에 바뀜/); + assert.deepEqual(errors(lookup({source: 100, doc: 100})), []); + assert.deepEqual(errors(lookup({source: 100, doc: Infinity})), []); + assert.deepEqual(errors(lookup({source: 200, doc: null})), []); + + write("concept.md", VALID_FRONTMATTER.replace("draft", "deprecated")); + assert.deepEqual(errors(lookup({source: 200, doc: 100})), []); + }); + + it("git 이력으로 출처 변경을 감지함", () => { + /** + * 임시 저장소에서 git 실행 + * + * @param {string[]} args - git 인자 + * @param {string} [date] - 커밋 시각. 초 단위 비교라 커밋마다 다르게 지정 + */ + const git = (args, date) => execFileSync("git", ["-c", "user.name=test", "-c", "user.email=test@example.com", ...args], { + cwd: root, + stdio: "ignore", + env: {...process.env, ...(date ? {GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date} : {})}, + }); + const commitAll = (message, date) => { + git(["add", "-A"]); + git(["commit", "-q", "-m", message], date); + }; + + git(["init", "-q"]); + commitAll("init", "2026-10-01T00:00:00Z"); + assert.deepEqual(errors(createGitLastChangeLookup(bundle)), []); + + writeFileSync(join(root, "code.ts"), "changed"); + assert.equal(errors(createGitLastChangeLookup(bundle)).length, 1, "커밋 전 출처 변경"); + + commitAll("code only", "2026-10-02T00:00:00Z"); + assert.equal(errors(createGitLastChangeLookup(bundle)).length, 1, "출처만 커밋"); + + write("concept.md", VALID_FRONTMATTER.replace("stale_after", "verified:\n - { by: human:test, at: 2026-10-03T00:00:00Z }\nstale_after")); + commitAll("doc", "2026-10-03T00:00:00Z"); + assert.deepEqual(errors(createGitLastChangeLookup(bundle)), [], "문서에 확인 기록 추가"); + }); + + it("git 저장소가 아니면 출처 변경 감지를 건너뛰고 경고함", () => { + const issues = lintWiki(bundle, {now: NOW}); + assert.deepEqual(issues.filter((issue) => issue.level === "error"), []); + assert.ok(issues.some((issue) => issue.level === "warning" && issue.message.includes("git 이력"))); + }); + it("log.md 날짜 형식과 순서를 검사함", () => { write("log.md", "# 로그\n\n## 2026-09-30\n\n## 2026-10-02\n\n## 어제\n"); const found = errors(); diff --git a/wiki/architecture/ai-call-guard.md b/wiki/architecture/ai-call-guard.md index 8fa8cd6..caf256c 100644 --- a/wiki/architecture/ai-call-guard.md +++ b/wiki/architecture/ai-call-guard.md @@ -9,7 +9,6 @@ sources: - resource: ../../functions/src/openai.ts - resource: ../../functions/src/translateFlashcards.ts generated: { by: claude-code/claude-opus-5-5, at: 2026-10-02T00:00:00Z } -stale_after: 2027-01-02 --- # 왜 필요한가 diff --git a/wiki/architecture/firestore-data-boundary.md b/wiki/architecture/firestore-data-boundary.md index 4e8af12..df05480 100644 --- a/wiki/architecture/firestore-data-boundary.md +++ b/wiki/architecture/firestore-data-boundary.md @@ -12,7 +12,6 @@ sources: title: "fix(77): users 문서 쓰기를 소유 필드로 한정하고 사용량 카운터 분리" resource: https://github.com/chucoding/code-recall/pull/81 generated: { by: claude-code/claude-opus-5-5, at: 2026-10-02T00:00:00Z } -stale_after: 2027-01-02 --- # 규칙 요약 diff --git a/wiki/architecture/system-overview.md b/wiki/architecture/system-overview.md index f99d972..3f41ca9 100644 --- a/wiki/architecture/system-overview.md +++ b/wiki/architecture/system-overview.md @@ -11,7 +11,6 @@ sources: - resource: ../../functions/src/trending.ts - resource: ../../functions/src/flashcard-pregeneration.ts generated: { by: claude-code/claude-opus-5-5, at: 2026-10-02T00:00:00Z } -stale_after: 2027-01-02 --- # 구성 diff --git a/wiki/flashcard/deck-size-budget.md b/wiki/flashcard/deck-size-budget.md index beb9941..18dcb7d 100644 --- a/wiki/flashcard/deck-size-budget.md +++ b/wiki/flashcard/deck-size-budget.md @@ -11,7 +11,6 @@ sources: title: "fix(82): 카드 덱을 문서 크기 예산 안으로 줄여 저장" resource: https://github.com/chucoding/code-recall/pull/83 generated: { by: claude-code/claude-opus-5-5, at: 2026-10-02T00:00:00Z } -stale_after: 2027-01-02 --- # 규칙 diff --git a/wiki/flashcard/generation-paths.md b/wiki/flashcard/generation-paths.md index f585a51..be8d3b6 100644 --- a/wiki/flashcard/generation-paths.md +++ b/wiki/flashcard/generation-paths.md @@ -11,7 +11,6 @@ sources: - resource: ../../functions/src/flashcard-pregeneration.ts - resource: ../../app/src/features/flashcard/model/useTodayFlashcards.ts generated: { by: claude-code/claude-opus-5-5, at: 2026-10-02T00:00:00Z } -stale_after: 2027-01-02 --- # 세 경로 diff --git a/wiki/flashcard/pregeneration.md b/wiki/flashcard/pregeneration.md index d93b350..83b623c 100644 --- a/wiki/flashcard/pregeneration.md +++ b/wiki/flashcard/pregeneration.md @@ -11,7 +11,6 @@ sources: title: "feat(86): 오늘의 플래시카드를 사용자 자정에 서버에서 미리 생성" resource: https://github.com/chucoding/code-recall/pull/88 generated: { by: claude-code/claude-opus-5-5, at: 2026-10-02T00:00:00Z } -stale_after: 2027-01-02 --- # 왜 만들었나 diff --git a/wiki/log.md b/wiki/log.md index cd478e2..5d6025e 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -4,6 +4,7 @@ ## 2026-10-02 +- **Update** 코드 파생 문서 6건의 `stale_after`를 빼고 출처 변경 감지로 신선도를 판정 (#94) - **Creation** 위키 번들 골격과 초기 개념 문서 7건 작성 (#94) - [시스템 구성](architecture/system-overview.md), [Firestore 데이터 경계](architecture/firestore-data-boundary.md), [공개 AI 함수 호출 경계](architecture/ai-call-guard.md) - [카드 생성 경로](flashcard/generation-paths.md), [오늘의 플래시카드 사전 생성](flashcard/pregeneration.md), [덱 문서 크기 예산](flashcard/deck-size-budget.md) From 5fe21f0dcffd842a66b072a61c5fd075e69545c4 Mon Sep 17 00:00:00 2001 From: chucoding Date: Fri, 2 Oct 2026 00:56:36 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat(94):=20=EC=9C=84=ED=82=A4=20=EC=B6=9C?= =?UTF-8?q?=EC=B2=98=20=EB=B3=80=EA=B2=BD=20=EA=B0=90=EC=A7=80=EB=A5=BC=20?= =?UTF-8?q?=EB=B9=BC=EA=B3=A0=20Ingest=20=EA=B7=9C=EC=B9=99=EC=97=90=20?= =?UTF-8?q?=EB=A7=A1=EA=B9=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 출처 변경 감지는 파일 단위로 판정해 주석 한 줄만 고쳐도 실패하고, 파일 하나가 여러 문서의 출처라 한 번에 여러 건이 함께 걸린다. 이렇게 자주 막히면 내용을 확인하지 않고 확인 기록만 추가해 통과시키게 되므로, 아직 위키를 운영해 보지 않은 시점에 강제하기에는 이르다고 판단했다. 그래서 코드 파생 문서는 같은 PR에서 함께 고치는 규칙에만 맡기고, 실제로 놓치는 사례가 생기는지 보면서 다시 정하기로 했다. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/wiki-lint.yml | 14 +++-- AGENTS.md | 14 ++--- scripts/wiki-lint.mjs | 106 ++++---------------------------- scripts/wiki-lint.test.mjs | 58 +---------------- wiki/log.md | 2 +- 5 files changed, 32 insertions(+), 162 deletions(-) diff --git a/.github/workflows/wiki-lint.yml b/.github/workflows/wiki-lint.yml index 579f960..156ccc9 100644 --- a/.github/workflows/wiki-lint.yml +++ b/.github/workflows/wiki-lint.yml @@ -1,10 +1,19 @@ name: Wiki Lint on: - # 코드만 고친 PR도 위키 문서의 출처를 바꿀 수 있어 경로 필터를 두지 않음 pull_request: + paths: + - "wiki/**" + - "scripts/wiki-lint*.mjs" + - "AGENTS.md" + - "package.json" + - "pnpm-lock.yaml" + - ".github/workflows/wiki-lint.yml" push: branches: [main] + paths: + - "wiki/**" + - "scripts/wiki-lint*.mjs" # 저장소 밖 사실에 기대는 문서는 커밋 없이도 낡으므로 stale_after 만료를 주기 실행으로 잡음 schedule: - cron: "0 0 * * 1" # 매주 월요일 09시 KST @@ -18,9 +27,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - with: - # 출처 변경 감지가 파일별 마지막 커밋 시각을 비교하므로 전체 이력이 필요 - fetch-depth: 0 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v7 with: diff --git a/AGENTS.md b/AGENTS.md index 8697b94..8bcc3a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,14 +57,12 @@ stale_after: 2027-04-02 # 저장소 안 출처가 없는 문서만 필 --- ``` -- 문서가 낡았는지는 출처 종류에 따라 두 신호로 봅니다. +- 문서가 낡았는지는 출처 종류에 따라 다르게 다룹니다. -| 출처 | 신호 | 동작 | -|------|------|------| -| 저장소 안 파일이 하나라도 있음 | 출처 변경 감지 | 출처 파일의 마지막 커밋이 문서의 마지막 커밋보다 나중이면 CI가 실패. `stale_after`는 쓰지 않음 | -| 외부 URL만 있음 (외부 스펙, 서비스 제한, 결정 기록) | `stale_after` | 커밋 없이도 바뀌므로 날짜로 재확인. 작성일로부터 6개월을 기본으로 잡음 | - -- 출처 변경 감지에 걸리면 출처를 다시 읽고, 내용이 달라졌으면 본문을 고칩니다. 내용이 그대로면 `verified`에 확인 기록을 추가합니다. 둘 다 문서 파일을 바꾸므로 다음 검사에서 통과합니다. +| 출처 | 신선도 관리 | +|------|------| +| 저장소 안 파일이 하나라도 있음 | 아래 `Ingest` 규칙대로 코드를 고치는 PR에서 함께 고침. 자동 감지는 두지 않음 | +| 외부 URL만 있음 (외부 스펙, 서비스 제한, 결정 기록) | `stale_after` 필수. 커밋 없이도 바뀌므로 날짜로 재확인. 작성일로부터 6개월을 기본으로 잡음 | - 사람이 PR 리뷰로 내용을 확인하면 `verified`에 `human:`를 추가하고 `status`를 `stable`로 올립니다. 에이전트는 사람 확인 없이 `stable`로 올리지 않습니다. - 더 이상 맞지 않지만 링크와 이력 때문에 남길 문서는 `status: deprecated`로 바꾸고 대체 문서를 본문에 링크합니다. @@ -92,6 +90,6 @@ stale_after: 2027-04-02 # 저장소 안 출처가 없는 문서만 필 ### Lint -- 정적 검사: `pnpm wiki:lint`. frontmatter 필수 키, `sources` 경로 존재, 출처 변경 감지, 깨진 링크, `index.md` 누락, `log.md` 형식, `stale_after` 만료를 봅니다. CI는 모든 PR과 main 푸시에서 실행하고, `stale_after` 만료를 잡으려고 매주 월요일에도 실행합니다. +- 정적 검사: `pnpm wiki:lint`. frontmatter 필수 키, `sources` 경로 존재, 깨진 링크, `index.md` 누락, `log.md` 형식, `stale_after` 만료를 봅니다. CI는 위키와 검사 스크립트를 고친 PR, main 푸시에서 실행하고, `stale_after` 만료를 잡으려고 매주 월요일에도 실행합니다. - 의미 검사: 요청을 받으면 문서끼리의 모순, 코드와 어긋난 주장, 고아 개념, 빠진 상호 참조를 점검해 수정 PR을 올립니다. - `stale_after`가 지난 문서는 `sources`를 다시 읽어 내용을 확인한 뒤 날짜를 갱신합니다. 확인 없이 날짜만 미루지 않습니다. diff --git a/scripts/wiki-lint.mjs b/scripts/wiki-lint.mjs index 7b55525..d05b481 100644 --- a/scripts/wiki-lint.mjs +++ b/scripts/wiki-lint.mjs @@ -1,4 +1,3 @@ -import {execFileSync} from "node:child_process"; import {existsSync, readdirSync, readFileSync, statSync} from "node:fs"; import {dirname, join, relative, resolve, sep} from "node:path"; import {fileURLToPath} from "node:url"; @@ -11,9 +10,8 @@ import {parse} from "yaml"; * 도구로 옮겨도 신뢰 신호를 잃지 않도록 출처 필드를 처음부터 강제한다. 모순이나 오래된 * 주장처럼 의미를 봐야 하는 점검은 LLM Lint가 맡고, 여기서는 기계적으로 판정할 수 있는 것만 본다. * - * 문서가 낡았는지는 두 신호로 본다. - * - 저장소 안 파일을 출처로 둔 문서: 출처 파일이 문서보다 나중에 커밋되면 낡은 것으로 봄 - * - 저장소 안 출처가 없는 문서: 바뀌어도 커밋이 생기지 않으므로 `stale_after` 날짜로만 판정 + * 코드에서 나온 문서의 신선도는 같은 PR에서 함께 고치는 규칙(AGENTS.md의 Ingest)에 맡긴다. + * 저장소 안 출처가 없는 문서는 바뀌어도 이 저장소에 커밋이 생기지 않으므로 `stale_after`로 재확인한다. */ /** OKF 예약 파일. 개념 문서가 아니라 목록과 이력이므로 frontmatter 검사에서 뺌 */ @@ -113,12 +111,11 @@ function resolveLocalTarget(target, file, bundleRoot) { * @param {string} file - 문서 경로 * @param {string} bundleRoot - 번들 루트 * @param {Date} now - 기준 시각 - * @return {{problems: {level: "error" | "warning", message: string}[], localSources: string[]}} - * 발견한 문제와 저장소 안 출처의 절대 경로 + * @return {{level: "error" | "warning", message: string}[]} 발견한 문제 */ function checkConcept(data, file, bundleRoot, now) { const problems = []; - const localSources = []; + let localSourceCount = 0; const error = (message) => problems.push({level: "error", message}); for (const key of REQUIRED_KEYS) { @@ -143,14 +140,14 @@ function checkConcept(data, file, bundleRoot, now) { } const local = resolveLocalTarget(resource, file, bundleRoot); if (!local) return; - if (existsSync(local)) localSources.push(resolve(local)); - else error(`\`sources[${index}].resource\` 경로가 없음: ${resource}`); + localSourceCount += 1; + if (!existsSync(local)) error(`\`sources[${index}].resource\` 경로가 없음: ${resource}`); }); } } - // 저장소 안 출처가 없으면 출처 변경 감지가 불가능해 날짜가 유일한 재확인 계기 - if (Array.isArray(data.sources) && data.sources.length > 0 && localSources.length === 0 && + // 저장소 안 출처가 없으면 바뀌어도 커밋이 생기지 않아 날짜가 유일한 재확인 계기 + if (Array.isArray(data.sources) && data.sources.length > 0 && localSourceCount === 0 && (data.stale_after === undefined || data.stale_after === null || data.stale_after === "")) { error("저장소 안 출처가 없는 문서는 `stale_after`가 필요함"); } @@ -190,47 +187,7 @@ function checkConcept(data, file, bundleRoot, now) { } } - return {problems, localSources}; -} - -/** - * 파일의 마지막 변경 시각 조회 함수 - * - * 반환값은 마지막 커밋의 커미터 시각(초)이다. 커밋하지 않은 변경이 있으면 `Infinity`로 보아 - * 작업 중인 문서와 출처를 가장 최근으로 취급한다. 추적하지 않는 무시 파일은 null이다. - * - * @typedef {(path: string) => number | null} LastChangeLookup - */ - -/** - * git 이력으로 마지막 변경 시각을 조회하는 함수 생성 - * - * @param {string} cwd - 저장소 안의 디렉터리 - * @return {LastChangeLookup | null} git 저장소가 아니거나 얕은 클론이라 이력이 없으면 null - */ -export function createGitLastChangeLookup(cwd) { - const git = (args) => execFileSync("git", args, {cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"]}).trim(); - - try { - if (git(["rev-parse", "--is-shallow-repository"]) === "true") return null; - } catch { - return null; - } - - const cache = new Map(); - return (path) => { - if (!cache.has(path)) { - let value = null; - if (git(["status", "--porcelain", "--", path])) { - value = Infinity; - } else { - const committedAt = git(["log", "-1", "--format=%ct", "--", path]); - if (committedAt) value = Number(committedAt); - } - cache.set(path, value); - } - return cache.get(path); - }; + return problems; } /** @@ -243,32 +200,6 @@ function toIsoDate(date) { return date.toISOString().slice(0, 10); } -/** - * 출처 변경 감지. 문서보다 나중에 커밋된 저장소 안 출처를 찾음 - * - * 같은 커밋에서 출처와 문서를 함께 고치면 시각이 같아 통과한다. 출처만 고친 커밋이 있으면 - * 문서를 고칠 때까지 계속 실패하므로, 머지 뒤에도 어긋남이 남지 않는다. - * - * @param {string} file - 문서 경로 - * @param {string[]} localSources - 저장소 안 출처의 절대 경로 - * @param {LastChangeLookup} lastChange - 마지막 변경 시각 조회 함수 - * @return {string[]} 발견한 문제 - */ -function checkSourceChanges(file, localSources, lastChange) { - const documentChangedAt = lastChange(file); - if (documentChangedAt === null) return []; - - return localSources.flatMap((source) => { - const sourceChangedAt = lastChange(source); - if (sourceChangedAt === null || sourceChangedAt <= documentChangedAt) return []; - const sourcePath = relative(dirname(file), source).split(sep).join("/"); - return [ - `출처 ${sourcePath}가 이 문서보다 나중에 바뀜. ` + - "내용이 달라졌으면 본문을 고치고, 그대로면 `verified`에 확인 기록을 추가해야 함", - ]; - }); -} - /** * `log.md` 검사. 날짜 제목이 `YYYY-MM-DD`이고 최신순인지 봄 * @@ -296,12 +227,10 @@ function checkLog(text) { * 위키 번들 전체 검사 * * @param {string} bundleRoot - 번들 루트(`wiki/`) - * @param {{now?: Date, getLastChange?: LastChangeLookup | null}} [options] - 기준 시각과 - * 변경 시각 조회 함수. 테스트에서 고정할 때 넘기고, 생략하면 현재 시각과 git 이력을 씀. - * 조회 함수에 null을 넘기면 출처 변경 감지를 끔 + * @param {{now?: Date}} [options] - 기준 시각. 테스트에서 고정할 때 씀 * @return {WikiLintIssue[]} 발견한 문제 */ -export function lintWiki(bundleRoot, {now = new Date(), getLastChange} = {}) { +export function lintWiki(bundleRoot, {now = new Date()} = {}) { /** @type {WikiLintIssue[]} */ const issues = []; const toBundlePath = (path) => relative(bundleRoot, path).split(sep).join("/"); @@ -312,12 +241,6 @@ export function lintWiki(bundleRoot, {now = new Date(), getLastChange} = {}) { return issues; } - // null을 직접 넘기면 감지를 끈 것으로 보고, 자동 조회가 실패했을 때만 경고 - const lastChange = getLastChange === undefined ? createGitLastChangeLookup(bundleRoot) : getLastChange; - if (getLastChange === undefined && !lastChange) { - report("warning", bundleRoot, "git 이력이 없어 출처 변경 감지를 건너뜀. CI에서는 전체 이력으로 체크아웃해야 함"); - } - const files = listMarkdownFiles(bundleRoot); /** 각 문서가 링크한 로컬 경로. index 누락 검사에 씀 */ const linkedFrom = new Map(); @@ -338,12 +261,7 @@ export function lintWiki(bundleRoot, {now = new Date(), getLastChange} = {}) { report("error", file, `frontmatter YAML 해석 실패: ${parseError.message}`); } if (data && typeof data === "object") { - const {problems, localSources} = checkConcept(data, file, bundleRoot, now); - problems.forEach(({level, message}) => report(level, file, message)); - if (lastChange && data.status !== "deprecated") { - checkSourceChanges(file, localSources, lastChange) - .forEach((message) => report("error", file, message)); - } + checkConcept(data, file, bundleRoot, now).forEach(({level, message}) => report(level, file, message)); } else if (data !== undefined) { report("error", file, "frontmatter가 키-값 형식이 아님"); } diff --git a/scripts/wiki-lint.test.mjs b/scripts/wiki-lint.test.mjs index c627cb1..5919d25 100644 --- a/scripts/wiki-lint.test.mjs +++ b/scripts/wiki-lint.test.mjs @@ -1,10 +1,9 @@ import assert from "node:assert/strict"; -import {execFileSync} from "node:child_process"; import {mkdirSync, mkdtempSync, rmSync, writeFileSync} from "node:fs"; import {tmpdir} from "node:os"; import {dirname, join} from "node:path"; import {afterEach, beforeEach, describe, it} from "node:test"; -import {createGitLastChangeLookup, lintWiki} from "./wiki-lint.mjs"; +import {lintWiki} from "./wiki-lint.mjs"; /** 테스트 기준 시각 */ const NOW = new Date("2026-10-02T00:00:00Z"); @@ -43,7 +42,7 @@ describe("lintWiki", () => { * * @return {string[]} `파일: 메시지` 목록 */ - const errors = (getLastChange = null) => lintWiki(bundle, {now: NOW, getLastChange}) + const errors = () => lintWiki(bundle, {now: NOW}) .filter((issue) => issue.level === "error") .map((issue) => `${issue.file}: ${issue.message}`); @@ -80,7 +79,7 @@ describe("lintWiki", () => { assert.ok(errors().some((message) => message.includes("stale_after") && message.includes("지남"))); write("concept.md", VALID_FRONTMATTER.replace("2027-01-01", "2026-10-10")); - const warnings = lintWiki(bundle, {now: NOW, getLastChange: null}).filter((issue) => issue.level === "warning"); + const warnings = lintWiki(bundle, {now: NOW}).filter((issue) => issue.level === "warning"); assert.equal(warnings.length, 1); }); @@ -131,57 +130,6 @@ describe("lintWiki", () => { assert.deepEqual(errors(), []); }); - it("출처가 문서보다 나중에 바뀌면 잡고, 같거나 이전이면 통과함", () => { - const lookup = (times) => (path) => (path.endsWith("code.ts") ? times.source : times.doc); - - assert.equal(errors(lookup({source: 200, doc: 100})).length, 1); - assert.match(errors(lookup({source: 200, doc: 100}))[0], /출처 \.\.\/code\.ts가 이 문서보다 나중에 바뀜/); - assert.deepEqual(errors(lookup({source: 100, doc: 100})), []); - assert.deepEqual(errors(lookup({source: 100, doc: Infinity})), []); - assert.deepEqual(errors(lookup({source: 200, doc: null})), []); - - write("concept.md", VALID_FRONTMATTER.replace("draft", "deprecated")); - assert.deepEqual(errors(lookup({source: 200, doc: 100})), []); - }); - - it("git 이력으로 출처 변경을 감지함", () => { - /** - * 임시 저장소에서 git 실행 - * - * @param {string[]} args - git 인자 - * @param {string} [date] - 커밋 시각. 초 단위 비교라 커밋마다 다르게 지정 - */ - const git = (args, date) => execFileSync("git", ["-c", "user.name=test", "-c", "user.email=test@example.com", ...args], { - cwd: root, - stdio: "ignore", - env: {...process.env, ...(date ? {GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date} : {})}, - }); - const commitAll = (message, date) => { - git(["add", "-A"]); - git(["commit", "-q", "-m", message], date); - }; - - git(["init", "-q"]); - commitAll("init", "2026-10-01T00:00:00Z"); - assert.deepEqual(errors(createGitLastChangeLookup(bundle)), []); - - writeFileSync(join(root, "code.ts"), "changed"); - assert.equal(errors(createGitLastChangeLookup(bundle)).length, 1, "커밋 전 출처 변경"); - - commitAll("code only", "2026-10-02T00:00:00Z"); - assert.equal(errors(createGitLastChangeLookup(bundle)).length, 1, "출처만 커밋"); - - write("concept.md", VALID_FRONTMATTER.replace("stale_after", "verified:\n - { by: human:test, at: 2026-10-03T00:00:00Z }\nstale_after")); - commitAll("doc", "2026-10-03T00:00:00Z"); - assert.deepEqual(errors(createGitLastChangeLookup(bundle)), [], "문서에 확인 기록 추가"); - }); - - it("git 저장소가 아니면 출처 변경 감지를 건너뛰고 경고함", () => { - const issues = lintWiki(bundle, {now: NOW}); - assert.deepEqual(issues.filter((issue) => issue.level === "error"), []); - assert.ok(issues.some((issue) => issue.level === "warning" && issue.message.includes("git 이력"))); - }); - it("log.md 날짜 형식과 순서를 검사함", () => { write("log.md", "# 로그\n\n## 2026-09-30\n\n## 2026-10-02\n\n## 어제\n"); const found = errors(); diff --git a/wiki/log.md b/wiki/log.md index 5d6025e..839fe44 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -4,7 +4,7 @@ ## 2026-10-02 -- **Update** 코드 파생 문서 6건의 `stale_after`를 빼고 출처 변경 감지로 신선도를 판정 (#94) +- **Update** 코드 파생 문서 6건의 `stale_after`를 빼고, 신선도는 같은 PR에서 함께 고치는 Ingest 규칙에 맡김 (#94) - **Creation** 위키 번들 골격과 초기 개념 문서 7건 작성 (#94) - [시스템 구성](architecture/system-overview.md), [Firestore 데이터 경계](architecture/firestore-data-boundary.md), [공개 AI 함수 호출 경계](architecture/ai-call-guard.md) - [카드 생성 경로](flashcard/generation-paths.md), [오늘의 플래시카드 사전 생성](flashcard/pregeneration.md), [덱 문서 크기 예산](flashcard/deck-size-budget.md) From 8e55a0629746dd148a6dc7cc302a903e77e610f8 Mon Sep 17 00:00:00 2001 From: chucoding Date: Fri, 2 Oct 2026 01:04:49 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat(94):=20=EC=9C=84=ED=82=A4=20stale=5Faf?= =?UTF-8?q?ter=EB=A5=BC=20=EB=AA=A8=EB=93=A0=20=EB=AC=B8=EC=84=9C=EC=97=90?= =?UTF-8?q?=20=EB=B3=B5=EC=9B=90=ED=95=98=EA=B3=A0=20=EC=A0=95=EA=B8=B0=20?= =?UTF-8?q?=EA=B2=80=EC=82=AC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 출처 변경 감지를 뺀 뒤로는 코드 파생 문서에 신선도 신호가 하나도 남지 않아, stale_after를 모든 개념 문서에 다시 필수로 붙였다. 매주 정기 실행은 혼자 개발하는 지금 단계에서는 과하다고 보고 뺐다. 그래서 만료는 위키를 고치는 PR이나 로컬 실행에서 드러난다. 다른 기여자의 PR이 생기면 정기 검사와 자동 감지를 다시 검토한다. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/wiki-lint.yml | 3 --- AGENTS.md | 14 +++++------- scripts/wiki-lint.mjs | 23 +++++--------------- scripts/wiki-lint.test.mjs | 16 +------------- wiki/architecture/ai-call-guard.md | 1 + wiki/architecture/firestore-data-boundary.md | 1 + wiki/architecture/system-overview.md | 1 + wiki/flashcard/deck-size-budget.md | 1 + wiki/flashcard/generation-paths.md | 1 + wiki/flashcard/pregeneration.md | 1 + wiki/log.md | 1 - 11 files changed, 18 insertions(+), 45 deletions(-) diff --git a/.github/workflows/wiki-lint.yml b/.github/workflows/wiki-lint.yml index 156ccc9..b31b4f1 100644 --- a/.github/workflows/wiki-lint.yml +++ b/.github/workflows/wiki-lint.yml @@ -14,9 +14,6 @@ on: paths: - "wiki/**" - "scripts/wiki-lint*.mjs" - # 저장소 밖 사실에 기대는 문서는 커밋 없이도 낡으므로 stale_after 만료를 주기 실행으로 잡음 - schedule: - - cron: "0 0 * * 1" # 매주 월요일 09시 KST workflow_dispatch: permissions: diff --git a/AGENTS.md b/AGENTS.md index 8bcc3a2..4b8e109 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,16 +53,12 @@ sources: # 필수. 하나 이상 generated: { by: claude-code/claude-opus-5-5, at: 2026-10-02T00:00:00Z } # 필수. <도구>/<모델> verified: # 선택. stable이면 human: 항목 필수 - { by: human:chucoding, at: 2026-10-03T00:00:00Z } -stale_after: 2027-04-02 # 저장소 안 출처가 없는 문서만 필수. 이 날짜가 지나면 CI가 실패 +stale_after: 2027-01-02 # 필수. 이 날짜가 지나면 검사가 실패 --- ``` -- 문서가 낡았는지는 출처 종류에 따라 다르게 다룹니다. - -| 출처 | 신선도 관리 | -|------|------| -| 저장소 안 파일이 하나라도 있음 | 아래 `Ingest` 규칙대로 코드를 고치는 PR에서 함께 고침. 자동 감지는 두지 않음 | -| 외부 URL만 있음 (외부 스펙, 서비스 제한, 결정 기록) | `stale_after` 필수. 커밋 없이도 바뀌므로 날짜로 재확인. 작성일로부터 6개월을 기본으로 잡음 | +- `stale_after`는 코드에서 파생한 문서는 작성일로부터 3개월, 결정 기록은 6개월을 기본으로 잡습니다. +- 코드가 바뀌었는지 자동으로 감지하지는 않습니다. 코드 파생 문서는 아래 `Ingest` 규칙대로 코드를 고치는 PR에서 함께 고칩니다. - 사람이 PR 리뷰로 내용을 확인하면 `verified`에 `human:`를 추가하고 `status`를 `stable`로 올립니다. 에이전트는 사람 확인 없이 `stable`로 올리지 않습니다. - 더 이상 맞지 않지만 링크와 이력 때문에 남길 문서는 `status: deprecated`로 바꾸고 대체 문서를 본문에 링크합니다. @@ -79,7 +75,7 @@ stale_after: 2027-04-02 # 저장소 안 출처가 없는 문서만 필 코드나 규칙을 바꾸는 PR에서, 바뀐 사실을 다루는 위키 문서가 있으면 같은 PR에서 함께 고칩니다. 1. `wiki/index.md`에서 영향받는 문서를 찾습니다. -2. 본문과 `sources`, `generated`를 갱신합니다. 내용이 바뀌면 기존 `verified`는 지우고 `status`를 `draft`로 내립니다. `stale_after`가 있는 문서는 날짜를 다시 잡습니다. +2. 본문과 `sources`, `generated`를 갱신하고 `stale_after`를 다시 잡습니다. 내용이 바뀌면 기존 `verified`는 지우고 `status`를 `draft`로 내립니다. 3. 새 주제면 개념 문서를 만들고 디렉터리 `index.md`에 링크합니다. 4. `wiki/log.md` 맨 위 날짜 아래에 `**Creation**`, `**Update**`, `**Deprecation**` 중 하나로 시작하는 한 줄을 추가합니다. 5. `pnpm wiki:lint`를 통과시킵니다. @@ -90,6 +86,6 @@ stale_after: 2027-04-02 # 저장소 안 출처가 없는 문서만 필 ### Lint -- 정적 검사: `pnpm wiki:lint`. frontmatter 필수 키, `sources` 경로 존재, 깨진 링크, `index.md` 누락, `log.md` 형식, `stale_after` 만료를 봅니다. CI는 위키와 검사 스크립트를 고친 PR, main 푸시에서 실행하고, `stale_after` 만료를 잡으려고 매주 월요일에도 실행합니다. +- 정적 검사: `pnpm wiki:lint`. frontmatter 필수 키, `sources` 경로 존재, 깨진 링크, `index.md` 누락, `log.md` 형식, `stale_after` 만료를 봅니다. CI는 위키와 검사 스크립트를 고친 PR과 main 푸시에서 실행합니다. 정기 실행은 두지 않으므로, `stale_after` 만료는 위키를 고치는 PR이나 로컬 실행에서 드러납니다. - 의미 검사: 요청을 받으면 문서끼리의 모순, 코드와 어긋난 주장, 고아 개념, 빠진 상호 참조를 점검해 수정 PR을 올립니다. - `stale_after`가 지난 문서는 `sources`를 다시 읽어 내용을 확인한 뒤 날짜를 갱신합니다. 확인 없이 날짜만 미루지 않습니다. diff --git a/scripts/wiki-lint.mjs b/scripts/wiki-lint.mjs index d05b481..94c6696 100644 --- a/scripts/wiki-lint.mjs +++ b/scripts/wiki-lint.mjs @@ -7,18 +7,15 @@ import {parse} from "yaml"; * 위키 정적 검사 * * `wiki/`는 OKF v0.2 번들이다. OKF 스펙은 `type`만 필수로 두지만, 이 저장소는 나중에 어떤 - * 도구로 옮겨도 신뢰 신호를 잃지 않도록 출처 필드를 처음부터 강제한다. 모순이나 오래된 + * 도구로 옮겨도 신뢰 신호를 잃지 않도록 출처와 수명 필드를 처음부터 강제한다. 모순이나 오래된 * 주장처럼 의미를 봐야 하는 점검은 LLM Lint가 맡고, 여기서는 기계적으로 판정할 수 있는 것만 본다. - * - * 코드에서 나온 문서의 신선도는 같은 PR에서 함께 고치는 규칙(AGENTS.md의 Ingest)에 맡긴다. - * 저장소 안 출처가 없는 문서는 바뀌어도 이 저장소에 커밋이 생기지 않으므로 `stale_after`로 재확인한다. */ /** OKF 예약 파일. 개념 문서가 아니라 목록과 이력이므로 frontmatter 검사에서 뺌 */ const RESERVED_FILES = new Set(["index.md", "log.md"]); /** 개념 문서에 반드시 있어야 하는 frontmatter 키 */ -const REQUIRED_KEYS = ["type", "title", "description", "status", "sources", "generated"]; +const REQUIRED_KEYS = ["type", "title", "description", "status", "sources", "generated", "stale_after"]; /** OKF v0.2 수명 상태 */ const STATUS_VALUES = new Set(["draft", "stable", "deprecated"]); @@ -115,7 +112,6 @@ function resolveLocalTarget(target, file, bundleRoot) { */ function checkConcept(data, file, bundleRoot, now) { const problems = []; - let localSourceCount = 0; const error = (message) => problems.push({level: "error", message}); for (const key of REQUIRED_KEYS) { @@ -139,19 +135,13 @@ function checkConcept(data, file, bundleRoot, now) { return; } const local = resolveLocalTarget(resource, file, bundleRoot); - if (!local) return; - localSourceCount += 1; - if (!existsSync(local)) error(`\`sources[${index}].resource\` 경로가 없음: ${resource}`); + if (local && !existsSync(local)) { + error(`\`sources[${index}].resource\` 경로가 없음: ${resource}`); + } }); } } - // 저장소 안 출처가 없으면 바뀌어도 커밋이 생기지 않아 날짜가 유일한 재확인 계기 - if (Array.isArray(data.sources) && data.sources.length > 0 && localSourceCount === 0 && - (data.stale_after === undefined || data.stale_after === null || data.stale_after === "")) { - error("저장소 안 출처가 없는 문서는 `stale_after`가 필요함"); - } - if (data.generated !== undefined) { const {by, at} = data.generated ?? {}; if (typeof by !== "string" || !ACTOR_PATTERN.test(by)) { @@ -233,8 +223,7 @@ function checkLog(text) { export function lintWiki(bundleRoot, {now = new Date()} = {}) { /** @type {WikiLintIssue[]} */ const issues = []; - const toBundlePath = (path) => relative(bundleRoot, path).split(sep).join("/"); - const report = (level, file, message) => issues.push({level, file: toBundlePath(file), message}); + const report = (level, file, message) => issues.push({level, file: relative(bundleRoot, file).split(sep).join("/"), message}); if (!existsSync(bundleRoot)) { report("error", bundleRoot, "위키 디렉터리가 없음"); diff --git a/scripts/wiki-lint.test.mjs b/scripts/wiki-lint.test.mjs index 5919d25..f961bef 100644 --- a/scripts/wiki-lint.test.mjs +++ b/scripts/wiki-lint.test.mjs @@ -64,7 +64,7 @@ describe("lintWiki", () => { it("frontmatter 필수 키 누락을 잡음", () => { write("concept.md", "---\ntype: Architecture\n---\n# 본문\n"); const found = errors(); - for (const key of ["title", "description", "status", "sources", "generated"]) { + for (const key of ["title", "description", "status", "sources", "generated", "stale_after"]) { assert.ok(found.some((message) => message.includes(`\`${key}\``)), key); } }); @@ -116,20 +116,6 @@ describe("lintWiki", () => { assert.ok(found.includes("sub/child.md: sub/index.md가 없어 이 문서를 찾을 수 없음")); }); - it("저장소 안 출처가 있으면 stale_after가 없어도 통과함", () => { - write("concept.md", VALID_FRONTMATTER.replace("stale_after: 2027-01-01\n", "")); - assert.deepEqual(errors(), []); - }); - - it("저장소 안 출처가 없는 문서는 stale_after를 요구함", () => { - const external = VALID_FRONTMATTER.replace("../code.ts", "https://example.com/spec"); - write("concept.md", external.replace("stale_after: 2027-01-01\n", "")); - assert.deepEqual(errors(), ["concept.md: 저장소 안 출처가 없는 문서는 `stale_after`가 필요함"]); - - write("concept.md", external); - assert.deepEqual(errors(), []); - }); - it("log.md 날짜 형식과 순서를 검사함", () => { write("log.md", "# 로그\n\n## 2026-09-30\n\n## 2026-10-02\n\n## 어제\n"); const found = errors(); diff --git a/wiki/architecture/ai-call-guard.md b/wiki/architecture/ai-call-guard.md index caf256c..8fa8cd6 100644 --- a/wiki/architecture/ai-call-guard.md +++ b/wiki/architecture/ai-call-guard.md @@ -9,6 +9,7 @@ sources: - resource: ../../functions/src/openai.ts - resource: ../../functions/src/translateFlashcards.ts generated: { by: claude-code/claude-opus-5-5, at: 2026-10-02T00:00:00Z } +stale_after: 2027-01-02 --- # 왜 필요한가 diff --git a/wiki/architecture/firestore-data-boundary.md b/wiki/architecture/firestore-data-boundary.md index df05480..4e8af12 100644 --- a/wiki/architecture/firestore-data-boundary.md +++ b/wiki/architecture/firestore-data-boundary.md @@ -12,6 +12,7 @@ sources: title: "fix(77): users 문서 쓰기를 소유 필드로 한정하고 사용량 카운터 분리" resource: https://github.com/chucoding/code-recall/pull/81 generated: { by: claude-code/claude-opus-5-5, at: 2026-10-02T00:00:00Z } +stale_after: 2027-01-02 --- # 규칙 요약 diff --git a/wiki/architecture/system-overview.md b/wiki/architecture/system-overview.md index 3f41ca9..f99d972 100644 --- a/wiki/architecture/system-overview.md +++ b/wiki/architecture/system-overview.md @@ -11,6 +11,7 @@ sources: - resource: ../../functions/src/trending.ts - resource: ../../functions/src/flashcard-pregeneration.ts generated: { by: claude-code/claude-opus-5-5, at: 2026-10-02T00:00:00Z } +stale_after: 2027-01-02 --- # 구성 diff --git a/wiki/flashcard/deck-size-budget.md b/wiki/flashcard/deck-size-budget.md index 18dcb7d..beb9941 100644 --- a/wiki/flashcard/deck-size-budget.md +++ b/wiki/flashcard/deck-size-budget.md @@ -11,6 +11,7 @@ sources: title: "fix(82): 카드 덱을 문서 크기 예산 안으로 줄여 저장" resource: https://github.com/chucoding/code-recall/pull/83 generated: { by: claude-code/claude-opus-5-5, at: 2026-10-02T00:00:00Z } +stale_after: 2027-01-02 --- # 규칙 diff --git a/wiki/flashcard/generation-paths.md b/wiki/flashcard/generation-paths.md index be8d3b6..f585a51 100644 --- a/wiki/flashcard/generation-paths.md +++ b/wiki/flashcard/generation-paths.md @@ -11,6 +11,7 @@ sources: - resource: ../../functions/src/flashcard-pregeneration.ts - resource: ../../app/src/features/flashcard/model/useTodayFlashcards.ts generated: { by: claude-code/claude-opus-5-5, at: 2026-10-02T00:00:00Z } +stale_after: 2027-01-02 --- # 세 경로 diff --git a/wiki/flashcard/pregeneration.md b/wiki/flashcard/pregeneration.md index 83b623c..d93b350 100644 --- a/wiki/flashcard/pregeneration.md +++ b/wiki/flashcard/pregeneration.md @@ -11,6 +11,7 @@ sources: title: "feat(86): 오늘의 플래시카드를 사용자 자정에 서버에서 미리 생성" resource: https://github.com/chucoding/code-recall/pull/88 generated: { by: claude-code/claude-opus-5-5, at: 2026-10-02T00:00:00Z } +stale_after: 2027-01-02 --- # 왜 만들었나 diff --git a/wiki/log.md b/wiki/log.md index 839fe44..cd478e2 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -4,7 +4,6 @@ ## 2026-10-02 -- **Update** 코드 파생 문서 6건의 `stale_after`를 빼고, 신선도는 같은 PR에서 함께 고치는 Ingest 규칙에 맡김 (#94) - **Creation** 위키 번들 골격과 초기 개념 문서 7건 작성 (#94) - [시스템 구성](architecture/system-overview.md), [Firestore 데이터 경계](architecture/firestore-data-boundary.md), [공개 AI 함수 호출 경계](architecture/ai-call-guard.md) - [카드 생성 경로](flashcard/generation-paths.md), [오늘의 플래시카드 사전 생성](flashcard/pregeneration.md), [덱 문서 크기 예산](flashcard/deck-size-budget.md) From e45788034f420e169825cd4d1b16ac829f5d9025 Mon Sep 17 00:00:00 2001 From: chucoding Date: Mon, 5 Oct 2026 00:22:44 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat(94):=20=EC=9C=84=ED=82=A4=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=81=20=ED=95=84=EB=93=9C=EB=A5=BC=20UTC=20=EC=98=A4?= =?UTF-8?q?=ED=94=84=EC=85=8B=EC=9D=84=20=EB=B6=99=EC=9D=B8=20=ED=98=95?= =?UTF-8?q?=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OKF 공식 저장소에 머지된 스펙 변경(PR #6)으로 모든 시각 필드가 UTC 오프셋을 명시한 ISO 8601 datetime이어야 하는데, stale_after는 날짜만 쓰고 있어 현재 v0.2 스펙에 맞지 않는다. 날짜만 쓰면 타임존마다 다른 시각을 가리켜 같은 문서가 어디서는 만료되고 어디서는 유효해지므로, 문서 값을 바꾸고 린트도 오프셋 없는 시각을 거부하게 했다. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 3 ++- scripts/wiki-lint.mjs | 23 ++++++++++++++------ scripts/wiki-lint.test.mjs | 20 +++++++++++++---- wiki/architecture/ai-call-guard.md | 2 +- wiki/architecture/firestore-data-boundary.md | 2 +- wiki/architecture/system-overview.md | 2 +- wiki/decisions/wiki-location.md | 2 +- wiki/flashcard/deck-size-budget.md | 2 +- wiki/flashcard/generation-paths.md | 2 +- wiki/flashcard/pregeneration.md | 2 +- wiki/log.md | 4 ++++ 11 files changed, 45 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4b8e109..f394503 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,10 +53,11 @@ sources: # 필수. 하나 이상 generated: { by: claude-code/claude-opus-5-5, at: 2026-10-02T00:00:00Z } # 필수. <도구>/<모델> verified: # 선택. stable이면 human: 항목 필수 - { by: human:chucoding, at: 2026-10-03T00:00:00Z } -stale_after: 2027-01-02 # 필수. 이 날짜가 지나면 검사가 실패 +stale_after: 2027-01-02T00:00:00Z # 필수. 이 시각이 지나면 검사가 실패 --- ``` +- `generated.at`, `verified[].at`, `stale_after`처럼 시각을 담는 값은 모두 `2027-01-02T00:00:00Z`처럼 UTC 오프셋을 붙입니다. 날짜만 쓰면 타임존마다 다른 시각을 가리켜 OKF v0.2 스펙에 어긋납니다. - `stale_after`는 코드에서 파생한 문서는 작성일로부터 3개월, 결정 기록은 6개월을 기본으로 잡습니다. - 코드가 바뀌었는지 자동으로 감지하지는 않습니다. 코드 파생 문서는 아래 `Ingest` 규칙대로 코드를 고치는 PR에서 함께 고칩니다. - 사람이 PR 리뷰로 내용을 확인하면 `verified`에 `human:`를 추가하고 `status`를 `stable`로 올립니다. 에이전트는 사람 확인 없이 `stable`로 올리지 않습니다. diff --git a/scripts/wiki-lint.mjs b/scripts/wiki-lint.mjs index 94c6696..a75fba6 100644 --- a/scripts/wiki-lint.mjs +++ b/scripts/wiki-lint.mjs @@ -72,14 +72,23 @@ function stripCode(body) { } /** - * 값이 날짜 또는 ISO 8601 문자열이면 Date로 바꿈 + * OKF v0.2 시각 표기. UTC 오프셋이 명시된 ISO 8601 datetime + * + * 날짜만 쓰면 타임존마다 다른 시각을 가리켜, 같은 문서가 어디서는 만료되고 어디서는 유효해짐 + */ +const OKF_DATETIME_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/; + +/** 시각 형식 오류 안내 문구 */ +const DATETIME_HINT = "`2026-12-31T00:00:00Z`처럼 UTC 오프셋을 붙인 ISO 8601 시각이어야 함"; + +/** + * 값이 오프셋을 명시한 ISO 8601 datetime 문자열이면 Date로 바꿈 * * @param {unknown} value - frontmatter 값 - * @return {Date | null} 해석할 수 없으면 null + * @return {Date | null} 형식이 맞지 않거나 해석할 수 없으면 null */ function toDate(value) { - if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value; - if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}/.test(value)) return null; + if (typeof value !== "string" || !OKF_DATETIME_PATTERN.test(value)) return null; const date = new Date(value); return Number.isNaN(date.getTime()) ? null : date; } @@ -147,7 +156,7 @@ function checkConcept(data, file, bundleRoot, now) { if (typeof by !== "string" || !ACTOR_PATTERN.test(by)) { error("`generated.by`는 `<도구>/<버전>` 형식이어야 함"); } - if (!toDate(at)) error("`generated.at`은 ISO 8601 시각이어야 함"); + if (!toDate(at)) error(`\`generated.at\`은 ${DATETIME_HINT}`); } const verified = data.verified === undefined ? [] : [data.verified].flat(); @@ -155,7 +164,7 @@ function checkConcept(data, file, bundleRoot, now) { if (typeof entry?.by !== "string" || !ACTOR_PATTERN.test(entry.by)) { error(`\`verified[${index}].by\`는 \`human:\`, \`process:\`, \`<도구>/<버전>\` 중 하나여야 함`); } - if (!toDate(entry?.at)) error(`\`verified[${index}].at\`은 ISO 8601 시각이어야 함`); + if (!toDate(entry?.at)) error(`\`verified[${index}].at\`은 ${DATETIME_HINT}`); }); // stable은 사람이 확인한 문서에만 붙임. 기계 확인만으로 올리면 신뢰 단계가 섞임 @@ -166,7 +175,7 @@ function checkConcept(data, file, bundleRoot, now) { if (data.stale_after !== undefined) { const staleAfter = toDate(data.stale_after); if (!staleAfter) { - error("`stale_after`는 ISO 8601 날짜여야 함"); + error(`\`stale_after\`는 ${DATETIME_HINT}`); } else if (data.status !== "deprecated") { const daysLeft = (staleAfter.getTime() - now.getTime()) / (24 * 60 * 60 * 1000); if (daysLeft <= 0) { diff --git a/scripts/wiki-lint.test.mjs b/scripts/wiki-lint.test.mjs index f961bef..1c5db60 100644 --- a/scripts/wiki-lint.test.mjs +++ b/scripts/wiki-lint.test.mjs @@ -17,7 +17,7 @@ status: draft sources: - resource: ../code.ts generated: { by: claude-code/claude-opus-5-5, at: 2026-10-02T00:00:00Z } -stale_after: 2027-01-01 +stale_after: 2027-01-01T00:00:00Z --- `; @@ -75,16 +75,28 @@ describe("lintWiki", () => { }); it("만료된 stale_after를 오류로, 임박한 stale_after를 경고로 봄", () => { - write("concept.md", VALID_FRONTMATTER.replace("2027-01-01", "2026-10-01")); + write("concept.md", VALID_FRONTMATTER.replace("2027-01-01T", "2026-10-01T")); assert.ok(errors().some((message) => message.includes("stale_after") && message.includes("지남"))); - write("concept.md", VALID_FRONTMATTER.replace("2027-01-01", "2026-10-10")); + write("concept.md", VALID_FRONTMATTER.replace("2027-01-01T", "2026-10-10T")); const warnings = lintWiki(bundle, {now: NOW}).filter((issue) => issue.level === "warning"); assert.equal(warnings.length, 1); }); + it("UTC 오프셋 없는 시각을 잡음", () => { + write("concept.md", VALID_FRONTMATTER + .replace("2027-01-01T00:00:00Z", "2027-01-01") + .replace("at: 2026-10-02T00:00:00Z", "at: 2026-10-02T00:00:00")); + const found = errors(); + assert.ok(found.some((message) => message.includes("`stale_after`") && message.includes("UTC 오프셋"))); + assert.ok(found.some((message) => message.includes("`generated.at`") && message.includes("UTC 오프셋"))); + + write("concept.md", VALID_FRONTMATTER.replace("2027-01-01T00:00:00Z", "2027-01-01T09:00:00+09:00")); + assert.deepEqual(errors(), []); + }); + it("deprecated 문서는 stale_after 만료를 보지 않음", () => { - write("concept.md", VALID_FRONTMATTER.replace("2027-01-01", "2026-01-01").replace("draft", "deprecated")); + write("concept.md", VALID_FRONTMATTER.replace("2027-01-01T", "2026-01-01T").replace("draft", "deprecated")); assert.deepEqual(errors(), []); }); diff --git a/wiki/architecture/ai-call-guard.md b/wiki/architecture/ai-call-guard.md index 8fa8cd6..f303ab8 100644 --- a/wiki/architecture/ai-call-guard.md +++ b/wiki/architecture/ai-call-guard.md @@ -9,7 +9,7 @@ sources: - resource: ../../functions/src/openai.ts - resource: ../../functions/src/translateFlashcards.ts generated: { by: claude-code/claude-opus-5-5, at: 2026-10-02T00:00:00Z } -stale_after: 2027-01-02 +stale_after: 2027-01-02T00:00:00Z --- # 왜 필요한가 diff --git a/wiki/architecture/firestore-data-boundary.md b/wiki/architecture/firestore-data-boundary.md index 4e8af12..9089498 100644 --- a/wiki/architecture/firestore-data-boundary.md +++ b/wiki/architecture/firestore-data-boundary.md @@ -12,7 +12,7 @@ sources: title: "fix(77): users 문서 쓰기를 소유 필드로 한정하고 사용량 카운터 분리" resource: https://github.com/chucoding/code-recall/pull/81 generated: { by: claude-code/claude-opus-5-5, at: 2026-10-02T00:00:00Z } -stale_after: 2027-01-02 +stale_after: 2027-01-02T00:00:00Z --- # 규칙 요약 diff --git a/wiki/architecture/system-overview.md b/wiki/architecture/system-overview.md index f99d972..1d1e938 100644 --- a/wiki/architecture/system-overview.md +++ b/wiki/architecture/system-overview.md @@ -11,7 +11,7 @@ sources: - resource: ../../functions/src/trending.ts - resource: ../../functions/src/flashcard-pregeneration.ts generated: { by: claude-code/claude-opus-5-5, at: 2026-10-02T00:00:00Z } -stale_after: 2027-01-02 +stale_after: 2027-01-02T00:00:00Z --- # 구성 diff --git a/wiki/decisions/wiki-location.md b/wiki/decisions/wiki-location.md index ac04d69..ebc2def 100644 --- a/wiki/decisions/wiki-location.md +++ b/wiki/decisions/wiki-location.md @@ -21,7 +21,7 @@ sources: title: Docs-as-code topologies resource: https://passo.uno/docs-as-code-topologies/ generated: { by: claude-code/claude-opus-5-5, at: 2026-10-02T00:00:00Z } -stale_after: 2027-04-02 +stale_after: 2027-04-02T00:00:00Z --- # 결정 diff --git a/wiki/flashcard/deck-size-budget.md b/wiki/flashcard/deck-size-budget.md index beb9941..255cb62 100644 --- a/wiki/flashcard/deck-size-budget.md +++ b/wiki/flashcard/deck-size-budget.md @@ -11,7 +11,7 @@ sources: title: "fix(82): 카드 덱을 문서 크기 예산 안으로 줄여 저장" resource: https://github.com/chucoding/code-recall/pull/83 generated: { by: claude-code/claude-opus-5-5, at: 2026-10-02T00:00:00Z } -stale_after: 2027-01-02 +stale_after: 2027-01-02T00:00:00Z --- # 규칙 diff --git a/wiki/flashcard/generation-paths.md b/wiki/flashcard/generation-paths.md index f585a51..7b4ec0f 100644 --- a/wiki/flashcard/generation-paths.md +++ b/wiki/flashcard/generation-paths.md @@ -11,7 +11,7 @@ sources: - resource: ../../functions/src/flashcard-pregeneration.ts - resource: ../../app/src/features/flashcard/model/useTodayFlashcards.ts generated: { by: claude-code/claude-opus-5-5, at: 2026-10-02T00:00:00Z } -stale_after: 2027-01-02 +stale_after: 2027-01-02T00:00:00Z --- # 세 경로 diff --git a/wiki/flashcard/pregeneration.md b/wiki/flashcard/pregeneration.md index d93b350..06530cb 100644 --- a/wiki/flashcard/pregeneration.md +++ b/wiki/flashcard/pregeneration.md @@ -11,7 +11,7 @@ sources: title: "feat(86): 오늘의 플래시카드를 사용자 자정에 서버에서 미리 생성" resource: https://github.com/chucoding/code-recall/pull/88 generated: { by: claude-code/claude-opus-5-5, at: 2026-10-02T00:00:00Z } -stale_after: 2027-01-02 +stale_after: 2027-01-02T00:00:00Z --- # 왜 만들었나 diff --git a/wiki/log.md b/wiki/log.md index cd478e2..a83110d 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -2,6 +2,10 @@ 새 항목은 맨 위에 추가합니다. 날짜 제목은 `## YYYY-MM-DD` 형식이고, 항목은 `**Creation**`, `**Update**`, `**Deprecation**` 중 하나로 시작합니다. +## 2026-10-05 + +- **Update** 개념 문서 7건의 `stale_after`를 UTC 오프셋을 붙인 시각으로 변경. OKF v0.2 스펙의 시각 표기 규칙을 따름 (#94) + ## 2026-10-02 - **Creation** 위키 번들 골격과 초기 개념 문서 7건 작성 (#94) From 6c821f6ba8e67a76c23107301de4b7d72e34d728 Mon Sep 17 00:00:00 2001 From: chucoding Date: Mon, 5 Oct 2026 01:45:14 +0900 Subject: [PATCH 6/7] =?UTF-8?q?ci(94):=20=EC=9C=84=ED=82=A4=20=EA=B2=80?= =?UTF-8?q?=EC=82=AC=20=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=EB=A5=BC=20?= =?UTF-8?q?=EA=B2=80=EC=82=AC=20=EA=B2=B0=EA=B3=BC=EC=97=90=20=EC=98=81?= =?UTF-8?q?=ED=96=A5=EC=9D=84=20=EC=A3=BC=EB=8A=94=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=EC=97=90=EC=84=9C=EB=A7=8C=20=EC=8B=A4=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md는 검사 스크립트가 읽지 않아 고쳐도 결과가 달라지지 않고, pnpm-lock.yaml은 app이나 functions 의존성만 바꿔도 바뀌어 관계없는 PR마다 위키 검사를 돌렸다. 그래서 두 경로를 빼고, PR과 main 푸시 트리거가 같은 경로 목록을 보도록 맞췄다. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/wiki-lint.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wiki-lint.yml b/.github/workflows/wiki-lint.yml index b31b4f1..570e897 100644 --- a/.github/workflows/wiki-lint.yml +++ b/.github/workflows/wiki-lint.yml @@ -5,15 +5,15 @@ on: paths: - "wiki/**" - "scripts/wiki-lint*.mjs" - - "AGENTS.md" - "package.json" - - "pnpm-lock.yaml" - ".github/workflows/wiki-lint.yml" push: branches: [main] paths: - "wiki/**" - "scripts/wiki-lint*.mjs" + - "package.json" + - ".github/workflows/wiki-lint.yml" workflow_dispatch: permissions: From 1ee7ecad21c1d42d0785818187823fcc007a5a46 Mon Sep 17 00:00:00 2001 From: chucoding Date: Mon, 5 Oct 2026 01:54:57 +0900 Subject: [PATCH 7/7] =?UTF-8?q?docs(94):=20AGENTS.md=EC=9D=98=20=EC=9C=84?= =?UTF-8?q?=ED=82=A4=20=EC=9E=91=EC=84=B1=20=EC=A0=88=EC=B0=A8=EB=A5=BC=20?= =?UTF-8?q?=EA=B0=9C=EC=9D=B8=20=EC=8A=A4=ED=82=AC=EB=A1=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md는 위키와 관계없는 작업에서도 매 세션 통째로 읽히는데, 절반 이상이 위키를 쓸 때만 필요한 frontmatter 형식과 Ingest, Query, Lint 절차였다. 그래서 항상 지킬 원칙과 이 저장소 전용 값만 남기고, OKF 작성 절차는 다른 프로젝트에서도 쓸 수 있게 개인 스킬 chucoding:okf-wiki로 옮겼다. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 85 ++++++--------------------------- wiki/decisions/wiki-location.md | 2 +- wiki/log.md | 1 + 3 files changed, 17 insertions(+), 71 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f394503..d69c6b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,79 +14,24 @@ 로컬 셋팅과 코드 규칙, 브랜치와 커밋 규칙은 [CONTRIBUTING.md](.github/CONTRIBUTING.md)를 따릅니다. -## 작업 전 +## 위키 -- 작업과 관련된 문서를 [wiki/index.md](wiki/index.md)에서 먼저 찾아 읽습니다. 원본 코드 전체를 다시 훑기 전에 위키부터 봅니다. -- 위키 내용과 코드가 다르면 코드가 현재 동작의 정본입니다. 어긋남을 작업 보고에 적고, 아래 `Ingest` 절차로 위키를 고칩니다. -- `status: draft`이거나 `verified`가 없는 문서는 사람이 확인하지 않은 내용입니다. 근거(`sources`)를 직접 열어 확인한 뒤 의존합니다. - -## 위키 구조 - -`wiki/`는 [OKF v0.2](https://github.com/GoogleCloudPlatform/open-knowledge-format) 번들입니다. 카파시 LLM Wiki 패턴의 세 계층은 이 저장소에서 아래와 같이 대응합니다. - -| 계층 | 위치 | 누가 쓰는가 | -|------|------|------| -| 원본 | 코드, `firestore.rules`, GitHub 이슈와 PR | 사람과 에이전트가 평소 작업으로 씀. 위키 작업 중에는 읽기만 함 | -| 위키 | `wiki/` | 에이전트가 쓰고 사람이 PR에서 리뷰 | -| 스키마 | 이 파일 | 사람이 정함 | - -- `index.md`는 디렉터리 목록이고 `log.md`는 변경 이력입니다. 두 이름은 개념 문서에 쓰지 않습니다. -- 개념 문서는 주제별 하위 디렉터리에 두고, 같은 디렉터리 `index.md`에 한 줄 설명과 함께 링크합니다. 새 하위 디렉터리를 만들면 상위 `index.md`에 링크합니다. -- 파일 이름은 `kebab-case.md`입니다. - -## 개념 문서 frontmatter - -OKF는 `type`만 필수지만 이 저장소는 신뢰 신호를 처음부터 강제합니다. `pnpm wiki:lint`가 검사합니다. - -```yaml ---- -type: Workflow # 필수. Architecture, Workflow, Data Rule, Security Boundary, Decision 등 -title: 오늘의 플래시카드 사전 생성 # 필수 -description: 한 문장 요약 # 필수 -tags: [flashcard, scheduler] # 선택 -status: draft # 필수. draft, stable, deprecated -sources: # 필수. 하나 이상 - - resource: ../../functions/src/flashcard-pregeneration.ts # 문서 기준 상대 경로. 존재해야 함 - - id: pr-88 - title: "feat(86): ..." - resource: https://github.com/chucoding/code-recall/pull/88 -generated: { by: claude-code/claude-opus-5-5, at: 2026-10-02T00:00:00Z } # 필수. <도구>/<모델> -verified: # 선택. stable이면 human: 항목 필수 - - { by: human:chucoding, at: 2026-10-03T00:00:00Z } -stale_after: 2027-01-02T00:00:00Z # 필수. 이 시각이 지나면 검사가 실패 ---- -``` +`wiki/`는 [OKF v0.2](https://github.com/GoogleCloudPlatform/open-knowledge-format) 형식의 LLM Wiki입니다. -- `generated.at`, `verified[].at`, `stale_after`처럼 시각을 담는 값은 모두 `2027-01-02T00:00:00Z`처럼 UTC 오프셋을 붙입니다. 날짜만 쓰면 타임존마다 다른 시각을 가리켜 OKF v0.2 스펙에 어긋납니다. -- `stale_after`는 코드에서 파생한 문서는 작성일로부터 3개월, 결정 기록은 6개월을 기본으로 잡습니다. -- 코드가 바뀌었는지 자동으로 감지하지는 않습니다. 코드 파생 문서는 아래 `Ingest` 규칙대로 코드를 고치는 PR에서 함께 고칩니다. -- 사람이 PR 리뷰로 내용을 확인하면 `verified`에 `human:`를 추가하고 `status`를 `stable`로 올립니다. 에이전트는 사람 확인 없이 `stable`로 올리지 않습니다. -- 더 이상 맞지 않지만 링크와 이력 때문에 남길 문서는 `status: deprecated`로 바꾸고 대체 문서를 본문에 링크합니다. +### 항상 지키는 원칙 -## 무엇을 쓰고 무엇을 쓰지 않는가 - -- 씁니다: 코드만 봐서는 알기 어려운 이유, 앱과 서버처럼 여러 곳을 함께 바꿔야 하는 값, 보안과 비용 경계, 되돌리기 어려운 결정 -- 쓰지 않습니다: 코드를 그대로 옮긴 설명, 함수 시그니처 목록, 커밋 이력으로 충분한 변경 내용 -- 저장소가 public이므로 비밀 값, 비용과 매출 수치, 사용자 개인정보, 운영 계정 정보는 쓰지 않습니다. 배경은 [위키를 코드 저장소에 두는 결정](wiki/decisions/wiki-location.md)에 있습니다. - -## 운영 동작 - -### Ingest - -코드나 규칙을 바꾸는 PR에서, 바뀐 사실을 다루는 위키 문서가 있으면 같은 PR에서 함께 고칩니다. - -1. `wiki/index.md`에서 영향받는 문서를 찾습니다. -2. 본문과 `sources`, `generated`를 갱신하고 `stale_after`를 다시 잡습니다. 내용이 바뀌면 기존 `verified`는 지우고 `status`를 `draft`로 내립니다. -3. 새 주제면 개념 문서를 만들고 디렉터리 `index.md`에 링크합니다. -4. `wiki/log.md` 맨 위 날짜 아래에 `**Creation**`, `**Update**`, `**Deprecation**` 중 하나로 시작하는 한 줄을 추가합니다. -5. `pnpm wiki:lint`를 통과시킵니다. - -### Query +- 작업과 관련된 문서를 [wiki/index.md](wiki/index.md)에서 먼저 찾아 읽습니다. 원본 코드 전체를 다시 훑기 전에 위키부터 봅니다. +- 위키 내용과 코드가 다르면 코드가 현재 동작의 정본입니다. 어긋남을 작업 보고에 적고 위키를 고칩니다. +- `status: draft`이거나 `verified`가 없는 문서는 사람이 확인하지 않은 내용입니다. 근거(`sources`)를 직접 열어 확인한 뒤 의존합니다. +- 코드나 규칙을 바꾸는 PR에서, 바뀐 사실을 다루는 위키 문서가 있으면 같은 PR에서 함께 고칩니다. 코드 변경을 자동으로 감지하지는 않습니다. -설계 질문에는 위키를 먼저 찾아 답하고, 답에 쓴 문서를 링크합니다. 위키에 없어 코드를 조사해 얻은 답이 다시 쓸 만하면 `Ingest`로 문서를 추가합니다. +### 위키를 쓰거나 고칠 때 -### Lint +frontmatter 형식, 신뢰 규칙, Ingest와 Query와 Lint 절차는 `chucoding:okf-wiki` 스킬을 따릅니다. 이 저장소에서는 아래 값을 씁니다. -- 정적 검사: `pnpm wiki:lint`. frontmatter 필수 키, `sources` 경로 존재, 깨진 링크, `index.md` 누락, `log.md` 형식, `stale_after` 만료를 봅니다. CI는 위키와 검사 스크립트를 고친 PR과 main 푸시에서 실행합니다. 정기 실행은 두지 않으므로, `stale_after` 만료는 위키를 고치는 PR이나 로컬 실행에서 드러납니다. -- 의미 검사: 요청을 받으면 문서끼리의 모순, 코드와 어긋난 주장, 고아 개념, 빠진 상호 참조를 점검해 수정 PR을 올립니다. -- `stale_after`가 지난 문서는 `sources`를 다시 읽어 내용을 확인한 뒤 날짜를 갱신합니다. 확인 없이 날짜만 미루지 않습니다. +| 항목 | 값 | +|------|------| +| 문서 유형(`type`) | `Architecture`, `Workflow`, `Data Rule`, `Security Boundary`, `Decision` | +| `stale_after` 기본 기간 | 코드 파생 문서는 작성일로부터 3개월, 결정 기록은 6개월 | +| 정적 검사 | `pnpm wiki:lint`. CI는 위키와 검사 스크립트를 고친 PR과 main 푸시에서 실행 | +| 쓰지 않는 내용 | 저장소가 public이므로 비밀 값, 비용과 매출 수치, 사용자 개인정보, 운영 계정 정보. 배경은 [위키를 코드 저장소에 두는 결정](wiki/decisions/wiki-location.md) | diff --git a/wiki/decisions/wiki-location.md b/wiki/decisions/wiki-location.md index ebc2def..c38457a 100644 --- a/wiki/decisions/wiki-location.md +++ b/wiki/decisions/wiki-location.md @@ -26,7 +26,7 @@ stale_after: 2027-04-02T00:00:00Z # 결정 -LLM Wiki를 이 저장소의 `wiki/` 디렉터리에 OKF v0.2 번들로 둡니다. 에이전트 규칙(카파시 패턴의 schema 계층)은 루트 `AGENTS.md`가 정본이고, `CLAUDE.md`는 그 파일을 불러오기만 합니다. +LLM Wiki를 이 저장소의 `wiki/` 디렉터리에 OKF v0.2 번들로 둡니다. 에이전트 규칙(카파시 패턴의 schema 계층)은 루트 `AGENTS.md`가 정본이고, `CLAUDE.md`는 그 파일을 불러오기만 합니다. `AGENTS.md`에는 항상 지킬 원칙과 이 저장소 전용 값만 두고, 위키를 쓸 때만 필요한 OKF 작성 절차는 다른 프로젝트에서도 쓸 수 있게 개인 스킬 `chucoding:okf-wiki`로 분리합니다. `AGENTS.md`는 위키와 관계없는 작업에서도 매번 읽히기 때문입니다. # 검토한 선택지 diff --git a/wiki/log.md b/wiki/log.md index a83110d..68b4d4f 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -4,6 +4,7 @@ ## 2026-10-05 +- **Update** [위키를 코드 저장소에 두는 결정](decisions/wiki-location.md)에 위키 작성 절차를 개인 스킬로 분리한 내용 추가 (#94) - **Update** 개념 문서 7건의 `stale_after`를 UTC 오프셋을 붙인 시각으로 변경. OKF v0.2 스펙의 시각 표기 규칙을 따름 (#94) ## 2026-10-02