pr-docent 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -0
- package/bin/pr-docent.mjs +3 -2
- package/host/native.mjs +0 -5
- package/host/pr-docent-host.mjs +22 -13
- package/package.json +2 -1
- package/scripts/check-tour.mjs +18 -6
- package/scripts/tour-prompt.md +25 -11
- package/src/diff.js +18 -2
- package/src/guidelines.js +102 -0
- package/src/tour.js +6 -0
package/README.md
CHANGED
|
@@ -15,6 +15,7 @@ GitHub 풀 리퀘스트의 **Files changed** 화면 위에서 동작하는 리
|
|
|
15
15
|
- 접힌 대용량 diff("Load Diff")는 자동으로 펼칩니다.
|
|
16
16
|
- 패널이 열려 있는 동안 diff가 패널에 가리지 않도록 페이지 폭을 줄입니다.
|
|
17
17
|
- PR마다 마지막 위치를 기억합니다.
|
|
18
|
+
- 화면 언어는 영어(기본)와 한국어를 지원합니다. 툴바의 PR Docent 아이콘을 눌러 자동(브라우저 언어), English, 한국어 중에서 고릅니다. 로컬에서 만드는 투어도 고른 언어로 작성되고, 음성은 투어 문장의 언어에 맞춰 읽습니다.
|
|
18
19
|
|
|
19
20
|
## 단축키
|
|
20
21
|
|
|
@@ -90,6 +91,27 @@ PR이 열리거나 커밋이 추가될 때마다 Claude Code가 diff를 읽고
|
|
|
90
91
|
- 검사를 통과한 투어를 이 PR에 저장하고 바로 재생합니다. 생성에는 보통 2~3분이 걸립니다.
|
|
91
92
|
- 제거: `npx pr-docent uninstall`
|
|
92
93
|
|
|
94
|
+
## 투어 생성 규칙 바꾸기 (`.github/pr-docent.md`)
|
|
95
|
+
|
|
96
|
+
Actions 생성과 로컬 생성 모두 기본적으로 짧은 투어를 만듭니다. 단계 수는 바뀐 줄 25줄당 1개에 3을 더한 값까지(최대 20)이고, 단계마다 문장 3개, 문장마다 80자까지입니다. 주석만 바뀐 곳, 임포트, 문서처럼 사소한 변경은 단계 대신 "건너뛴 파일"에 넣습니다. 검사기가 이 기준을 확인하고, 넘으면 Claude가 다시 줄입니다.
|
|
97
|
+
|
|
98
|
+
저장소에 `.github/pr-docent.md`를 두면 그 저장소의 규칙을 더할 수 있습니다. 앞부분 `---` 블록은 숫자 기준을 바꾸고, 본문은 생성 지시문 뒤에 붙어 기본 규칙보다 우선합니다.
|
|
99
|
+
|
|
100
|
+
```markdown
|
|
101
|
+
---
|
|
102
|
+
maxSteps: 6 # 단계 수 상한 (기본: 3 + 바뀐 줄 25줄당 1, 최대 20)
|
|
103
|
+
sentencesPerStep: 2 # 단계당 문장 수 (기본 3)
|
|
104
|
+
sentenceChars: 100 # 문장 길이 (기본 80자)
|
|
105
|
+
pointChars: 120 # 리뷰 포인트 길이 (기본 120자)
|
|
106
|
+
summaryChars: 200 # PR 요약 길이 (기본 200자)
|
|
107
|
+
chapterSummaryChars: 60
|
|
108
|
+
---
|
|
109
|
+
- 도메인 용어는 원장, 안분, 파생 라인으로 씁니다.
|
|
110
|
+
- Flyway 마이그레이션 파일은 항상 집중 단계로 다룹니다.
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
PR의 head 커밋에 있는 파일을 읽으므로, 규칙을 바꾸는 PR에서는 바뀐 규칙으로 생성됩니다.
|
|
114
|
+
|
|
93
115
|
## 투어 불러오기
|
|
94
116
|
|
|
95
117
|
- PR 코멘트에 자동 생성된 투어가 있으면 그 투어를 불러옵니다. 직접 불러온 투어가 있으면 그쪽이 우선합니다.
|
|
@@ -142,6 +164,7 @@ GitHub의 2026년 Files changed 화면 기준입니다. 화면 구조가 바뀌
|
|
|
142
164
|
|
|
143
165
|
- 빌드 단계가 없습니다. 콘텐츠 스크립트(`src/loader.js`)가 ES 모듈(`src/main.js`)을 불러옵니다.
|
|
144
166
|
- 테스트: `npm test` (Node 내장 테스트 러너). 투어 검증, diff 대조, 재생 상태, 단축키, PR 주소 해석, GitHub 파일 ID 해시, 줄 선택자를 다룹니다.
|
|
167
|
+
- npm 게시: `package.json`의 버전을 올려 머지한 뒤 `git tag npm-v<버전> && git push origin npm-v<버전>`. GitHub Actions(`publish.yml`)가 trusted publishing으로 게시하므로 npm 토큰이나 2FA 입력이 필요 없습니다.
|
|
145
168
|
- 투어 검사: `node scripts/check-tour.mjs <tour.json> <pr.diff> [owner/repo#number]` (`gh pr diff <number> > pr.diff`)
|
|
146
169
|
|
|
147
170
|
## 라이선스
|
package/bin/pr-docent.mjs
CHANGED
|
@@ -17,7 +17,7 @@ import { EXTENSION_IDS, HOST_NAME } from "../host/native.mjs";
|
|
|
17
17
|
|
|
18
18
|
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
19
19
|
// What the host needs at run time; everything else in the package is for the extension or tests.
|
|
20
|
-
const RUNTIME_FILES = ["host/native.mjs", "host/pr-docent-host.mjs", "src/diff.js", "src/tour.js", "scripts/check-tour.mjs", "scripts/tour-prompt.md"];
|
|
20
|
+
const RUNTIME_FILES = ["host/native.mjs", "host/pr-docent-host.mjs", "src/diff.js", "src/guidelines.js", "src/tour.js", "scripts/check-tour.mjs", "scripts/tour-prompt.md"];
|
|
21
21
|
const HOSTS_DIRS = {
|
|
22
22
|
darwin: join(homedir(), "Library", "Application Support", "Google", "Chrome", "NativeMessagingHosts"),
|
|
23
23
|
linux: join(homedir(), ".config", "google-chrome", "NativeMessagingHosts"),
|
|
@@ -75,7 +75,8 @@ function install(args) {
|
|
|
75
75
|
Generator: ${app}
|
|
76
76
|
Allowed extensions: ${extensionIds.join(", ")}
|
|
77
77
|
Claude Code: ${claude}
|
|
78
|
-
Reload the extension in chrome://extensions, then
|
|
78
|
+
Reload the extension in chrome://extensions, then click "Create a tour with local Claude Code"
|
|
79
|
+
in the panel ("로컬 Claude Code로 투어 생성" in Korean).`);
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
function uninstall() {
|
package/host/native.mjs
CHANGED
|
@@ -33,8 +33,3 @@ export function extensionIdFromKey(key) {
|
|
|
33
33
|
const hex = createHash("sha256").update(Buffer.from(key, "base64")).digest("hex").slice(0, 32);
|
|
34
34
|
return [...hex].map((c) => String.fromCharCode(97 + parseInt(c, 16))).join("");
|
|
35
35
|
}
|
|
36
|
-
|
|
37
|
-
/** Replaces {{NAME}} placeholders; unknown ones are left as they are. */
|
|
38
|
-
export function fillTemplate(text, vars) {
|
|
39
|
-
return text.replace(/{{(\w+)}}/g, (match, name) => (name in vars ? String(vars[name]) : match));
|
|
40
|
-
}
|
package/host/pr-docent-host.mjs
CHANGED
|
@@ -8,9 +8,10 @@ import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSy
|
|
|
8
8
|
import { homedir, tmpdir } from "node:os";
|
|
9
9
|
import { dirname, join, resolve, sep } from "node:path";
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
|
-
import { checkTour, visibleLines } from "../src/diff.js";
|
|
11
|
+
import { changedLineCount, checkTour, visibleLines } from "../src/diff.js";
|
|
12
|
+
import { buildPrompt, checkBrevity, parseGuidelines } from "../src/guidelines.js";
|
|
12
13
|
import { parseTour } from "../src/tour.js";
|
|
13
|
-
import { createDecoder, encodeMessage
|
|
14
|
+
import { createDecoder, encodeMessage } from "./native.mjs";
|
|
14
15
|
|
|
15
16
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
16
17
|
const CONFIG = readConfig();
|
|
@@ -35,7 +36,7 @@ process.stdin.on("end", () => {
|
|
|
35
36
|
process.exit(0);
|
|
36
37
|
});
|
|
37
38
|
|
|
38
|
-
async function generate({ pr, title, body, headSha, diff, files, model, language }) {
|
|
39
|
+
async function generate({ pr, title, body, headSha, diff, files, guidelines = "", model, language }) {
|
|
39
40
|
// Real path: on macOS the temp folder is a symlink, and the write rule must match exactly.
|
|
40
41
|
const work = realpathSync(mkdtempSync(join(tmpdir(), "pr-docent-")));
|
|
41
42
|
try {
|
|
@@ -51,13 +52,19 @@ async function generate({ pr, title, body, headSha, diff, files, model, language
|
|
|
51
52
|
}
|
|
52
53
|
writeFileSync(join(out, "pr.diff"), diff);
|
|
53
54
|
writeFileSync(join(out, "pr.json"), JSON.stringify({ title, body, headRefOid: headSha }, null, 2));
|
|
55
|
+
if (guidelines) writeFileSync(join(out, "guidelines.md"), guidelines);
|
|
56
|
+
const { limits, errors: guidelineErrors } = parseGuidelines(guidelines);
|
|
57
|
+
if (guidelineErrors.length) throw new Error(`.github/pr-docent.md: ${guidelineErrors.join(" / ")}`);
|
|
58
|
+
const changedLines = changedLineCount(diff);
|
|
54
59
|
|
|
55
60
|
const prRef = `${pr.owner}/${pr.repo}#${pr.number}`;
|
|
56
|
-
const prompt =
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
+
const prompt = buildPrompt(readFileSync(join(ROOT, "scripts", "tour-prompt.md"), "utf8"), {
|
|
62
|
+
pr: prRef,
|
|
63
|
+
out,
|
|
64
|
+
tools: ROOT,
|
|
65
|
+
language: language || "한국어",
|
|
66
|
+
changedLines,
|
|
67
|
+
guidelinesText: guidelines,
|
|
61
68
|
});
|
|
62
69
|
await runClaude(prompt, repo, [
|
|
63
70
|
"--model", model || "claude-opus-5-5",
|
|
@@ -69,11 +76,13 @@ async function generate({ pr, title, body, headSha, diff, files, model, language
|
|
|
69
76
|
try {
|
|
70
77
|
raw = JSON.parse(readFileSync(join(out, "tour.json"), "utf8"));
|
|
71
78
|
} catch {
|
|
72
|
-
throw new Error("Claude
|
|
79
|
+
throw new Error("Claude did not write a tour file.");
|
|
73
80
|
}
|
|
74
81
|
const { tour, errors } = parseTour(raw);
|
|
75
|
-
const problems = errors.length
|
|
76
|
-
|
|
82
|
+
const problems = errors.length
|
|
83
|
+
? errors
|
|
84
|
+
: [...checkTour(tour, visibleLines(diff)), ...checkBrevity(tour, { changedLines, limits })];
|
|
85
|
+
if (problems.length) throw new Error(`The tour did not pass the checks: ${problems.slice(0, 3).join(" / ")}`);
|
|
77
86
|
return raw;
|
|
78
87
|
} finally {
|
|
79
88
|
rmSync(work, { recursive: true, force: true });
|
|
@@ -99,12 +108,12 @@ function runClaude(prompt, cwd, args) {
|
|
|
99
108
|
child.stderr.on("data", keep);
|
|
100
109
|
const progress = setInterval(() => send({ type: "progress", seconds: Math.round((Date.now() - started) / 1000) }), 5000);
|
|
101
110
|
const timeout = setTimeout(() => child.kill(), TIMEOUT_MS);
|
|
102
|
-
child.on("error", (error) => reject(new Error(`Claude Code
|
|
111
|
+
child.on("error", (error) => reject(new Error(`Could not start Claude Code (${CONFIG.claude}): ${error.message}`)));
|
|
103
112
|
child.on("close", (code) => {
|
|
104
113
|
clearInterval(progress);
|
|
105
114
|
clearTimeout(timeout);
|
|
106
115
|
if (code === 0) resolvePromise();
|
|
107
|
-
else reject(new Error(`Claude Code
|
|
116
|
+
else reject(new Error(`Claude Code exited with code ${code}. ${output.trim().slice(-300)}`));
|
|
108
117
|
});
|
|
109
118
|
});
|
|
110
119
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pr-docent",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Guided, narrated review tours on top of GitHub pull request diffs. This package installs the local tour generator for the PR Docent Chrome extension.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"bin/",
|
|
27
27
|
"host/",
|
|
28
28
|
"src/diff.js",
|
|
29
|
+
"src/guidelines.js",
|
|
29
30
|
"src/tour.js",
|
|
30
31
|
"scripts/check-tour.mjs",
|
|
31
32
|
"scripts/tour-prompt.md"
|
package/scripts/check-tour.mjs
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Validates a tour against a pull request diff.
|
|
3
3
|
//
|
|
4
|
-
// node scripts/check-tour.mjs <tour.json> <pr.diff> [owner/repo#number]
|
|
4
|
+
// node scripts/check-tour.mjs <tour.json> <pr.diff> [owner/repo#number] [--guidelines <file>]
|
|
5
5
|
//
|
|
6
6
|
// Prints every problem and exits 1, or prints a one-line summary and exits 0.
|
|
7
7
|
// The diff must use 3 lines of context (`gh pr diff` or `git diff -U3`), as GitHub does.
|
|
8
|
+
// --guidelines points at a copy of the repository's .github/pr-docent.md; a missing file means
|
|
9
|
+
// the default limits.
|
|
8
10
|
|
|
9
|
-
import { readFileSync } from "node:fs";
|
|
10
|
-
import { checkTour, visibleLines } from "../src/diff.js";
|
|
11
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
12
|
+
import { changedLineCount, checkTour, visibleLines } from "../src/diff.js";
|
|
13
|
+
import { checkBrevity, parseGuidelines } from "../src/guidelines.js";
|
|
11
14
|
import { flattenSteps, parseTour } from "../src/tour.js";
|
|
12
15
|
|
|
13
|
-
const
|
|
16
|
+
const args = process.argv.slice(2);
|
|
17
|
+
const flag = args.indexOf("--guidelines");
|
|
18
|
+
const guidelinesPath = flag >= 0 ? args.splice(flag, 2)[1] : null;
|
|
19
|
+
const [tourPath, diffPath, expectedPr] = args;
|
|
14
20
|
if (!tourPath || !diffPath) {
|
|
15
|
-
console.error("usage: node scripts/check-tour.mjs <tour.json> <pr.diff> [owner/repo#number]");
|
|
21
|
+
console.error("usage: node scripts/check-tour.mjs <tour.json> <pr.diff> [owner/repo#number] [--guidelines <file>]");
|
|
16
22
|
process.exit(2);
|
|
17
23
|
}
|
|
18
24
|
|
|
@@ -37,7 +43,13 @@ if (expectedPr) {
|
|
|
37
43
|
if (actual !== expectedPr) fail([`pr is ${actual}, expected ${expectedPr}`]);
|
|
38
44
|
}
|
|
39
45
|
|
|
40
|
-
const
|
|
46
|
+
const diff = readFileSync(diffPath, "utf8");
|
|
47
|
+
const guidelines = parseGuidelines(guidelinesPath && existsSync(guidelinesPath) ? readFileSync(guidelinesPath, "utf8") : "");
|
|
48
|
+
if (guidelines.errors.length) fail(guidelines.errors.map((error) => `.github/pr-docent.md: ${error}`));
|
|
49
|
+
const problems = [
|
|
50
|
+
...checkTour(tour, visibleLines(diff)),
|
|
51
|
+
...checkBrevity(tour, { changedLines: changedLineCount(diff), limits: guidelines.limits }),
|
|
52
|
+
];
|
|
41
53
|
if (problems.length) fail(problems);
|
|
42
54
|
|
|
43
55
|
console.log(`OK: ${tour.chapters.length} chapters, ${flattenSteps(tour).length} steps, ${tour.skipped.length} skipped files`);
|
package/scripts/tour-prompt.md
CHANGED
|
@@ -16,7 +16,7 @@ code while they listen, so the tour must say what to look at and why, in reading
|
|
|
16
16
|
|
|
17
17
|
Write the tour to `{{OUT}}/tour.json`, then run
|
|
18
18
|
|
|
19
|
-
node {{TOOLS}}/scripts/check-tour.mjs {{OUT}}/tour.json {{OUT}}/pr.diff {{PR}}
|
|
19
|
+
node {{TOOLS}}/scripts/check-tour.mjs {{OUT}}/tour.json {{OUT}}/pr.diff {{PR}} --guidelines {{OUT}}/guidelines.md
|
|
20
20
|
|
|
21
21
|
and fix every problem it reports until it prints `OK`. Then stop. Do not post comments or
|
|
22
22
|
change any other file. Use these absolute paths as written and do not change directory:
|
|
@@ -55,12 +55,23 @@ change any other file. Use these absolute paths as written and do not change dir
|
|
|
55
55
|
|
|
56
56
|
## Rules
|
|
57
57
|
|
|
58
|
+
Be brief. Reviewers listen while they look at the code, so every sentence must earn its place.
|
|
59
|
+
The checker enforces these limits for this diff ({{CHANGED_LINES}} changed lines):
|
|
60
|
+
- At most {{MAX_STEPS}} steps in total.
|
|
61
|
+
- At most {{SENTENCES_PER_STEP}} sentences per step, each at most {{SENTENCE_CHARS}} characters.
|
|
62
|
+
- `point`: one question, at most {{POINT_CHARS}} characters.
|
|
63
|
+
- `summary`: at most {{SUMMARY_CHARS}} characters; each chapter `summary`: at most
|
|
64
|
+
{{CHAPTER_SUMMARY_CHARS}}.
|
|
65
|
+
|
|
58
66
|
Coverage and order
|
|
59
|
-
- Every changed file must be visited by a step or listed in `skipped` with a reason
|
|
60
|
-
|
|
67
|
+
- Every changed file must be visited by a step or listed in `skipped` with a short reason.
|
|
68
|
+
- Make steps only for changes a reviewer must think about. Put trivial changes in `skipped`
|
|
69
|
+
instead of steps: comment or doc-comment edits, imports, docs, test fixtures that pass values
|
|
70
|
+
through, lock files, generated code, renames, binaries. A file can have a step for its real
|
|
71
|
+
change and nothing for its trivial ones.
|
|
72
|
+
- Merge neighbouring changes that make one point into one step; never repeat a step.
|
|
61
73
|
- Order chapters the way a person should read the change, not by file path: what changes and
|
|
62
|
-
why, then the core logic, then how it is wired in, then tests
|
|
63
|
-
- A large file may need several steps; a trivial one may share a chapter with related files.
|
|
74
|
+
why, then the core logic, then how it is wired in, then tests.
|
|
64
75
|
|
|
65
76
|
Lines
|
|
66
77
|
- `lines` are line numbers in the new file (`"side": "right"`, the default). Use
|
|
@@ -72,13 +83,16 @@ Lines
|
|
|
72
83
|
step as a whole.
|
|
73
84
|
|
|
74
85
|
Notes are spoken aloud
|
|
75
|
-
- Write in {{LANGUAGE}}. One
|
|
76
|
-
- Say
|
|
86
|
+
- Write in {{LANGUAGE}}. One sentence per note entry, one idea per sentence.
|
|
87
|
+
- Say why the change matters: intent, risk, or how it connects to other code. Do not restate
|
|
88
|
+
what the code plainly shows ("adds a parameter", "imports X", "updates the comment").
|
|
77
89
|
- Avoid symbols, brackets and long lists that sound bad in speech. Name identifiers as written.
|
|
78
90
|
|
|
79
91
|
Levels and review points
|
|
80
92
|
- `focus`: where a bug would hurt or a design decision is made; at most about a third of the
|
|
81
|
-
steps. `skim`: worth a look.
|
|
82
|
-
- `point` is
|
|
83
|
-
a failure mode, a contract another caller relies on. Leave it out when there is nothing
|
|
84
|
-
to check. Never invent problems.
|
|
93
|
+
steps. `skim`: worth a look.
|
|
94
|
+
- `point` is one concrete question for the reviewer, grounded in the code you read: an edge
|
|
95
|
+
case, a failure mode, a contract another caller relies on. Leave it out when there is nothing
|
|
96
|
+
real to check. Never invent problems.
|
|
97
|
+
|
|
98
|
+
{{GUIDELINES}}
|
package/src/diff.js
CHANGED
|
@@ -43,16 +43,20 @@ export function visibleLines(diffText) {
|
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
45
|
* Problems that would make the tour point at the wrong place or skip part of the PR:
|
|
46
|
-
* files outside the diff, lines not shown on the page, beats outside their step,
|
|
47
|
-
* changed files the tour neither visits nor skips.
|
|
46
|
+
* files outside the diff, lines not shown on the page, beats outside their step, repeated
|
|
47
|
+
* steps, and changed files the tour neither visits nor skips.
|
|
48
48
|
*/
|
|
49
49
|
export function checkTour(tour, files) {
|
|
50
50
|
const errors = [];
|
|
51
51
|
const visited = new Set();
|
|
52
|
+
const seen = new Set();
|
|
52
53
|
tour.chapters.forEach((chapter, c) =>
|
|
53
54
|
chapter.steps.forEach((step, s) => {
|
|
54
55
|
const where = `chapters[${c}].steps[${s}] (${step.file})`;
|
|
55
56
|
visited.add(step.file);
|
|
57
|
+
const key = `${step.file}|${step.side}|${step.lines}`;
|
|
58
|
+
if (seen.has(key)) errors.push(`${where}: repeats an earlier step on the same lines`);
|
|
59
|
+
seen.add(key);
|
|
56
60
|
const file = files.get(step.file);
|
|
57
61
|
if (!file) return errors.push(`${where}: file is not changed in this pull request`);
|
|
58
62
|
const shown = file[step.side];
|
|
@@ -80,6 +84,18 @@ export function checkTour(tour, files) {
|
|
|
80
84
|
return errors;
|
|
81
85
|
}
|
|
82
86
|
|
|
87
|
+
/** Added plus removed lines, which sets how long a tour of the diff may be. */
|
|
88
|
+
export function changedLineCount(diffText) {
|
|
89
|
+
let count = 0;
|
|
90
|
+
let inHunk = false;
|
|
91
|
+
for (const line of String(diffText).split("\n")) {
|
|
92
|
+
if (line.startsWith("diff --git ")) inHunk = false;
|
|
93
|
+
else if (line.startsWith("@@")) inHunk = true;
|
|
94
|
+
else if (inHunk && (line.startsWith("+") || line.startsWith("-"))) count++;
|
|
95
|
+
}
|
|
96
|
+
return count;
|
|
97
|
+
}
|
|
98
|
+
|
|
83
99
|
function range([start, end]) {
|
|
84
100
|
return Array.from({ length: end - start + 1 }, (_, i) => start + i);
|
|
85
101
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// How long a generated tour may be, and a repository's own instructions for it.
|
|
2
|
+
// Pure functions — no DOM, no chrome.*, no file system.
|
|
3
|
+
//
|
|
4
|
+
// A repository can add .github/pr-docent.md. Its optional front matter overrides the limits,
|
|
5
|
+
// and the rest is appended to the generator's instructions:
|
|
6
|
+
//
|
|
7
|
+
// ---
|
|
8
|
+
// maxSteps: 5
|
|
9
|
+
// sentenceChars: 100
|
|
10
|
+
// ---
|
|
11
|
+
// Use the domain terms 원장, 안분 and 파생 라인. Always mark migrations as focus.
|
|
12
|
+
|
|
13
|
+
import { flattenSteps, splitSentences } from "./tour.js";
|
|
14
|
+
|
|
15
|
+
export const GUIDELINES_PATH = ".github/pr-docent.md";
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_LIMITS = {
|
|
18
|
+
maxSteps: null, // null: 3 + 1 per 25 changed lines, at most 20
|
|
19
|
+
sentencesPerStep: 3,
|
|
20
|
+
sentenceChars: 80,
|
|
21
|
+
pointChars: 120,
|
|
22
|
+
summaryChars: 200,
|
|
23
|
+
chapterSummaryChars: 60,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** { limits, instructions, errors } from the text of .github/pr-docent.md (or "" when absent). */
|
|
27
|
+
export function parseGuidelines(text) {
|
|
28
|
+
const limits = { ...DEFAULT_LIMITS };
|
|
29
|
+
const errors = [];
|
|
30
|
+
const match = String(text ?? "").match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
31
|
+
for (const line of match ? match[1].split(/\r?\n/) : []) {
|
|
32
|
+
if (!line.trim() || line.trim().startsWith("#")) continue;
|
|
33
|
+
const [, key, value] = line.replace(/\s+#.*$/, "").match(/^\s*(\w+)\s*:\s*(.*?)\s*$/) ?? [];
|
|
34
|
+
if (!key || !(key in DEFAULT_LIMITS)) errors.push(`Unknown setting: ${line.trim()}`);
|
|
35
|
+
else if (!/^\d+$/.test(value) || Number(value) < 1) errors.push(`${key} must be a positive whole number`);
|
|
36
|
+
else limits[key] = Number(value);
|
|
37
|
+
}
|
|
38
|
+
const instructions = String(text ?? "").slice(match ? match[0].length : 0).trim();
|
|
39
|
+
return { limits, instructions, errors };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** How many steps a tour of a diff with [changedLines] added or removed lines may have. */
|
|
43
|
+
export function stepBudget(changedLines, limits = DEFAULT_LIMITS) {
|
|
44
|
+
return limits.maxSteps ?? Math.min(20, 3 + Math.ceil(changedLines / 25));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Problems that make a tour wordier than the limits allow. */
|
|
48
|
+
export function checkBrevity(tour, { changedLines, limits = DEFAULT_LIMITS }) {
|
|
49
|
+
const errors = [];
|
|
50
|
+
const steps = flattenSteps(tour);
|
|
51
|
+
const budget = stepBudget(changedLines, limits);
|
|
52
|
+
if (steps.length > budget) {
|
|
53
|
+
errors.push(`${steps.length} steps for ${changedLines} changed lines; at most ${budget}. Merge related steps and list trivial changes (comments, imports, docs, pass-through code) in "skipped".`);
|
|
54
|
+
}
|
|
55
|
+
if (tour.summary.length > limits.summaryChars) errors.push(`summary is ${tour.summary.length} characters; at most ${limits.summaryChars}`);
|
|
56
|
+
tour.chapters.forEach((chapter, c) => {
|
|
57
|
+
if (chapter.summary.length > limits.chapterSummaryChars) {
|
|
58
|
+
errors.push(`chapters[${c}].summary is ${chapter.summary.length} characters; at most ${limits.chapterSummaryChars}`);
|
|
59
|
+
}
|
|
60
|
+
chapter.steps.forEach((step, s) => {
|
|
61
|
+
const where = `chapters[${c}].steps[${s}] (${step.file})`;
|
|
62
|
+
const sentences = step.beats.flatMap((beat) => splitSentences(beat.text).map((x) => x.text));
|
|
63
|
+
if (sentences.length > limits.sentencesPerStep) {
|
|
64
|
+
errors.push(`${where}: ${sentences.length} sentences; at most ${limits.sentencesPerStep}. Keep only what the code doesn't already say.`);
|
|
65
|
+
}
|
|
66
|
+
for (const sentence of sentences) {
|
|
67
|
+
if (sentence.length > limits.sentenceChars) {
|
|
68
|
+
errors.push(`${where}: a sentence is ${sentence.length} characters; at most ${limits.sentenceChars}: "${sentence.slice(0, 30)}…"`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (step.point.length > limits.pointChars) {
|
|
72
|
+
errors.push(`${where}: point is ${step.point.length} characters; at most ${limits.pointChars}. Ask one question.`);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
return errors;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The generator's instructions: [template] (scripts/tour-prompt.md) with the paths, the limits
|
|
81
|
+
* for this diff and the repository's own instructions filled in.
|
|
82
|
+
*/
|
|
83
|
+
export function buildPrompt(template, { pr, out, tools, language, changedLines, guidelinesText = "" }) {
|
|
84
|
+
const { limits, instructions } = parseGuidelines(guidelinesText);
|
|
85
|
+
const vars = {
|
|
86
|
+
PR: pr,
|
|
87
|
+
OUT: out,
|
|
88
|
+
TOOLS: tools,
|
|
89
|
+
LANGUAGE: language,
|
|
90
|
+
CHANGED_LINES: changedLines,
|
|
91
|
+
MAX_STEPS: stepBudget(changedLines, limits),
|
|
92
|
+
SENTENCES_PER_STEP: limits.sentencesPerStep,
|
|
93
|
+
SENTENCE_CHARS: limits.sentenceChars,
|
|
94
|
+
POINT_CHARS: limits.pointChars,
|
|
95
|
+
SUMMARY_CHARS: limits.summaryChars,
|
|
96
|
+
CHAPTER_SUMMARY_CHARS: limits.chapterSummaryChars,
|
|
97
|
+
GUIDELINES: instructions
|
|
98
|
+
? `## Repository guidelines (${GUIDELINES_PATH})\n\nThese come from the repository and take precedence over the rules above.\n\n${instructions}\n`
|
|
99
|
+
: "",
|
|
100
|
+
};
|
|
101
|
+
return template.replace(/{{(\w+)}}/g, (match, name) => (name in vars ? String(vars[name]) : match));
|
|
102
|
+
}
|
package/src/tour.js
CHANGED
|
@@ -160,6 +160,12 @@ export function findTourInBlocks(blocks, { owner, repo, number }) {
|
|
|
160
160
|
return found;
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
/** "ko" when the tour's text has Hangul, otherwise "en": the language to narrate it in. */
|
|
164
|
+
export function tourLanguage(tour) {
|
|
165
|
+
const text = [tour.title, tour.summary, ...flattenSteps(tour).flatMap((s) => [s.note, s.point])].join(" ");
|
|
166
|
+
return /[가-힣]/.test(text) ? "ko" : "en";
|
|
167
|
+
}
|
|
168
|
+
|
|
163
169
|
/** The bundled tour file name for a PR, e.g. "your-org--your-repo--42.json". */
|
|
164
170
|
export function tourFileName({ owner, repo, number }) {
|
|
165
171
|
return `${owner}--${repo}--${number}.json`;
|