For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 저장소의 마크다운 문서 34편을 /docs 경로에서 읽을 수 있게 하고, 공개해서는 안 되는 값이 빌드를 통과하지 못하게 막는다.
Architecture: 기존 근거 파이프라인과 대칭 구조다. 명시적 매니페스트가 공개 대상을 선언하고, 빌드 앞단의 scripts/verify-docs.ts가 원본을 읽어 안전 검사·가림·HTML 변환을 거친 스냅샷 content/generated/docs.public.json을 만든다. 웹 런타임은 그 스냅샷만 읽으므로 원본 저장소를 직접 읽지 않는다.
Tech Stack: Next.js 16 App Router, React 19, TypeScript, node:test + tsx(단위·통합), Playwright(브라우저), markdown-it(빌드 전용 devDependency)
Global Constraints
- 설계 문서:
docs/superpowers/specs/2026-08-17-public-docs-browser-design.md - 웹 런타임은 원본 저장소를 직접 읽지 않는다. 런타임이 읽는 것은
content/generated/docs.public.json하나뿐이다. (헌법 원칙 V) - 런타임 의존성은
next,react,react-dom셋으로 유지한다. markdown-it은 반드시devDependencies에 넣는다. - 공개 대상은 글로브가 아니라
content/docs-manifest.ts에 선언한 항목만이다. - 절대 서버 경로와 사설 주소는 가린다. 개인키·Authorization 헤더·Bearer 토큰·비밀값 할당·데이터베이스 URL·이메일·허용 목록 밖 외부 URL은 가리지 않고 빌드를 실패시킨다.
- 허용 URL 원본은
https://harness.insapien.co.kr하나로 시작한다. - 모든 페이지는 정적 생성하고 JavaScript 없이 읽을 수 있어야 한다. (헌법 원칙 VI)
- 테스트를 구현보다 먼저 쓰고 RED를 확인한 뒤 GREEN으로 만든다. (헌법 원칙 III)
- 문서 안의
#제목은 페이지 h1과 충돌하므로 한 단계씩 낮춰 h2부터 시작한다. - 커밋 메시지는 한국어로 쓰고 무엇을 왜 바꿨는지 남긴다.
시작 전 상태: npm run verify:manual이 unknown public route: /docs 하나로 실패한다. Task 1이 이것을 GREEN으로 만든다.
Task 1: /docs를 공개 경로로 선언한다
문서와 게이트를 먼저 고친다. 헌법 원칙 II가 명세 수정이 구현을 선행하도록 요구한다. 이 시점에는 페이지가 없으므로 헤더 메뉴(content/site.ts)는 아직 건드리지 않는다. 링크가 깨진 메뉴를 먼저 만들지 않기 위해서다.
Files:
- Modify:
.specify/memory/constitution.md(기술 및 운영 제약의 경로 목록) - Modify:
scripts/verify-manual.mjs:13-21(PUBLIC_ROUTES) - Modify:
docs/manual-impact.md
Interfaces:
-
Consumes: 없음
-
Produces:
PUBLIC_ROUTES에/docs가 포함되어 이후 모든 문서가/docs경로를 언급할 수 있다. -
[ ] Step 1: 실패를 먼저 확인한다
Run: npm run verify:manual
Expected: FAIL — docs/superpowers/specs/2026-08-17-public-docs-browser-design.md: unknown public route: /docs
- [ ] Step 2: 헌법의 경로 제약을 다섯 개로 개정한다
.specify/memory/constitution.md의 기술 및 운영 제약에서 아래 줄을 찾는다.
- 공개 사이트는 한국어 개발자 독자를 우선하며 `/`, `/guide`, `/5240lab`,
`/roadmap` 네 개의 안정적인 경로를 제공한다.
다음으로 바꾼다.
- 공개 사이트는 한국어 개발자 독자를 우선하며 `/`, `/guide`, `/5240lab`,
`/roadmap`, `/docs` 다섯 개의 안정적인 경로를 제공한다. `/docs`는 매니페스트에
선언한 문서만 공개하며, 빌드 시 검증된 스냅샷만 런타임에 전달한다.
- [ ] Step 3: 매뉴얼 게이트의 공개 경로 목록에
/docs를 더한다
scripts/verify-manual.mjs의 PUBLIC_ROUTES를 수정한다.
const PUBLIC_ROUTES = new Set([
"/",
"/guide",
"/5240lab",
"/roadmap",
"/docs",
"/api/health",
"/robots.txt",
"/sitemap.xml",
]);
- [ ] Step 4: 매뉴얼 영향을 기록한다
docs/manual-impact.md 끝에 다음 절을 추가한다.
## 공개 문서 조회 (2026-08-17)
- manual-impact: yes
- 근거: 공개 경로가 네 개에서 다섯 개로 늘고 `/docs` 아래 문서 상세 경로가 생긴다.
- 조치: 운영 매뉴얼의 외부 스모크 경로 목록에 `/docs`를 더하고, 공개 대상 문서는
`content/docs-manifest.ts`에 선언한 것만이라는 규칙을 운영 매뉴얼에 남긴다.
- 공개 경로 확인: `/docs` 목록과 `/docs/<묶음>/<문서>` 상세가 모두 200으로 응답해야 한다.
- [ ] Step 5: 게이트가 통과하는지 확인한다
Run: npm run verify:manual
Expected: PASS — Manual contract verified: commands, links, routes, public URLs, and leak checks passed.
- [ ] Step 6: 커밋
git add .specify/memory/constitution.md scripts/verify-manual.mjs docs/manual-impact.md
git commit -m "docs(harness): 공개 경로에 /docs를 더하고 헌법과 매뉴얼 영향을 맞춘다"
Task 2: 문서 공개 안전 정책
가리는 것과 실패시키는 것을 나누는 모듈이다. 기존 lib/evidence-validator.ts의 UNSAFE_PUBLIC_PATTERNS는 모든 URL을 금지하므로 그대로 쓸 수 없다. 문서용 정책을 따로 둔다.
Files:
- Create:
lib/docs-safety.ts - Test:
tests/unit/docs-safety.test.ts
Interfaces:
-
Consumes: 없음
-
Produces:
class DocSafetyError extends Errorinterface DocRedaction { kind: "absolute-path" | "private-address"; original: string }interface DocRedactionResult { text: string; redactions: DocRedaction[] }function redactDocText(text: string): DocRedactionResultfunction assertDocPublicSafe(text: string, docId: string): voidconst ALLOWED_DOC_ORIGINS: readonly string[]
-
[ ] Step 1: 실패하는 테스트를 쓴다
Create tests/unit/docs-safety.test.ts:
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
ALLOWED_DOC_ORIGINS,
assertDocPublicSafe,
DocSafetyError,
redactDocText,
} from "../../lib/docs-safety";
describe("redactDocText", () => {
it("절대 서버 경로를 프로젝트 루트 표기로 바꾼다", () => {
const result = redactDocText("프로젝트 루트: `<프로젝트 루트>`");
assert.equal(result.text, "프로젝트 루트: `<프로젝트 루트>`");
assert.deepEqual(result.redactions, [
{ kind: "absolute-path", original: "<프로젝트 루트>" },
]);
});
it("사설 주소와 포트를 로컬 표기로 바꾼다", () => {
const result = redactDocText("후보 검사: <로컬>/api/health");
assert.equal(result.text, "후보 검사: <로컬>/api/health");
assert.deepEqual(result.redactions, [
{ kind: "private-address", original: "<로컬>" },
]);
});
it("허용 원본의 주소는 건드리지 않는다", () => {
const input = "외부 스모크: https://harness.insapien.co.kr/api/health";
assert.equal(redactDocText(input).text, input);
});
it("가릴 것이 없으면 원문을 그대로 돌려준다", () => {
const input = "제목 폭은 em으로 잡는다.";
const result = redactDocText(input);
assert.equal(result.text, input);
assert.deepEqual(result.redactions, []);
});
});
describe("assertDocPublicSafe", () => {
it("가림으로 처리된 문서는 통과시킨다", () => {
assert.doesNotThrow(() => {
assertDocPublicSafe("배포 주소는 https://harness.insapien.co.kr 이다.", "operations");
});
});
it("개인키 표식을 거부한다", () => {
assert.throws(
() => assertDocPublicSafe("-----BEGIN PRIVATE KEY-----", "operations"),
(error: unknown) => error instanceof DocSafetyError && /개인키/u.test((error as Error).message),
);
});
it("비밀값 할당을 거부한다", () => {
assert.throws(
() => assertDocPublicSafe("DATABASE_URL=postgres://user:pw@host/db", "operations"),
DocSafetyError,
);
});
it("이메일 주소를 거부한다", () => {
assert.throws(() => assertDocPublicSafe("문의: someone@example.com", "handoff"), DocSafetyError);
});
it("허용 목록 밖의 외부 주소를 거부한다", () => {
assert.throws(() => assertDocPublicSafe("참고: https://example.com/a", "research"), DocSafetyError);
});
it("가리지 않은 절대 경로가 남아 있으면 거부한다", () => {
assert.throws(() => assertDocPublicSafe("루트는 <프로젝트 루트> 이다.", "quickstart"), DocSafetyError);
});
it("오류 메시지에 문서 ID를 담는다", () => {
assert.throws(
() => assertDocPublicSafe("문의: someone@example.com", "handoff"),
(error: unknown) => error instanceof DocSafetyError && /handoff/u.test((error as Error).message),
);
});
});
describe("ALLOWED_DOC_ORIGINS", () => {
it("사이트 자기 주소 하나로 시작한다", () => {
assert.deepEqual([...ALLOWED_DOC_ORIGINS], ["https://harness.insapien.co.kr"]);
});
});
- [ ] Step 2: RED를 확인한다
Run: node --import tsx --test tests/unit/docs-safety.test.ts
Expected: FAIL — Cannot find module '../../lib/docs-safety'
- [ ] Step 3: 최소 구현을 쓴다
Create lib/docs-safety.ts:
export class DocSafetyError extends Error {
constructor(message: string) {
super(message);
this.name = "DocSafetyError";
}
}
export const ALLOWED_DOC_ORIGINS = ["https://harness.insapien.co.kr"] as const;
export interface DocRedaction {
kind: "absolute-path" | "private-address";
original: string;
}
export interface DocRedactionResult {
text: string;
redactions: DocRedaction[];
}
const PRIVATE_ADDRESS_PATTERN =
/https?:\/\/(?:localhost|127\.\d{1,3}\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})(?::\d{2,5})?/gu;
const ABSOLUTE_PATH_PATTERN =
/\/(?:home|Users|root|etc|opt|srv|usr|var|tmp)(?:\/[A-Za-z0-9._-]+)*/gu;
const FAILING_PATTERNS: ReadonlyArray<[RegExp, string]> = [
[/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/iu, "개인키 표식"],
[/\bAuthorization\s*:\s*(?:Bearer|Basic)\s+\S+/iu, "Authorization 헤더"],
[/\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/u, "Bearer 토큰"],
[/\b(?:password|passwd|token|secret|api[_-]?key|database_url)\s*=\s*\S+/iu, "비밀값 할당"],
[/\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?):\/\/\S+/iu, "데이터베이스 URL"],
[/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/iu, "이메일 주소"],
];
export function redactDocText(text: string): DocRedactionResult {
const redactions: DocRedaction[] = [];
const withoutPrivateAddresses = text.replace(PRIVATE_ADDRESS_PATTERN, (match) => {
redactions.push({ kind: "private-address", original: match });
return "<로컬>";
});
const withoutAbsolutePaths = withoutPrivateAddresses.replace(ABSOLUTE_PATH_PATTERN, (match) => {
redactions.push({ kind: "absolute-path", original: match });
return "<프로젝트 루트>";
});
return { text: withoutAbsolutePaths, redactions };
}
export function assertDocPublicSafe(text: string, docId: string): void {
for (const [pattern, label] of FAILING_PATTERNS) {
if (pattern.test(text)) {
throw new DocSafetyError(`${docId}: 공개할 수 없는 값(${label})이 있습니다.`);
}
}
for (const match of text.matchAll(/https?:\/\/[^\s)"'`<>]+/gu)) {
const origin = new URL(match[0]).origin;
if (!ALLOWED_DOC_ORIGINS.includes(origin as (typeof ALLOWED_DOC_ORIGINS)[number])) {
throw new DocSafetyError(`${docId}: 허용 목록에 없는 외부 주소(${origin})가 있습니다.`);
}
}
const leftoverPath = text.match(ABSOLUTE_PATH_PATTERN);
if (leftoverPath) {
throw new DocSafetyError(`${docId}: 가려지지 않은 절대 경로(${leftoverPath[0]})가 있습니다.`);
}
}
- [ ] Step 4: GREEN을 확인한다
Run: node --import tsx --test tests/unit/docs-safety.test.ts
Expected: PASS — 12 tests
- [ ] Step 5: 커밋
git add lib/docs-safety.ts tests/unit/docs-safety.test.ts
git commit -m "feat(harness): 문서 공개 안전 정책을 더한다"
Task 3: 마크다운 변환기
빌드 시점에만 도는 변환기다. markdown-it은 기본값이 html: false라 원시 HTML을 이스케이프한다. 별도 위생 처리기가 필요 없다.
Files:
- Create:
lib/docs-markdown.ts - Test:
tests/unit/docs-markdown.test.ts - Modify:
package.json(devDependencies)
Interfaces:
-
Consumes: 없음
-
Produces:
interface DocHeading { id: string; text: string; level: 2 | 3 | 4 }interface DocRender { html: string; toc: DocHeading[] }function renderDocMarkdown(markdown: string): DocRenderfunction extractDocTitle(markdown: string): string— 첫#제목. 없으면DocSafetyError가 아니라Error를 던진다.
-
[ ] Step 1: 변환기를 빌드 전용 의존성으로 더한다
npm install --save-dev markdown-it@15 @types/markdown-it@14
package.json의 dependencies가 next, react, react-dom 셋 그대로인지 확인한다.
- [ ] Step 2: 실패하는 테스트를 쓴다
Create tests/unit/docs-markdown.test.ts:
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { extractDocTitle, renderDocMarkdown } from "../../lib/docs-markdown";
describe("renderDocMarkdown", () => {
it("문서의 제목을 한 단계씩 낮춘다", () => {
const { html } = renderDocMarkdown("# 문서 제목\n\n## 절 제목\n\n### 항 제목\n");
assert.match(html, /<h2 id="문서-제목">문서 제목<\/h2>/u);
assert.match(html, /<h3 id="절-제목">절 제목<\/h3>/u);
assert.match(html, /<h4 id="항-제목">항 제목<\/h4>/u);
});
it("낮춘 제목을 목차로 모은다", () => {
const { toc } = renderDocMarkdown("# 제목\n\n## 배경\n\n### 세부\n");
assert.deepEqual(toc, [
{ id: "제목", text: "제목", level: 2 },
{ id: "배경", text: "배경", level: 3 },
{ id: "세부", text: "세부", level: 4 },
]);
});
it("같은 제목이 반복되면 앵커에 번호를 붙인다", () => {
const { toc } = renderDocMarkdown("## 검증\n\n## 검증\n");
assert.deepEqual(
toc.map((heading) => heading.id),
["검증", "검증-2"],
);
});
it("원시 HTML을 실행 가능한 태그로 내보내지 않는다", () => {
const { html } = renderDocMarkdown("<script>alert(1)</script>\n");
assert.doesNotMatch(html, /<script>/u);
assert.match(html, /<script>/u);
});
it("표와 코드 블록을 변환한다", () => {
const { html } = renderDocMarkdown("| a | b |\n| --- | --- |\n| 1 | 2 |\n\n```bash\nls\n```\n");
assert.match(html, /<table>/u);
assert.match(html, /<code[^>]*>ls\n<\/code>/u);
});
it("h5 아래는 더 낮추지 않고 h6에서 멈춘다", () => {
const { html } = renderDocMarkdown("##### 다섯\n\n###### 여섯\n");
assert.match(html, /<h6[^>]*>다섯<\/h6>/u);
assert.match(html, /<h6[^>]*>여섯<\/h6>/u);
});
});
describe("extractDocTitle", () => {
it("첫 번째 최상위 제목을 뽑는다", () => {
assert.equal(extractDocTitle("# 운영 매뉴얼\n\n본문\n"), "운영 매뉴얼");
});
it("최상위 제목이 없으면 거부한다", () => {
assert.throws(() => extractDocTitle("본문만 있다\n"), /최상위 제목/u);
});
});
- [ ] Step 3: RED를 확인한다
Run: node --import tsx --test tests/unit/docs-markdown.test.ts
Expected: FAIL — Cannot find module '../../lib/docs-markdown'
- [ ] Step 4: 최소 구현을 쓴다
Create lib/docs-markdown.ts:
import MarkdownIt from "markdown-it";
export interface DocHeading {
id: string;
text: string;
level: 2 | 3 | 4;
}
export interface DocRender {
html: string;
toc: DocHeading[];
}
function slugify(text: string): string {
return text
.trim()
.toLowerCase()
.replace(/[^\p{Letter}\p{Number}\s-]/gu, "")
.replace(/\s+/gu, "-");
}
export function extractDocTitle(markdown: string): string {
const match = markdown.match(/^#\s+(.+)$/mu);
if (!match) {
throw new Error("문서에 최상위 제목이 없습니다.");
}
return match[1].trim();
}
export function renderDocMarkdown(markdown: string): DocRender {
const md = new MarkdownIt({ html: false, linkify: false, typographer: false });
const tokens = md.parse(markdown, {});
const toc: DocHeading[] = [];
const usedIds = new Map<string, number>();
for (const [index, token] of tokens.entries()) {
if (token.type !== "heading_open") continue;
const sourceLevel = Number(token.tag.slice(1));
const targetLevel = Math.min(sourceLevel + 1, 6);
token.tag = `h${targetLevel}`;
const closing = tokens[index + 2];
if (closing?.type === "heading_close") {
closing.tag = `h${targetLevel}`;
}
const text = tokens[index + 1]?.content ?? "";
const base = slugify(text);
const seen = usedIds.get(base) ?? 0;
usedIds.set(base, seen + 1);
const id = seen === 0 ? base : `${base}-${seen + 1}`;
token.attrSet("id", id);
if (targetLevel <= 4) {
toc.push({ id, text, level: targetLevel as 2 | 3 | 4 });
}
}
return { html: md.renderer.render(tokens, md.options, {}), toc };
}
- [ ] Step 5: GREEN을 확인한다
Run: node --import tsx --test tests/unit/docs-markdown.test.ts
Expected: PASS — 8 tests
- [ ] Step 6: 커밋
git add lib/docs-markdown.ts tests/unit/docs-markdown.test.ts package.json package-lock.json
git commit -m "feat(harness): 빌드 전용 마크다운 변환기를 더한다"
Task 4: 매니페스트와 스키마
공개 대상을 손으로 선언한다. 제목은 문서의 최상위 제목에서 가져오므로 매니페스트에는 적지 않는다. 제목이 두 곳에서 어긋나는 일을 막기 위해서다.
Files:
- Create:
lib/docs-schema.ts - Create:
content/docs-manifest.ts - Test:
tests/unit/docs-schema.test.ts
Interfaces:
-
Consumes: 없음
-
Produces:
const DOC_GROUPS: readonly { id: DocGroupId; label: string; description: string }[]type DocGroupId = "design" | "specs" | "operations" | "standards"interface DocManifestEntry { group: DocGroupId; slug: string; path: string; summary: string }interface DocsManifest { entries: DocManifestEntry[] }function parseDocsManifest(value: unknown): DocsManifestinterface DocPublic { group: DocGroupId; slug: string; title: string; summary: string; sourcePath: string; html: string; toc: DocHeading[] }interface DocsPublicSnapshot { generatedFrom: string; docs: DocPublic[] }function parseDocsPublicSnapshot(value: unknown): DocsPublicSnapshotconst docsManifest: DocsManifest(content/docs-manifest.ts)
-
[ ] Step 1: 실패하는 테스트를 쓴다
Create tests/unit/docs-schema.test.ts:
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { docsManifest } from "../../content/docs-manifest";
import { DOC_GROUPS, parseDocsManifest, parseDocsPublicSnapshot } from "../../lib/docs-schema";
describe("parseDocsManifest", () => {
it("올바른 매니페스트를 통과시킨다", () => {
const parsed = parseDocsManifest({
entries: [
{ group: "design", slug: "public-docs-browser", path: "docs/a.md", summary: "요약" },
],
});
assert.equal(parsed.entries.length, 1);
});
it("알 수 없는 묶음을 거부한다", () => {
assert.throws(
() => parseDocsManifest({ entries: [{ group: "unknown", slug: "a", path: "a.md", summary: "요약" }] }),
/묶음/u,
);
});
it("kebab-case가 아닌 슬러그를 거부한다", () => {
assert.throws(
() => parseDocsManifest({ entries: [{ group: "design", slug: "Not_Kebab", path: "a.md", summary: "요약" }] }),
/슬러그/u,
);
});
it("묶음 안에서 슬러그 중복을 거부한다", () => {
assert.throws(
() =>
parseDocsManifest({
entries: [
{ group: "design", slug: "a", path: "a.md", summary: "요약" },
{ group: "design", slug: "a", path: "b.md", summary: "요약" },
],
}),
/중복/u,
);
});
it("마크다운이 아닌 경로를 거부한다", () => {
assert.throws(
() => parseDocsManifest({ entries: [{ group: "design", slug: "a", path: "a.txt", summary: "요약" }] }),
/\.md/u,
);
});
it("프로젝트 밖을 가리키는 경로를 거부한다", () => {
assert.throws(
() => parseDocsManifest({ entries: [{ group: "design", slug: "a", path: "../secret.md", summary: "요약" }] }),
/프로젝트 안/u,
);
});
it("빈 요약을 거부한다", () => {
assert.throws(
() => parseDocsManifest({ entries: [{ group: "design", slug: "a", path: "a.md", summary: "" }] }),
/요약/u,
);
});
});
describe("docsManifest", () => {
it("네 묶음을 모두 채운다", () => {
const groups = new Set(docsManifest.entries.map((entry) => entry.group));
assert.deepEqual([...groups].sort(), ["design", "operations", "specs", "standards"]);
});
it("설계 문서에 이번 설계가 들어 있다", () => {
const slugs = docsManifest.entries.map((entry) => entry.slug);
assert.ok(slugs.includes("public-docs-browser"));
});
it("스키마를 통과한다", () => {
assert.doesNotThrow(() => parseDocsManifest(docsManifest));
});
it("DOC_GROUPS에 선언한 묶음만 쓴다", () => {
const declared = new Set(DOC_GROUPS.map((group) => group.id));
for (const entry of docsManifest.entries) {
assert.ok(declared.has(entry.group), `${entry.slug}의 묶음이 선언되지 않았습니다.`);
}
});
});
describe("parseDocsPublicSnapshot", () => {
it("올바른 스냅샷을 통과시킨다", () => {
const parsed = parseDocsPublicSnapshot({
generatedFrom: "content/docs-manifest.ts",
docs: [
{
group: "design",
slug: "public-docs-browser",
title: "공개 문서 조회 설계",
summary: "요약",
sourcePath: "docs/superpowers/specs/2026-08-17-public-docs-browser-design.md",
html: "<h2 id=\"a\">a</h2>",
toc: [{ id: "a", text: "a", level: 2 }],
},
],
});
assert.equal(parsed.docs.length, 1);
});
it("본문이 빈 문서를 거부한다", () => {
assert.throws(
() =>
parseDocsPublicSnapshot({
generatedFrom: "content/docs-manifest.ts",
docs: [
{
group: "design",
slug: "a",
title: "제목",
summary: "요약",
sourcePath: "a.md",
html: "",
toc: [],
},
],
}),
/본문/u,
);
});
});
- [ ] Step 2: RED를 확인한다
Run: node --import tsx --test tests/unit/docs-schema.test.ts
Expected: FAIL — Cannot find module '../../lib/docs-schema'
- [ ] Step 3: 스키마를 쓴다
Create lib/docs-schema.ts:
import type { DocHeading } from "./docs-markdown";
export class DocsSchemaError extends Error {
constructor(message: string) {
super(message);
this.name = "DocsSchemaError";
}
}
export const DOC_GROUPS = [
{ id: "design", label: "설계", description: "브레인스토밍으로 확정한 설계 문서" },
{ id: "specs", label: "명세·계획", description: "기능정의서, 기술계획, 작업목록과 계약" },
{ id: "operations", label: "운영·검증", description: "운영 절차와 검증·배포 기록" },
{ id: "standards", label: "기준문서", description: "헌법과 디자인 시스템" },
] as const;
export type DocGroupId = (typeof DOC_GROUPS)[number]["id"];
const GROUP_IDS = new Set<string>(DOC_GROUPS.map((group) => group.id));
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
export interface DocManifestEntry {
group: DocGroupId;
slug: string;
path: string;
summary: string;
}
export interface DocsManifest {
entries: DocManifestEntry[];
}
export interface DocPublic {
group: DocGroupId;
slug: string;
title: string;
summary: string;
sourcePath: string;
html: string;
toc: DocHeading[];
}
export interface DocsPublicSnapshot {
generatedFrom: string;
docs: DocPublic[];
}
function asRecord(value: unknown, label: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new DocsSchemaError(`${label}은(는) 객체여야 합니다.`);
}
return value as Record<string, unknown>;
}
function asNonEmptyString(value: unknown, label: string): string {
if (typeof value !== "string" || value.trim() === "") {
throw new DocsSchemaError(`${label}은(는) 비어 있지 않은 문자열이어야 합니다.`);
}
return value;
}
export function parseDocsManifest(value: unknown): DocsManifest {
const record = asRecord(value, "문서 매니페스트");
const rawEntries = record.entries;
if (!Array.isArray(rawEntries) || rawEntries.length === 0) {
throw new DocsSchemaError("문서 매니페스트에는 항목이 하나 이상 있어야 합니다.");
}
const seen = new Set<string>();
const entries = rawEntries.map((rawEntry, index) => {
const entry = asRecord(rawEntry, `문서 매니페스트 ${index + 1}번째 항목`);
const group = asNonEmptyString(entry.group, `${index + 1}번째 항목의 묶음`);
if (!GROUP_IDS.has(group)) {
throw new DocsSchemaError(`알 수 없는 묶음 "${group}"입니다.`);
}
const slug = asNonEmptyString(entry.slug, `${index + 1}번째 항목의 슬러그`);
if (!SLUG_PATTERN.test(slug)) {
throw new DocsSchemaError(`슬러그 "${slug}"은(는) 소문자 kebab-case여야 합니다.`);
}
const key = `${group}/${slug}`;
if (seen.has(key)) {
throw new DocsSchemaError(`슬러그 "${key}"이(가) 중복되었습니다.`);
}
seen.add(key);
const docPath = asNonEmptyString(entry.path, `${index + 1}번째 항목의 경로`);
if (!docPath.endsWith(".md")) {
throw new DocsSchemaError(`경로 "${docPath}"은(는) .md 파일이어야 합니다.`);
}
if (docPath.startsWith("/") || docPath.split("/").includes("..")) {
throw new DocsSchemaError(`경로 "${docPath}"은(는) 프로젝트 안의 상대 경로여야 합니다.`);
}
const summary = asNonEmptyString(entry.summary, `${index + 1}번째 항목의 요약`);
return { group: group as DocGroupId, slug, path: docPath, summary };
});
return { entries };
}
export function parseDocsPublicSnapshot(value: unknown): DocsPublicSnapshot {
const record = asRecord(value, "문서 스냅샷");
const generatedFrom = asNonEmptyString(record.generatedFrom, "스냅샷 출처");
const rawDocs = record.docs;
if (!Array.isArray(rawDocs) || rawDocs.length === 0) {
throw new DocsSchemaError("문서 스냅샷에는 문서가 하나 이상 있어야 합니다.");
}
const docs = rawDocs.map((rawDoc, index) => {
const doc = asRecord(rawDoc, `${index + 1}번째 문서`);
const group = asNonEmptyString(doc.group, `${index + 1}번째 문서의 묶음`);
if (!GROUP_IDS.has(group)) {
throw new DocsSchemaError(`알 수 없는 묶음 "${group}"입니다.`);
}
return {
group: group as DocGroupId,
slug: asNonEmptyString(doc.slug, `${index + 1}번째 문서의 슬러그`),
title: asNonEmptyString(doc.title, `${index + 1}번째 문서의 제목`),
summary: asNonEmptyString(doc.summary, `${index + 1}번째 문서의 요약`),
sourcePath: asNonEmptyString(doc.sourcePath, `${index + 1}번째 문서의 원본 경로`),
html: asNonEmptyString(doc.html, `${index + 1}번째 문서의 본문`),
toc: (doc.toc ?? []) as DocHeading[],
};
});
return { generatedFrom, docs };
}
- [ ] Step 4: 매니페스트를 쓴다
Create content/docs-manifest.ts. 34편 전부를 선언한다.
import type { DocsManifest } from "../lib/docs-schema";
export const docsManifest: DocsManifest = {
entries: [
// 설계
{ group: "design", slug: "harness-website", path: "docs/superpowers/specs/2026-08-13-harness-website-design.md", summary: "공개 사이트의 정보 구조와 네 경로를 정한 최초 설계." },
{ group: "design", slug: "site-typography-scale", path: "docs/superpowers/specs/2026-08-15-site-typography-scale-design.md", summary: "제목과 본문의 크기 위계를 다시 잡은 설계." },
{ group: "design", slug: "public-docs-browser", path: "docs/superpowers/specs/2026-08-17-public-docs-browser-design.md", summary: "저장소 문서를 공개 경로에서 읽게 만든 설계." },
// 명세·계획
{ group: "specs", slug: "001-spec", path: "specs/001-harness-public-site/spec.md", summary: "공개 사이트의 사용자 시나리오와 수용 조건." },
{ group: "specs", slug: "001-plan", path: "specs/001-harness-public-site/plan.md", summary: "공개 사이트의 기술 계획과 데이터 흐름." },
{ group: "specs", slug: "001-tasks", path: "specs/001-harness-public-site/tasks.md", summary: "공개 사이트 구현을 단계별 작업으로 나눈 목록." },
{ group: "specs", slug: "001-research", path: "specs/001-harness-public-site/research.md", summary: "공개 사이트 구현 전 조사한 선택지와 근거." },
{ group: "specs", slug: "001-data-model", path: "specs/001-harness-public-site/data-model.md", summary: "근거, 계층, 성숙도 데이터의 구조." },
{ group: "specs", slug: "001-quickstart", path: "specs/001-harness-public-site/quickstart.md", summary: "공개 사이트를 처음부터 검증하는 절차." },
{ group: "specs", slug: "001-checklist-requirements", path: "specs/001-harness-public-site/checklists/requirements.md", summary: "기능정의서 품질 점검표." },
{ group: "specs", slug: "001-contract-evidence-manifest", path: "specs/001-harness-public-site/contracts/evidence-manifest.md", summary: "근거 매니페스트의 계약." },
{ group: "specs", slug: "001-contract-maturity-assessment", path: "specs/001-harness-public-site/contracts/maturity-assessment.md", summary: "성숙도 평가와 재판정의 계약." },
{ group: "specs", slug: "001-contract-public-routes", path: "specs/001-harness-public-site/contracts/public-routes.md", summary: "공개 경로가 지켜야 할 계약." },
{ group: "specs", slug: "002-spec", path: "specs/002-site-typography-scale/spec.md", summary: "타이포그래피 조정의 수용 조건." },
{ group: "specs", slug: "002-plan", path: "specs/002-site-typography-scale/plan.md", summary: "타이포그래피 조정의 기술 계획." },
{ group: "specs", slug: "002-tasks", path: "specs/002-site-typography-scale/tasks.md", summary: "타이포그래피 조정의 작업 목록." },
{ group: "specs", slug: "002-research", path: "specs/002-site-typography-scale/research.md", summary: "타이포그래피 조정 전 조사한 근거." },
{ group: "specs", slug: "002-data-model", path: "specs/002-site-typography-scale/data-model.md", summary: "타이포그래피 토큰의 구조." },
{ group: "specs", slug: "002-quickstart", path: "specs/002-site-typography-scale/quickstart.md", summary: "타이포그래피 변경을 확인하는 절차." },
{ group: "specs", slug: "002-checklist-requirements", path: "specs/002-site-typography-scale/checklists/requirements.md", summary: "타이포그래피 기능정의서 점검표." },
{ group: "specs", slug: "002-contract-typography-ui", path: "specs/002-site-typography-scale/contracts/typography-ui-contract.md", summary: "글자 크기와 반응형 동작의 계약." },
// 운영·검증
{ group: "operations", slug: "operations", path: "docs/operations.md", summary: "배포, 활성화, 스모크와 사고 대응 절차." },
{ group: "operations", slug: "evidence-maintenance", path: "docs/evidence-maintenance.md", summary: "공개 근거를 더하고 고치는 규칙." },
{ group: "operations", slug: "handoff", path: "docs/handoff.md", summary: "구현 인계와 커밋 게이트." },
{ group: "operations", slug: "implementation-baseline", path: "docs/implementation-baseline.md", summary: "구현 시작 시점의 기준선." },
{ group: "operations", slug: "manual-impact", path: "docs/manual-impact.md", summary: "기능별 매뉴얼 영향과 릴리스 정합성 기록." },
{ group: "operations", slug: "verification-report", path: "docs/verification-report.md", summary: "Phase 6~8 검증 결과." },
{ group: "operations", slug: "deployment-report", path: "docs/deployment-report.md", summary: "Phase 8 배포 검증 결과." },
{ group: "operations", slug: "report-foundation", path: "docs/task-reports/foundation.md", summary: "기반 작업의 RED에서 GREEN까지의 기록." },
{ group: "operations", slug: "report-us1-guide", path: "docs/task-reports/us1-guide.md", summary: "작업 가이드 화면의 구현 기록." },
{ group: "operations", slug: "report-us2-evidence", path: "docs/task-reports/us2-evidence.md", summary: "공개 근거 아틀라스의 구현 기록." },
{ group: "operations", slug: "report-us3-roadmap", path: "docs/task-reports/us3-roadmap.md", summary: "개선 로드맵 화면의 구현 기록." },
// 기준문서
{ group: "standards", slug: "constitution", path: ".specify/memory/constitution.md", summary: "이 프로젝트가 지키는 여섯 원칙과 품질 게이트." },
{ group: "standards", slug: "design-system", path: "design-system/5240lab-harness/MASTER.md", summary: "색, 간격, 컴포넌트의 기준." },
],
};
- [ ] Step 5: GREEN을 확인한다
Run: node --import tsx --test tests/unit/docs-schema.test.ts
Expected: PASS — 13 tests
- [ ] Step 6: 커밋
git add lib/docs-schema.ts content/docs-manifest.ts tests/unit/docs-schema.test.ts
git commit -m "feat(harness): 공개 문서 매니페스트와 스키마를 더한다"
Task 5: 빌드 스냅샷 생성기
Files:
- Create:
lib/docs-snapshot.ts - Create:
scripts/verify-docs.ts - Create:
content/generated/docs.public.json(생성물) - Test:
tests/unit/docs-snapshot.test.ts - Modify:
package.json(scripts)
content/generated는 무시 대상이 아니다. content/generated/evidence.public.json이 이미 추적되고 있으므로 생성된 스냅샷도 같이 커밋한다.
Interfaces:
-
Consumes:
parseDocsManifest,parseDocsPublicSnapshot,DocsManifest,DocsPublicSnapshot(Task 4),redactDocText,assertDocPublicSafe(Task 2),renderDocMarkdown,extractDocTitle(Task 3) -
Produces:
function buildDocsSnapshot(options: { manifest: DocsManifest; projectRoot: string }): Promise<DocsPublicSnapshot>function writeDocsSnapshot(options: { manifest: DocsManifest; projectRoot: string; outputPath: string }): Promise<DocsPublicSnapshot>- npm script
verify:docs
-
[ ] Step 1: 실패하는 테스트를 쓴다
Create tests/unit/docs-snapshot.test.ts:
import assert from "node:assert/strict";
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, it } from "node:test";
import { buildDocsSnapshot } from "../../lib/docs-snapshot";
async function fixture(files: Record<string, string>): Promise<string> {
const root = await mkdtemp(path.join(tmpdir(), "docs-snapshot-"));
for (const [name, body] of Object.entries(files)) {
const target = path.join(root, name);
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, body, "utf8");
}
return root;
}
describe("buildDocsSnapshot", () => {
it("제목을 문서에서 가져오고 본문을 HTML로 바꾼다", async () => {
const root = await fixture({ "docs/a.md": "# 운영 매뉴얼\n\n## 배포\n\n본문\n" });
const snapshot = await buildDocsSnapshot({
manifest: { entries: [{ group: "operations", slug: "a", path: "docs/a.md", summary: "요약" }] },
projectRoot: root,
});
assert.equal(snapshot.docs[0].title, "운영 매뉴얼");
assert.match(snapshot.docs[0].html, /<h3 id="배포">배포<\/h3>/u);
assert.deepEqual(snapshot.docs[0].toc[0], { id: "운영-매뉴얼", text: "운영 매뉴얼", level: 2 });
});
it("절대 경로를 가린 뒤 스냅샷에 담는다", async () => {
const root = await fixture({ "docs/a.md": "# 제목\n\n루트는 `<프로젝트 루트>` 이다.\n" });
const snapshot = await buildDocsSnapshot({
manifest: { entries: [{ group: "operations", slug: "a", path: "docs/a.md", summary: "요약" }] },
projectRoot: root,
});
assert.match(snapshot.docs[0].html, /<프로젝트 루트>|<프로젝트 루트>/u);
assert.doesNotMatch(snapshot.docs[0].html, /\/home\/ubuntu/u);
});
it("비밀값이 있으면 빌드를 실패시킨다", async () => {
const root = await fixture({ "docs/a.md": "# 제목\n\nDATABASE_URL=postgres://u:p@h/db\n" });
await assert.rejects(
buildDocsSnapshot({
manifest: { entries: [{ group: "operations", slug: "a", path: "docs/a.md", summary: "요약" }] },
projectRoot: root,
}),
/공개할 수 없는 값/u,
);
});
it("매니페스트가 가리키는 파일이 없으면 실패시킨다", async () => {
const root = await fixture({ "docs/a.md": "# 제목\n\n본문\n" });
await assert.rejects(
buildDocsSnapshot({
manifest: { entries: [{ group: "operations", slug: "b", path: "docs/missing.md", summary: "요약" }] },
projectRoot: root,
}),
/찾을 수 없습니다/u,
);
});
it("최상위 제목이 없는 문서를 거부한다", async () => {
const root = await fixture({ "docs/a.md": "본문만 있다\n" });
await assert.rejects(
buildDocsSnapshot({
manifest: { entries: [{ group: "operations", slug: "a", path: "docs/a.md", summary: "요약" }] },
projectRoot: root,
}),
/최상위 제목/u,
);
});
it("매니페스트 순서를 그대로 지킨다", async () => {
const root = await fixture({
"docs/a.md": "# 가\n\n본문\n",
"docs/b.md": "# 나\n\n본문\n",
});
const snapshot = await buildDocsSnapshot({
manifest: {
entries: [
{ group: "operations", slug: "b", path: "docs/b.md", summary: "요약" },
{ group: "operations", slug: "a", path: "docs/a.md", summary: "요약" },
],
},
projectRoot: root,
});
assert.deepEqual(
snapshot.docs.map((doc) => doc.slug),
["b", "a"],
);
});
});
- [ ] Step 2: RED를 확인한다
Run: node --import tsx --test tests/unit/docs-snapshot.test.ts
Expected: FAIL — Cannot find module '../../lib/docs-snapshot'
- [ ] Step 3: 생성기를 쓴다
Create lib/docs-snapshot.ts:
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { assertDocPublicSafe, redactDocText } from "./docs-safety";
import { extractDocTitle, renderDocMarkdown } from "./docs-markdown";
import {
parseDocsPublicSnapshot,
type DocPublic,
type DocsManifest,
type DocsPublicSnapshot,
} from "./docs-schema";
interface BuildOptions {
manifest: DocsManifest;
projectRoot: string;
}
export async function buildDocsSnapshot({ manifest, projectRoot }: BuildOptions): Promise<DocsPublicSnapshot> {
const docs: DocPublic[] = [];
for (const entry of manifest.entries) {
const absolutePath = path.join(projectRoot, entry.path);
const docId = `${entry.group}/${entry.slug}`;
let raw: string;
try {
raw = await readFile(absolutePath, "utf8");
} catch {
throw new Error(`${docId}: 원본 문서 ${entry.path}을(를) 찾을 수 없습니다.`);
}
const { text } = redactDocText(raw);
assertDocPublicSafe(text, docId);
const title = extractDocTitle(text);
const { html, toc } = renderDocMarkdown(text);
docs.push({
group: entry.group,
slug: entry.slug,
title,
summary: entry.summary,
sourcePath: entry.path,
html,
toc,
});
}
return parseDocsPublicSnapshot({ generatedFrom: "content/docs-manifest.ts", docs });
}
export async function writeDocsSnapshot(
options: BuildOptions & { outputPath: string },
): Promise<DocsPublicSnapshot> {
const snapshot = await buildDocsSnapshot(options);
await writeFile(options.outputPath, `${JSON.stringify(snapshot, null, 2)}\n`, "utf8");
return snapshot;
}
- [ ] Step 4: GREEN을 확인한다
Run: node --import tsx --test tests/unit/docs-snapshot.test.ts
Expected: PASS — 6 tests
- [ ] Step 5: 빌드 스크립트를 쓴다
Create scripts/verify-docs.ts:
import path from "node:path";
import { fileURLToPath } from "node:url";
import { docsManifest } from "../content/docs-manifest";
import { parseDocsManifest } from "../lib/docs-schema";
import { writeDocsSnapshot } from "../lib/docs-snapshot";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const outputPath = path.join(projectRoot, "content", "generated", "docs.public.json");
async function main(): Promise<void> {
const manifest = parseDocsManifest(docsManifest);
const snapshot = await writeDocsSnapshot({ manifest, projectRoot, outputPath });
console.log(`Verified ${snapshot.docs.length} public documents.`);
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
- [ ] Step 6: npm script를 빌드 앞단에 연결한다
package.json의 scripts에서 verify:docs를 더하고 build에 끼운다.
"build": "npm run verify:evidence && npm run verify:content && npm run verify:docs && npm run verify:manual && next build && npm run verify:bundle",
"verify:docs": "node --import tsx scripts/verify-docs.ts",
- [ ] Step 7: 실제 34편으로 돌려 본다
Run: npm run verify:docs
Expected: PASS — Verified 34 public documents.
실패하면 메시지가 가리키는 문서를 고친다. 비밀값 실패는 원본을 고치고, 가림 대상은 정규식을 넓히지 말고 왜 안 걸렸는지 먼저 확인한다.
- [ ] Step 8: 커밋
git add lib/docs-snapshot.ts scripts/verify-docs.ts tests/unit/docs-snapshot.test.ts package.json content/generated/docs.public.json
git commit -m "feat(harness): 공개 문서 스냅샷을 빌드 때 생성한다"
Task 6: /docs 목록 화면
Files:
- Create:
app/docs/page.tsx - Create:
app/docs/docs.module.css - Create:
lib/docs.ts - Modify:
content/site.ts(siteRoutes에 문서 추가) - Modify:
scripts/verify-content.ts:27(REQUIRED_ROUTES) - Test:
tests/integration/docs-page.test.tsx
Interfaces:
-
Consumes:
content/generated/docs.public.json,DOC_GROUPS,parseDocsPublicSnapshot -
Produces:
function getDocGroups(): { id: DocGroupId; label: string; description: string; docs: DocPublic[] }[]function getDoc(group: string, slug: string): DocPublic | undefinedfunction getAllDocs(): DocPublic[]siteRoutes에{ id: "docs", href: "/docs", label: "문서", shortLabel: "문서" }
-
[ ] Step 1: 실패하는 테스트를 쓴다
Create tests/integration/docs-page.test.tsx:
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import { before, describe, it } from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
const require = createRequire(import.meta.url);
require.extensions[".css"] = () => undefined;
let DocsPage: typeof import("../../app/docs/page").default;
let getAllDocs: typeof import("../../lib/docs").getAllDocs;
let getDocGroups: typeof import("../../lib/docs").getDocGroups;
let siteRoutes: typeof import("../../content/site").siteRoutes;
before(async () => {
({ default: DocsPage } = await import("../../app/docs/page"));
({ getAllDocs, getDocGroups } = await import("../../lib/docs"));
({ siteRoutes } = await import("../../content/site"));
});
describe("문서 목록", () => {
it("헤더 탐색에 문서를 다섯 번째로 넣는다", () => {
assert.deepEqual(
siteRoutes.map((route) => route.href),
["/", "/guide", "/5240lab", "/roadmap", "/docs"],
);
});
it("네 묶음을 선언 순서대로 돌려준다", () => {
assert.deepEqual(
getDocGroups().map((group) => group.id),
["design", "specs", "operations", "standards"],
);
});
it("모든 묶음에 문서가 하나 이상 있다", () => {
for (const group of getDocGroups()) {
assert.ok(group.docs.length > 0, `${group.id} 묶음이 비어 있습니다.`);
}
});
it("문서 34편을 담는다", () => {
assert.equal(getAllDocs().length, 34);
});
it("목록에 제목, 요약, 원본 경로와 상세 링크를 담는다", () => {
const html = renderToStaticMarkup(<DocsPage />);
assert.match(html, /공개 문서 조회 설계/u);
assert.match(html, /href="\/docs\/design\/public-docs-browser"/u);
assert.match(html, /docs\/superpowers\/specs\/2026-08-17-public-docs-browser-design\.md/u);
});
it("제목이 하나뿐이다", () => {
const html = renderToStaticMarkup(<DocsPage />);
assert.equal(html.match(/<h1/gu)?.length, 1);
});
});
- [ ] Step 2: RED를 확인한다
Run: npm run test:integration
Expected: FAIL — Cannot find module '../../app/docs/page'
- [ ] Step 3: 런타임 접근자를 쓴다
Create lib/docs.ts:
import snapshotJson from "../content/generated/docs.public.json";
import { DOC_GROUPS, parseDocsPublicSnapshot, type DocGroupId, type DocPublic } from "./docs-schema";
const snapshot = parseDocsPublicSnapshot(snapshotJson);
export interface DocGroupView {
id: DocGroupId;
label: string;
description: string;
docs: DocPublic[];
}
export function getAllDocs(): DocPublic[] {
return snapshot.docs;
}
export function getDocGroups(): DocGroupView[] {
return DOC_GROUPS.map((group) => ({
id: group.id,
label: group.label,
description: group.description,
docs: snapshot.docs.filter((doc) => doc.group === group.id),
}));
}
export function getDoc(group: string, slug: string): DocPublic | undefined {
return snapshot.docs.find((doc) => doc.group === group && doc.slug === slug);
}
- [ ] Step 4: 경로를 등록한다
content/site.ts의 siteRoutes 마지막에 더한다.
{ id: "docs", href: "/docs", label: "문서", shortLabel: "문서" },
scripts/verify-content.ts의 REQUIRED_ROUTES를 바꾼다.
const REQUIRED_ROUTES = ["/", "/guide", "/5240lab", "/roadmap", "/docs"] as const;
- [ ] Step 5: 목록 화면을 쓴다
Create app/docs/page.tsx:
import type { Metadata } from "next";
import Link from "next/link";
import { getDocGroups } from "../../lib/docs";
import "./docs.module.css";
export const metadata: Metadata = {
title: "문서 — 5240lab Harness",
description: "하네스 작업이 만들어낸 설계, 명세, 운영 문서를 공개합니다.",
};
export default function DocsPage() {
const groups = getDocGroups();
return (
<div className="docs-page">
<section className="docs-hero" aria-labelledby="docs-title">
<p className="technical-label">Documents / Public read-only</p>
<h1 id="docs-title">하네스가 남긴 문서</h1>
<p className="docs-hero__lead">
설계에서 명세, 작업목록, 검증 기록까지 실제로 쓴 문서를 그대로 공개합니다.
매니페스트에 선언한 문서만 나옵니다.
</p>
</section>
{groups.map((group) => (
<section className="docs-group" key={group.id} aria-labelledby={`group-${group.id}`}>
<div className="docs-group__heading">
<p className="technical-label">{group.docs.length}편</p>
<h2 id={`group-${group.id}`}>{group.label}</h2>
<p>{group.description}</p>
</div>
<ul className="docs-list">
{group.docs.map((doc) => (
<li key={`${doc.group}/${doc.slug}`}>
<Link className="docs-card" href={`/docs/${doc.group}/${doc.slug}`}>
<span className="docs-card__title">{doc.title}</span>
<span className="docs-card__summary">{doc.summary}</span>
<code className="docs-card__path">{doc.sourcePath}</code>
</Link>
</li>
))}
</ul>
</section>
))}
</div>
);
}
- [ ] Step 6: 스타일을 쓴다
Create app/docs/docs.module.css:
:global(.docs-page) {
display: flex;
min-width: 0;
flex-direction: column;
gap: var(--space-16);
}
:global(.docs-hero) {
padding-block: var(--space-8) var(--space-12);
border-bottom: 1px solid var(--border);
}
/* ch는 라틴 "0" 폭 기준이라 한글 제목 줄바꿈을 못 지킨다. 글자 크기에 비례하는 em으로 잡는다. */
:global(.docs-hero h1) {
max-width: 11em;
margin-block: var(--space-4) var(--space-6);
}
:global(.docs-hero__lead) {
max-width: 43rem;
color: var(--foreground-muted);
font-size: clamp(1.125rem, 1rem + 0.5vw, 1.5rem);
}
:global(.docs-group__heading h2) {
margin-block: var(--space-2) var(--space-3);
}
:global(.docs-group__heading p:last-child) {
color: var(--foreground-muted);
}
:global(.docs-list) {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
gap: var(--space-4);
margin: var(--space-6) 0 0;
padding: 0;
list-style: none;
}
:global(.docs-card) {
display: grid;
gap: var(--space-2);
min-width: 0;
height: 100%;
padding: var(--space-6);
border: 1px solid var(--border);
border-radius: var(--radius-card);
background: var(--surface);
color: inherit;
text-decoration: none;
}
:global(.docs-card:hover),
:global(.docs-card:focus-visible) {
border-color: var(--primary);
}
:global(.docs-card__title) {
font-weight: 800;
}
:global(.docs-card__summary) {
color: var(--foreground-muted);
font-size: 0.9rem;
}
:global(.docs-card__path) {
color: var(--foreground-muted);
font-size: 0.75rem;
overflow-wrap: anywhere;
}
- [ ] Step 7: GREEN을 확인한다
Run: npm run test:integration
Expected: PASS — 문서 목록 6 tests 포함 전체 통과
- [ ] Step 8: 커밋
git add app/docs lib/docs.ts content/site.ts scripts/verify-content.ts tests/integration/docs-page.test.tsx
git commit -m "feat(harness): 문서 목록 화면과 다섯 번째 경로를 더한다"
Task 7: 문서 상세 화면
Files:
- Create:
app/docs/[group]/[slug]/page.tsx - Modify:
app/docs/docs.module.css(본문 스타일 추가) - Modify:
app/sitemap.ts - Test:
tests/integration/docs-detail.test.tsx
Interfaces:
-
Consumes:
getDoc,getAllDocs(Task 6),DOC_GROUPS(Task 4) -
Produces:
/docs/<group>/<slug>정적 경로 34개, sitemap 항목 34개 추가 -
[ ] Step 1: 실패하는 테스트를 쓴다
Create tests/integration/docs-detail.test.tsx:
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import { before, describe, it } from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
const require = createRequire(import.meta.url);
require.extensions[".css"] = () => undefined;
let DocPage: typeof import("../../app/docs/[group]/[slug]/page").default;
let generateStaticParams: typeof import("../../app/docs/[group]/[slug]/page").generateStaticParams;
let sitemap: typeof import("../../app/sitemap").default;
before(async () => {
({ default: DocPage, generateStaticParams } = await import("../../app/docs/[group]/[slug]/page"));
({ default: sitemap } = await import("../../app/sitemap"));
});
describe("문서 상세", () => {
it("문서 34편을 모두 정적 경로로 만든다", () => {
assert.equal(generateStaticParams().length, 34);
});
it("본문, 원본 경로, 목록 링크를 담는다", async () => {
const html = renderToStaticMarkup(
await DocPage({ params: Promise.resolve({ group: "design", slug: "public-docs-browser" }) }),
);
assert.match(html, /공개 문서 조회 설계/u);
assert.match(html, /href="\/docs"/u);
assert.match(html, /docs\/superpowers\/specs\/2026-08-17-public-docs-browser-design\.md/u);
});
it("문서 제목을 h2부터 쓰고 페이지 h1은 하나만 둔다", async () => {
const html = renderToStaticMarkup(
await DocPage({ params: Promise.resolve({ group: "design", slug: "public-docs-browser" }) }),
);
assert.equal(html.match(/<h1/gu)?.length, 1);
assert.match(html, /<h2 id="/u);
});
it("목차를 본문 앵커와 같은 ID로 만든다", async () => {
const html = renderToStaticMarkup(
await DocPage({ params: Promise.resolve({ group: "design", slug: "public-docs-browser" }) }),
);
assert.match(html, /href="#배경"/u);
assert.match(html, /id="배경"/u);
});
it("sitemap에 문서 경로를 담는다", () => {
const urls = sitemap().map((entry) => entry.url);
assert.ok(urls.includes("https://harness.insapien.co.kr/docs"));
assert.ok(urls.includes("https://harness.insapien.co.kr/docs/design/public-docs-browser"));
});
});
- [ ] Step 2: RED를 확인한다
Run: npm run test:integration
Expected: FAIL — Cannot find module '../../app/docs/[group]/[slug]/page'
- [ ] Step 3: 상세 화면을 쓴다
Create app/docs/[group]/[slug]/page.tsx:
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getAllDocs, getDoc } from "../../../../lib/docs";
import "../../docs.module.css";
interface PageProps {
params: Promise<{ group: string; slug: string }>;
}
export function generateStaticParams() {
return getAllDocs().map((doc) => ({ group: doc.group, slug: doc.slug }));
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { group, slug } = await params;
const doc = getDoc(group, slug);
if (!doc) return {};
return { title: `${doc.title} — 5240lab Harness`, description: doc.summary };
}
export default async function DocPage({ params }: PageProps) {
const { group, slug } = await params;
const doc = getDoc(group, slug);
if (!doc) notFound();
return (
<div className="doc-page">
<section className="doc-header">
<p className="technical-label">
<Link href="/docs">문서</Link> / {doc.sourcePath}
</p>
<h1>{doc.title}</h1>
<p className="doc-header__summary">{doc.summary}</p>
</section>
<div className="doc-body">
{doc.toc.length > 0 && (
<nav className="doc-toc" aria-label="문서 목차">
<p className="technical-label">목차</p>
<ul>
{doc.toc.map((heading) => (
<li key={heading.id} data-level={heading.level}>
<a href={`#${heading.id}`}>{heading.text}</a>
</li>
))}
</ul>
</nav>
)}
<article className="doc-article" dangerouslySetInnerHTML={{ __html: doc.html }} />
</div>
</div>
);
}
dangerouslySetInnerHTML을 쓰는 이유는 본문이 빌드 때 markdown-it이 html: false로 만든 HTML이고, 원본 문서의 원시 HTML은 이스케이프되어 실행 가능한 태그가 남지 않기 때문이다. 이 전제가 깨지면 Task 3의 원시 HTML 테스트가 먼저 실패한다.
- [ ] Step 4: 본문 스타일을 더한다
app/docs/docs.module.css 끝에 추가한다.
:global(.doc-page) {
display: flex;
min-width: 0;
flex-direction: column;
gap: var(--space-12);
}
:global(.doc-header) {
padding-block: var(--space-8) var(--space-8);
border-bottom: 1px solid var(--border);
}
/* ch는 라틴 "0" 폭 기준이라 한글 제목 줄바꿈을 못 지킨다. 글자 크기에 비례하는 em으로 잡는다. */
:global(.doc-header h1) {
max-width: 16em;
margin-block: var(--space-4) var(--space-4);
}
:global(.doc-header__summary) {
max-width: 43rem;
color: var(--foreground-muted);
}
:global(.doc-body) {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--space-8);
}
@media (min-width: 64rem) {
:global(.doc-body) {
grid-template-columns: 16rem minmax(0, 1fr);
align-items: start;
}
:global(.doc-toc) {
position: sticky;
top: var(--space-8);
}
}
:global(.doc-toc ul) {
display: grid;
gap: var(--space-1);
margin: var(--space-3) 0 0;
padding: 0;
list-style: none;
}
:global(.doc-toc a) {
color: var(--foreground-muted);
font-size: 0.85rem;
text-decoration: none;
}
:global(.doc-toc a:hover),
:global(.doc-toc a:focus-visible) {
color: var(--primary);
}
:global(.doc-toc li[data-level="3"]) { padding-left: var(--space-3); }
:global(.doc-toc li[data-level="4"]) { padding-left: var(--space-6); }
:global(.doc-article) {
min-width: 0;
max-width: var(--reading-max);
}
:global(.doc-article table) {
display: block;
width: max-content;
max-width: 100%;
overflow-x: auto;
border-collapse: collapse;
}
:global(.doc-article th),
:global(.doc-article td) {
padding: var(--space-2) var(--space-4);
border: 1px solid var(--border);
text-align: left;
}
:global(.doc-article pre) {
max-width: 100%;
padding: var(--space-4);
border-radius: var(--space-2);
background: #0b1020;
color: #eef2fa;
font-size: 0.8rem;
line-height: 1.7;
overflow-x: auto;
}
:global(.doc-article code) {
overflow-wrap: anywhere;
}
- [ ] Step 5: sitemap에 문서 경로를 더한다
app/sitemap.ts를 바꾼다.
import type { MetadataRoute } from "next";
import releaseJson from "../content/release.json";
import { site, siteRoutes } from "../content/site";
import { getAllDocs } from "../lib/docs";
export default function sitemap(): MetadataRoute.Sitemap {
const lastModified = new Date(releaseJson.createdOn);
const routes = siteRoutes.map(({ href }) => ({
url: new URL(href, site.url).toString(),
lastModified,
changeFrequency: "monthly" as const,
priority: href === "/" ? 1 : 0.8,
}));
const docs = getAllDocs().map((doc) => ({
url: new URL(`/docs/${doc.group}/${doc.slug}`, site.url).toString(),
lastModified,
changeFrequency: "monthly" as const,
priority: 0.5,
}));
return [...routes, ...docs];
}
- [ ] Step 6: GREEN을 확인한다
Run: npm run test:integration
Expected: PASS — 문서 상세 5 tests 포함 전체 통과
- [ ] Step 7: 커밋
git add "app/docs/[group]" app/docs/docs.module.css app/sitemap.ts tests/integration/docs-detail.test.tsx
git commit -m "feat(harness): 문서 상세 화면과 sitemap 문서 경로를 더한다"
Task 8: 브라우저 검증과 배포 스모크
Files:
- Create:
tests/browser/docs.spec.ts - Modify:
tests/browser/responsive.spec.ts:3(publicRoutes) - Modify:
tests/browser/heading-lines.spec.ts:12-17(expectedLines) - Modify:
tests/browser/heading-scale.spec.ts:7(routes) - Modify:
scripts/smoke.mjs:22-24(pageChecks) - Modify:
docs/operations.md(외부 스모크 경로 목록)
Interfaces:
-
Consumes: Task 6·7이 만든
/docs와/docs/<group>/<slug> -
Produces: 없음 (검증만)
-
[ ] Step 1: 실패하는 브라우저 테스트를 쓴다
Create tests/browser/docs.spec.ts:
import { expect, test } from "@playwright/test";
test("문서 목록이 네 묶음과 문서 링크를 보여준다", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/docs");
await expect(page.getByRole("heading", { level: 1 })).toHaveText("하네스가 남긴 문서");
for (const label of ["설계", "명세·계획", "운영·검증", "기준문서"]) {
await expect(page.getByRole("heading", { level: 2, name: label })).toBeVisible();
}
await expect(page.getByRole("link", { name: /공개 문서 조회 설계/u })).toBeVisible();
});
test("목록에서 문서를 열면 본문과 목차가 보인다", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/docs");
await page.getByRole("link", { name: /공개 문서 조회 설계/u }).click();
await expect(page).toHaveURL(/\/docs\/design\/public-docs-browser$/u);
await expect(page.getByRole("navigation", { name: "문서 목차" })).toBeVisible();
await expect(page.getByRole("heading", { level: 2, name: "배경" })).toBeVisible();
});
test("문서 본문에 실행 가능한 스크립트가 없다", async ({ page }) => {
await page.goto("/docs/design/public-docs-browser");
const scripts = await page.locator("article.doc-article script").count();
expect(scripts).toBe(0);
});
for (const width of [375, 768, 1024, 1440, 1920]) {
test(`문서 본문이 ${width}px에서 가로로 넘치지 않는다`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
await page.goto("/docs/specs/001-tasks");
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
);
expect(overflow).toBeLessThanOrEqual(1);
});
}
- [ ] Step 2: 기존 브라우저 테스트에
/docs를 더한다
tests/browser/responsive.spec.ts:
const publicRoutes = ["/", "/guide", "/5240lab", "/roadmap", "/docs"] as const;
tests/browser/heading-lines.spec.ts의 expectedLines 끝에 더한다.
{ route: "/docs", lines: 1 },
tests/browser/heading-scale.spec.ts:
const routes = ["/", "/guide", "/5240lab", "/roadmap", "/docs"] as const;
- [ ] Step 3: RED를 확인한다
Run: npx playwright test docs heading-lines heading-scale responsive --reporter=line
Expected: FAIL — /docs가 404이거나 목록 제목이 없다는 오류. Task 6·7이 이미 끝났다면 이 단계는 바로 PASS일 수 있다. 그 경우 통과를 기록하고 다음으로 간다.
- [ ] Step 4: 배포 스모크에
/docs를 더한다
scripts/smoke.mjs의 pageChecks를 바꾼다.
["/guide", "작업 가이드"],
["/5240lab", "5240lab"],
["/roadmap", "로드맵"],
["/docs", "하네스가 남긴 문서"],
["/docs/design/public-docs-browser", "공개 문서 조회 설계"],
- [ ] Step 5: 운영 매뉴얼의 스모크 경로 목록을 고친다
docs/operations.md에서 아래 문장을 찾는다.
외부 smoke는 `/`, `/guide`, `/5240lab`, `/roadmap`, `/api/health`,
`/robots.txt`, `/sitemap.xml`, 정적 자산과 임의 404의 status·복구 링크를 검사한다.
다음으로 바꾼다.
외부 smoke는 `/`, `/guide`, `/5240lab`, `/roadmap`, `/docs`, 문서 상세 한 편,
`/api/health`, `/robots.txt`, `/sitemap.xml`, 정적 자산과 임의 404의 status·복구
링크를 검사한다.
- [ ] Step 6: 전체 검증을 돌린다
Run: npm run verify:app
Expected: PASS
Run: npx playwright test --reporter=line
Expected: PASS — 기존 77건에 문서 8건이 더해져 전부 통과
- [ ] Step 7: 커밋
git add tests/browser scripts/smoke.mjs docs/operations.md
git commit -m "feat(harness): 문서 경로를 브라우저 검증과 배포 스모크에 넣는다"
완료 조건
npm run verify:app과npx playwright test가 모두 통과한다.npm run verify:docs가Verified 34 public documents.를 낸다.dependencies가next,react,react-dom셋 그대로다./docs와 문서 34편의 상세 경로가 JavaScript 없이 읽힌다.- 배포 후 외부 스모크가
/docs와 문서 상세 한 편을 200으로 확인한다.