#!/usr/bin/env node // 지어낸 목소리를 잡는다. 모자란 목소리는 재지 않는다 — 그것은 사람이 읽어야 안다. // // node check_voice.mjs [--블로그] <파일.md> [...] // // 왜 세지 않는가: 이 저장소에서 「종결어미 종류 수」를 세는 검사를 넣었더니, 그것을 맞추려고 // 없던 물음표 문장과 「~해 보자」가 문서에 끼어들었다. 목소리를 개수로 재면 같은 일이 난다. // 그래서 이 검사기는 있어야 할 것을 요구하지 않고, 있으면 안 되는 것만 잡는다. // // 참고 여섯 편을 그냥 돌리면 떨어진다. 그것은 규칙이 과해서가 아니라 장르가 달라서다 — // 여섯 편은 합니다체 블로그 글이라 `~는데요`를 쓰고 맺음말에 인사를 둔다. 기록은 한다체이고 // 인사할 자리가 없다. 여섯 편에 돌려 볼 때는 `--블로그`를 붙인다. 그러면 이 두 규칙만 꺼지고 // 지어낸 목소리를 잡는 규칙은 그대로 돈다 — 여섯 편은 그쪽에 한 건도 걸리지 않는다. import { readFileSync } from "node:fs"; import { basename } from "node:path"; const ERR = "error", WARN = "warn"; const RULES = [ // 1. 수사 과정을 말하는 문구. // // 이것만으로는 지어냈는지 알 수 없다. 22396 의 「여러 시행착오를 겪었습니다」는 바로 뒤에 // 실패한 시도 1 을 통째로 싣고, 7835 의 「고민 끝에」는 글 전체가 그 고민이다. 둘 다 정당하다. // 나쁜 것은 문장만 있고 과정이 없는 경우인데, 그건 문서를 읽어야 안다. 그래서 판단 항목이다. { id: "process-claimed", sev: WARN, re: /(처음에는[^.\n]{0,30}(의심|생각|짐작)|한참[^.\n]{0,15}(헤매|찾|고민)|여러[^.\n]{0,10}(시행착오|삽질)|고민\s*끝에|우여곡절|검토\s*끝에)/g, msg: "겪은 과정을 말했습니다. 이 문서가 그 과정을 실제로 보여 주면 두고, 문장만 있으면 지우세요." }, { id: "invented-emotion", sev: ERR, re: /(놀랍게도|당황스럽|의외로|뜻밖에도|아쉽게도|다행히도|기쁘게도|흥미롭게도|충격적)/g, msg: "감정을 지어냈습니다. 관측이 뜻밖이었다는 근거가 자료에 있어야 쓸 수 있습니다." }, // 2. 참고 글의 맺음말을 옮겨 온 자리. 기록에는 독자에게 인사하는 칸이 없다. { id: "borrowed-greeting", sev: ERR, re: /(도움이\s*되(길|기를)|되었으면\s*좋겠|공유(드립니다|하고자|합니다)|읽어\s*주셔서|감사합니다|즐거움을\s*느끼|노력하겠습니다|기대합니다)/g, msg: "블로그 맺음말의 인사입니다. 기록에는 그 자리가 없습니다. 남은 일이나 감수한 것으로 닫으세요." }, // 3. 합니다체의 부드러움을 한다체 문서에 섞은 자리 { id: "register-mix", sev: ERR, re: /(는데요|거든요|텐데요|인데요|한데요|잖아요|네요)/g, msg: "합니다체의 부드러운 어미를 섞었습니다. 참고 글의 어조이지 이 기록의 어조가 아닙니다." }, // 4. 겪지 않은 1인칭. 기록의 주어는 대개 코드와 요청이다. // 앞 글자가 한글이면 낱말 안이다 — 「브라우저는」의 「저는」을 잡지 않는다 { id: "unsupported-first-person", sev: WARN, re: /(? m.replace(/[^\n]/g, " ")) .replace(/`[^`\n]*`/g, (m) => " ".repeat(m.length)) .replace(/^---\n[\s\S]*?\n---\n/, (m) => m.replace(/[^\n]/g, " ")); } function lineOf(text, index) { return text.slice(0, index).split("\n").length; } let failed = 0; const argv = process.argv.slice(2); // 합니다체 블로그 글에 돌릴 때는 어조·인사 규칙을 끈다. 장르가 다르지 글이 나빠서가 아니다. const blogMode = argv.includes("--블로그"); const GENRE = new Set(["register-mix", "borrowed-greeting"]); const files = argv.filter((a) => !a.startsWith("--")); if (!files.length) { console.error("쓰는 법: node check_voice.mjs [--블로그] <파일.md>"); process.exit(2); } for (const file of files) { const raw = readFileSync(file, "utf8"); const text = strip(raw); const found = []; for (const r of RULES) { if (blogMode && GENRE.has(r.id)) continue; for (const m of text.matchAll(r.re)) { found.push({ ...r, line: lineOf(text, m.index), hit: m[0].trim() }); } } // 독자 쪽을 보는 문장은 하나까지가 정상이다 const steering = found.filter((f) => f.id === "steering"); const rest = found.filter((f) => f.id !== "steering"); const shown = steering.length > 1 ? rest.concat(steering) : rest; const errors = shown.filter((f) => f.sev === ERR).length; const warns = shown.length - errors; if (errors) failed = 1; console.log( errors ? `FAIL ${basename(file)} — error ${errors}건${warns ? ` · 경고 ${warns}건` : ""}` : `OK ${basename(file)}${warns ? ` (경고 ${warns}건)` : ""}` ); for (const f of shown) { const mark = f.sev === ERR ? " " : "·"; console.log(` ${mark} ${basename(file)}:${f.line} [${f.id}] "${f.hit}"`); console.log(` ${f.msg}`); } if (steering.length > 1) { console.log(` · [steering] 독자 쪽을 보는 문장이 ${steering.length}개입니다 — 하나만 남기세요.`); } } console.log( "\n검사기가 조용해도 목소리가 생긴 것은 아닙니다. 모자란 것은 사람이 읽어야 압니다." ); process.exit(failed);