gyojeong 0.1.0
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/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +38 -0
- package/apps/review-web/dist/_headers +7 -0
- package/apps/review-web/dist/assets/feedback-contract-BsrsBZh-.js +1 -0
- package/apps/review-web/dist/assets/feedback-wrapper-DIWwO_Fr.js +3 -0
- package/apps/review-web/dist/assets/feedback-wrapper-DWKFHwo5.css +1 -0
- package/apps/review-web/dist/assets/index-BGolyauJ.css +1 -0
- package/apps/review-web/dist/assets/index-Br6KkHpV.js +17 -0
- package/apps/review-web/dist/feedback-wrapper.html +50 -0
- package/apps/review-web/dist/index.html +14 -0
- package/bin/gyojeong.mjs +49 -0
- package/package.json +43 -0
- package/scripts/check.mjs +617 -0
- package/scripts/review-cli.mjs +6718 -0
- package/scripts/review-server.mjs +6003 -0
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire as __gyojeongRequire } from 'node:module'; const require = __gyojeongRequire(import.meta.url);
|
|
3
|
+
|
|
4
|
+
// ../../skills/gyojeong/scripts/check.mjs
|
|
5
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
|
|
8
|
+
// ../../skills/gyojeong/scripts/check-core.mjs
|
|
9
|
+
var EXCERPT_LENGTH = 80;
|
|
10
|
+
var EXIT = { pass: 0, warn: 1, fail: 2, undecidable: 3 };
|
|
11
|
+
var FENCE_RE = /^\s*(`{3,}|~{3,})/;
|
|
12
|
+
var HEADING_RE = /^\s{0,3}#{1,6}\s/;
|
|
13
|
+
function splitBlocks(text) {
|
|
14
|
+
const blocks = [];
|
|
15
|
+
let current = [];
|
|
16
|
+
let fence = null;
|
|
17
|
+
const flush = () => {
|
|
18
|
+
if (current.length) {
|
|
19
|
+
blocks.push({ kind: "prose", text: current.join("\n") });
|
|
20
|
+
current = [];
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
for (const line of text.replace(/\r\n?/g, "\n").split("\n")) {
|
|
24
|
+
if (fence) {
|
|
25
|
+
current.push(line);
|
|
26
|
+
const close = line.trim().match(/^(`{3,}|~{3,})\s*$/);
|
|
27
|
+
if (close && close[1][0] === fence[0] && close[1].length >= fence.length) {
|
|
28
|
+
blocks.push({ kind: "code", text: current.join("\n") });
|
|
29
|
+
current = [];
|
|
30
|
+
fence = null;
|
|
31
|
+
}
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const open = line.match(FENCE_RE);
|
|
35
|
+
if (open) {
|
|
36
|
+
flush();
|
|
37
|
+
fence = open[1];
|
|
38
|
+
current.push(line);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (line.trim() === "") {
|
|
42
|
+
flush();
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (HEADING_RE.test(line)) {
|
|
46
|
+
flush();
|
|
47
|
+
blocks.push({ kind: "heading", text: line });
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
current.push(line);
|
|
51
|
+
}
|
|
52
|
+
if (fence) {
|
|
53
|
+
blocks.push({ kind: "code", text: current.join("\n") });
|
|
54
|
+
} else {
|
|
55
|
+
flush();
|
|
56
|
+
}
|
|
57
|
+
return blocks;
|
|
58
|
+
}
|
|
59
|
+
function pairBlocks(sourceBlocks, draftBlocks) {
|
|
60
|
+
if (sourceBlocks.length !== draftBlocks.length) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
for (let i = 0; i < sourceBlocks.length; i += 1) {
|
|
64
|
+
if (sourceBlocks[i].kind === "code" !== (draftBlocks[i].kind === "code")) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return sourceBlocks.map((block, i) => [block, draftBlocks[i]]);
|
|
69
|
+
}
|
|
70
|
+
var CODE_SPAN_RE = /`[^`\n]+`/g;
|
|
71
|
+
var URL_RE = /\bhttps?:\/\/[^\s<>)\]"'`]+/g;
|
|
72
|
+
var FLAG_RE = /(?<![\w-])--[A-Za-z][\w-]*/g;
|
|
73
|
+
var IDENTIFIER_RE = /(?<![\w.-])[A-Za-z_][\w-]*(?:[._][\w-]+)+(?![\w-])/g;
|
|
74
|
+
var IDENTIFIER_STOPLIST = /* @__PURE__ */ new Set(["e.g", "i.e", "etc", "vs", "a.m", "p.m"]);
|
|
75
|
+
var LINK_TARGET_RE = /\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
|
|
76
|
+
function trimUrl(url) {
|
|
77
|
+
return url.replace(/[.,;:!?]+$/, "");
|
|
78
|
+
}
|
|
79
|
+
function maskCode(text) {
|
|
80
|
+
return text.replace(CODE_SPAN_RE, " ");
|
|
81
|
+
}
|
|
82
|
+
function stripComments(text) {
|
|
83
|
+
let out = "";
|
|
84
|
+
let quote = null;
|
|
85
|
+
let block = null;
|
|
86
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
87
|
+
const rest = text.slice(i);
|
|
88
|
+
if (block) {
|
|
89
|
+
if (rest.startsWith(block)) {
|
|
90
|
+
i += block.length - 1;
|
|
91
|
+
block = null;
|
|
92
|
+
}
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const char = text[i];
|
|
96
|
+
if (quote) {
|
|
97
|
+
out += char;
|
|
98
|
+
if (char === "\\") {
|
|
99
|
+
out += text[i + 1] ?? "";
|
|
100
|
+
i += 1;
|
|
101
|
+
} else if (char === quote) {
|
|
102
|
+
quote = null;
|
|
103
|
+
}
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (char === '"' || char === "'" || char === "`") {
|
|
107
|
+
quote = char;
|
|
108
|
+
out += char;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (rest.startsWith("/*")) {
|
|
112
|
+
block = "*/";
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (rest.startsWith("<!--")) {
|
|
116
|
+
block = "-->";
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (rest.startsWith("//") || char === "#") {
|
|
120
|
+
const end = text.indexOf("\n", i);
|
|
121
|
+
if (end === -1) break;
|
|
122
|
+
i = end - 1;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
out += char;
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
function normalizeCodeBlock(text) {
|
|
130
|
+
const raw = text.split("\n");
|
|
131
|
+
const open = raw[0] ?? "";
|
|
132
|
+
const closes = raw.length > 1 && /^\s*(`{3,}|~{3,})\s*$/.test(raw[raw.length - 1] ?? "");
|
|
133
|
+
const body = raw.slice(1, closes ? -1 : void 0).join("\n");
|
|
134
|
+
const lines = [open, ...stripComments(body).split("\n")];
|
|
135
|
+
const indent = lines[0].match(/^\s*/)[0].length;
|
|
136
|
+
return lines.map((line) => line.slice(Math.min(indent, line.match(/^\s*/)[0].length))).map((line) => line.replace(/\s+$/, "")).filter((line) => line.trim() !== "").join("\n");
|
|
137
|
+
}
|
|
138
|
+
function identifiers(prose) {
|
|
139
|
+
const masked = prose.replace(URL_RE, " ").replace(LINK_TARGET_RE, "] ");
|
|
140
|
+
return [...masked.matchAll(IDENTIFIER_RE)].map((m) => m[0].replace(/[.]+$/, "")).filter((id) => !IDENTIFIER_STOPLIST.has(id.toLowerCase()) && !/^\d/.test(id) && /[._]/.test(id));
|
|
141
|
+
}
|
|
142
|
+
function protectedTokens(blocks) {
|
|
143
|
+
const tokens = { code_block: [], code: [], url: [], flag: [], identifier: [] };
|
|
144
|
+
for (const block of blocks) {
|
|
145
|
+
if (block.kind === "code") {
|
|
146
|
+
tokens.code_block.push(normalizeCodeBlock(block.text));
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
tokens.code.push(...block.text.match(CODE_SPAN_RE) ?? []);
|
|
150
|
+
const prose = maskCode(block.text);
|
|
151
|
+
tokens.url.push(...(prose.match(URL_RE) ?? []).map(trimUrl));
|
|
152
|
+
tokens.flag.push(...prose.replace(URL_RE, " ").match(FLAG_RE) ?? []);
|
|
153
|
+
tokens.identifier.push(...identifiers(prose));
|
|
154
|
+
}
|
|
155
|
+
return tokens;
|
|
156
|
+
}
|
|
157
|
+
function multisetMinus(a, b) {
|
|
158
|
+
const counts = /* @__PURE__ */ new Map();
|
|
159
|
+
for (const item of b) counts.set(item, (counts.get(item) ?? 0) + 1);
|
|
160
|
+
const out = [];
|
|
161
|
+
for (const item of a) {
|
|
162
|
+
const n = counts.get(item) ?? 0;
|
|
163
|
+
if (n > 0) counts.set(item, n - 1);
|
|
164
|
+
else out.push(item);
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
var bareCount = (prose, text) => (prose.match(new RegExp(`(?<![\\w.$-])${text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w$-])`, "gu")) ?? []).length;
|
|
169
|
+
var TOKEN_LABEL = { code_block: "\uCF54\uB4DC \uBE14\uB85D", code: "\uC778\uB77C\uC778 \uCF54\uB4DC", url: "URL", flag: "CLI \uD50C\uB798\uADF8", identifier: "\uC2DD\uBCC4\uC790" };
|
|
170
|
+
function checkProtectedTokens(sourceBlocks, draftBlocks, add) {
|
|
171
|
+
const before = protectedTokens(sourceBlocks);
|
|
172
|
+
const after = protectedTokens(draftBlocks);
|
|
173
|
+
for (const kind of Object.keys(before)) {
|
|
174
|
+
const proseOf = (blocks) => maskCode(blocks.filter((block) => block.kind !== "code").map((block) => block.text).join("\n"));
|
|
175
|
+
const sourceProse = proseOf(sourceBlocks);
|
|
176
|
+
const draftProse = proseOf(draftBlocks);
|
|
177
|
+
for (const token of new Set(multisetMinus(before[kind], after[kind]))) {
|
|
178
|
+
const bare = token.slice(1, -1);
|
|
179
|
+
const replaced = multisetMinus(after[kind], before[kind]).length > 0;
|
|
180
|
+
if (kind === "code" && !replaced && bareCount(draftProse, bare) > bareCount(sourceProse, bare)) {
|
|
181
|
+
add("warn", "protected_token", `\uBC31\uD2F1\uC774 \uBE60\uC9C4 \uC778\uB77C\uC778 \uCF54\uB4DC: ${excerpt(token)}`, { token });
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const severity = kind === "flag" ? "warn" : "fail";
|
|
185
|
+
add(severity, "protected_token", `\uBE60\uC9C0\uAC70\uB098 \uBC14\uB010 ${TOKEN_LABEL[kind]}: ${excerpt(token)}`, { token });
|
|
186
|
+
}
|
|
187
|
+
for (const token of new Set(multisetMinus(after[kind], before[kind]))) {
|
|
188
|
+
const severity = kind === "code_block" || kind === "url" ? "fail" : "warn";
|
|
189
|
+
add(severity, "protected_token", `\uC6D0\uBB38\uC5D0 \uC5C6\uB294 ${TOKEN_LABEL[kind]}: ${excerpt(token)}`, { token });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
var NUM_RE = /\d[\d,]*(?:\.\d+)*/g;
|
|
194
|
+
var LIST_MARKER_RE = /^(\s*)\d+[.)]\s/gm;
|
|
195
|
+
var KOREAN_DATE_RE = /(\d{4})\s*년\s*(\d{1,2})\s*월\s*(\d{1,2})\s*일/g;
|
|
196
|
+
var KOREAN_UNIT_RE = /(\d+(?:\.\d+)?)\s*(억|만|천)/g;
|
|
197
|
+
var KOREAN_UNITS = { \uCC9C: 1e3, \uB9CC: 1e4, \uC5B5: 1e8 };
|
|
198
|
+
var ENGLISH_NUMBER_WORDS = {
|
|
199
|
+
zero: 0,
|
|
200
|
+
zeros: 0,
|
|
201
|
+
zeroes: 0,
|
|
202
|
+
one: 1,
|
|
203
|
+
ones: 1,
|
|
204
|
+
single: 1,
|
|
205
|
+
two: 2,
|
|
206
|
+
three: 3,
|
|
207
|
+
four: 4,
|
|
208
|
+
five: 5,
|
|
209
|
+
six: 6,
|
|
210
|
+
seven: 7,
|
|
211
|
+
eight: 8,
|
|
212
|
+
nine: 9,
|
|
213
|
+
ten: 10,
|
|
214
|
+
eleven: 11,
|
|
215
|
+
twelve: 12,
|
|
216
|
+
thirteen: 13,
|
|
217
|
+
fourteen: 14,
|
|
218
|
+
fifteen: 15,
|
|
219
|
+
sixteen: 16,
|
|
220
|
+
seventeen: 17,
|
|
221
|
+
eighteen: 18,
|
|
222
|
+
nineteen: 19,
|
|
223
|
+
twenty: 20,
|
|
224
|
+
thirty: 30,
|
|
225
|
+
forty: 40,
|
|
226
|
+
fifty: 50,
|
|
227
|
+
sixty: 60,
|
|
228
|
+
seventy: 70,
|
|
229
|
+
eighty: 80,
|
|
230
|
+
ninety: 90,
|
|
231
|
+
hundred: 100,
|
|
232
|
+
thousand: 1e3,
|
|
233
|
+
first: 1,
|
|
234
|
+
second: 2,
|
|
235
|
+
third: 3,
|
|
236
|
+
fourth: 4,
|
|
237
|
+
fifth: 5,
|
|
238
|
+
sixth: 6,
|
|
239
|
+
seventh: 7,
|
|
240
|
+
eighth: 8,
|
|
241
|
+
ninth: 9,
|
|
242
|
+
tenth: 10,
|
|
243
|
+
once: 1,
|
|
244
|
+
twice: 2,
|
|
245
|
+
double: 2,
|
|
246
|
+
triple: 3,
|
|
247
|
+
both: 2,
|
|
248
|
+
dozen: 12,
|
|
249
|
+
half: 50,
|
|
250
|
+
hex: 16,
|
|
251
|
+
hexadecimal: 16,
|
|
252
|
+
octal: 8,
|
|
253
|
+
binary: 2,
|
|
254
|
+
decimal: 10,
|
|
255
|
+
january: 1,
|
|
256
|
+
february: 2,
|
|
257
|
+
march: 3,
|
|
258
|
+
april: 4,
|
|
259
|
+
may: 5,
|
|
260
|
+
june: 6,
|
|
261
|
+
july: 7,
|
|
262
|
+
august: 8,
|
|
263
|
+
september: 9,
|
|
264
|
+
october: 10,
|
|
265
|
+
november: 11,
|
|
266
|
+
december: 12
|
|
267
|
+
};
|
|
268
|
+
var KOREAN_NUMBER_WORDS = { \uD55C: 1, \uD558\uB098: 1, \uB450: 2, \uB458: 2, \uC138: 3, \uC14B: 3, \uB124: 4, \uB137: 4, \uB2E4\uC12F: 5, \uC5EC\uC12F: 6, \uC77C\uACF1: 7, \uC5EC\uB35F: 8, \uC544\uD649: 9, \uC5F4: 10 };
|
|
269
|
+
var KOREAN_NUMERAL = "\uD55C|\uD558\uB098|\uB450|\uB458|\uC138|\uC14B|\uB124|\uB137|\uB2E4\uC12F|\uC5EC\uC12F|\uC77C\uACF1|\uC5EC\uB35F|\uC544\uD649|\uC5F4";
|
|
270
|
+
var KOREAN_COUNTER = "\uAC1C|\uBC88|\uAC00\uC9C0|\uBA85|\uB300|\uC904|\uB2E8\uACC4|\uBC30|\uC2DC\uAC04|\uBD84|\uCD08|\uC77C|\uC8FC|\uB2EC|\uACF3|\uCABD|\uC7A5|\uCE78";
|
|
271
|
+
var KOREAN_COUNTED_RES = [
|
|
272
|
+
new RegExp(`(?<![\uAC00-\uD7A3])(${KOREAN_NUMERAL})\\s+(?:${KOREAN_COUNTER})`, "g"),
|
|
273
|
+
new RegExp(`(?<![\uAC00-\uD7A3])(${KOREAN_NUMERAL})(?:${KOREAN_COUNTER})(?![\uAC00-\uD7A3])`, "g")
|
|
274
|
+
];
|
|
275
|
+
function numberProse(blocks) {
|
|
276
|
+
return blocks.filter((block) => block.kind !== "code").map((block) => {
|
|
277
|
+
const prose = maskCode(block.text).replace(URL_RE, " ").replace(LINK_TARGET_RE, "] ");
|
|
278
|
+
return prose.replace(IDENTIFIER_RE, " ").replace(LIST_MARKER_RE, "$1 ");
|
|
279
|
+
}).join("\n");
|
|
280
|
+
}
|
|
281
|
+
function normalizeNumber(raw) {
|
|
282
|
+
const plain = raw.replace(/,/g, "");
|
|
283
|
+
return /^\d+(\.\d+)?$/.test(plain) ? String(Number(plain)) : plain;
|
|
284
|
+
}
|
|
285
|
+
function digitNumbers(prose) {
|
|
286
|
+
const normalized = prose.replace(KOREAN_DATE_RE, (_, y, m, d) => ` ${y} ${Number(m)} ${Number(d)} `).replace(KOREAN_UNIT_RE, (_, n, unit) => ` ${Number(n) * KOREAN_UNITS[unit]} `);
|
|
287
|
+
return new Set((normalized.match(NUM_RE) ?? []).map(normalizeNumber));
|
|
288
|
+
}
|
|
289
|
+
function justifiedNumbers(prose, language) {
|
|
290
|
+
const out = /* @__PURE__ */ new Set();
|
|
291
|
+
if (language === "en") {
|
|
292
|
+
for (const m of prose.toLowerCase().matchAll(/\b[a-z]+\b/g)) {
|
|
293
|
+
if (Object.hasOwn(ENGLISH_NUMBER_WORDS, m[0])) out.add(String(ENGLISH_NUMBER_WORDS[m[0]]));
|
|
294
|
+
}
|
|
295
|
+
if (/\bnon-?negative\b|\bzero-based\b/i.test(prose)) out.add("0");
|
|
296
|
+
if (/\bpositive\b/i.test(prose)) out.add("1");
|
|
297
|
+
if (/\ban?\s+(?:full\s+|single\s+)?(?:year|month|week|day|hour|minute|second|byte|bit|character|element|item|time)s?\b/i.test(prose)) out.add("1");
|
|
298
|
+
} else {
|
|
299
|
+
for (const re of KOREAN_COUNTED_RES) {
|
|
300
|
+
for (const m of prose.matchAll(re)) out.add(String(KOREAN_NUMBER_WORDS[m[1]]));
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
305
|
+
function checkNumbers(sourceBlocks, draftBlocks, sourceLanguage, add) {
|
|
306
|
+
const sourceProse = numberProse(sourceBlocks);
|
|
307
|
+
const draftProse = numberProse(draftBlocks);
|
|
308
|
+
const sourceDigits = digitNumbers(sourceProse);
|
|
309
|
+
const draftDigits = digitNumbers(draftProse);
|
|
310
|
+
const justified = /* @__PURE__ */ new Set([...sourceDigits, ...justifiedNumbers(sourceProse, sourceLanguage)]);
|
|
311
|
+
const draftWords = justifiedNumbers(draftProse, "ko");
|
|
312
|
+
const draftAll = /* @__PURE__ */ new Set([...draftDigits, ...draftWords]);
|
|
313
|
+
const addedWords = [...draftWords].filter((n) => Number(n) >= 3);
|
|
314
|
+
for (const n of [...draftDigits, ...addedWords]) {
|
|
315
|
+
if (!justified.has(n)) add("fail", "number", `\uC6D0\uBB38\uC5D0 \uC5C6\uB294 \uC22B\uC790\uAC00 \uC0DD\uACBC\uC2B5\uB2C8\uB2E4: ${n}`, { token: n });
|
|
316
|
+
}
|
|
317
|
+
for (const n of sourceDigits) {
|
|
318
|
+
if (!draftAll.has(n)) add("warn", "number", `\uC6D0\uBB38\uC758 \uC22B\uC790\uAC00 \uBE60\uC84C\uC2B5\uB2C8\uB2E4: ${n}`, { token: n });
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
var EN_WEAK_PROHIBITION = [/\bavoid(?:s|ing)?\b/i, /\b(?:forbid|forbids|disallow|disallows)\b/i, /\bno\b[^.;!?\n]{1,40}\bshould\b/i, /\bnot\b[^.;!?\n]{1,40}\bonly\b|\bonly\b[^.;!?\n]{1,40}\bnot\b/i, /\bcannot\b|\bcan't\b/i];
|
|
322
|
+
var EN_PERMISSION = /\b(?:may|allowed|permitted|optional|optionally|free to|fine|okay|ok|need not|needn't|not required|no need|(?:do|does)n't need|(?:do|does) not need|(?:do|does)n't have to|(?:do|does) not have to|acceptable)\b/i;
|
|
323
|
+
var EN = {
|
|
324
|
+
prohibition: [
|
|
325
|
+
/(?:^|[.!?:]\s+|\n\s*(?:[-*+]|\d+[.)])?\s*|\*\*|>\s*)(?:do not|don't|never|avoid)\b(?!\s+(?:need|have to|require))/i,
|
|
326
|
+
// "should not", and "should, however, not"
|
|
327
|
+
/\b(?:must|shall|should)(?:\s+not|n't|,[^,.;!?\n]{1,25},\s+not)\b/i,
|
|
328
|
+
/\b(?:is|are|isn't|aren't)\s+not\s+(?:allowed|permitted)\b/i,
|
|
329
|
+
/\b(?:not allowed|not permitted|prohibited|forbidden)\b/i
|
|
330
|
+
],
|
|
331
|
+
obligation: [
|
|
332
|
+
/\bmust\b(?!\s*not|n't)/i,
|
|
333
|
+
/\bshall\b(?!\s*not)/i,
|
|
334
|
+
// "need to" and a bare "required" mostly describe ("elements we need to work with",
|
|
335
|
+
// "the minimal days required"), so only the directive forms count.
|
|
336
|
+
/(?<!(?:not|n't|never)\s+)\b(?:have|has)\s+to\b/i,
|
|
337
|
+
/\b(?:is|are)\s+required\s+to\b/i
|
|
338
|
+
]
|
|
339
|
+
};
|
|
340
|
+
var KO = {
|
|
341
|
+
prohibition: [
|
|
342
|
+
/지\s*(?:마|말)/,
|
|
343
|
+
/말아야|말\s*것/,
|
|
344
|
+
/(?:서는|면|어선|서도)\s*안\s*(?:됩|된|돼|되|될)/,
|
|
345
|
+
/금지|금합니다|삼가/,
|
|
346
|
+
/(?:않|없|말)아야|없어야|아니어야/,
|
|
347
|
+
/않도록\s*(?:하세요|하십시오|하십시요|합니다|한다|해\s*주|하라|주의)/,
|
|
348
|
+
/않는\s*(?:것이|게)\s*(?:좋|바람직)/,
|
|
349
|
+
/피해야|피하(?:세요|십시오|십시요|는\s*것이)/,
|
|
350
|
+
// "허용하지 않는 함수" states a fact; "허용되지 않습니다" is the prohibition.
|
|
351
|
+
/(?:허용되|허락되)지\s*않/
|
|
352
|
+
],
|
|
353
|
+
// "할 수 없습니다", "못하도록" are normal renderings of "must not" and "not allowed" in reference
|
|
354
|
+
// docs, and "절대 ~않습니다" states a fact, so these count as keeping a prohibition but not as
|
|
355
|
+
// adding one ("cannot" is also translated as 수 없다).
|
|
356
|
+
weakProhibition: [/수\s*없/, /못하(?:도록|게)|못\s*하(?:도록|게)/, /절대[^.!?\n]*(?:않|없|마)/],
|
|
357
|
+
obligation: [
|
|
358
|
+
/야(?:만)?\s*(?:하|합|한|해|함|할|했|됩|된|돼|되)/,
|
|
359
|
+
/반드시|필수|의무/,
|
|
360
|
+
/(?<![가-힣])꼭\s/,
|
|
361
|
+
/필요(?:가\s*)?있|필요(?:합니다|하다|해요|함)(?=[.!\s]|$)/
|
|
362
|
+
],
|
|
363
|
+
permission: [/수\s*있/, /(?:해|어|아|여)도\s*(?:됩|된|돼|되|좋|괜찮)/, /가능합|가능하다|허용됩|허용합/],
|
|
364
|
+
// "~해도 됩니다", "~해도 괜찮습니다": a permission stated outright, unlike the ambiguous 수 있다.
|
|
365
|
+
explicitPermission: [/도\s*(?:됩니다|된다|돼요|됨|되는|되며|되고|괜찮)/],
|
|
366
|
+
imperative: [/(?:세요|십시오|하라|해라)(?:[.!\s]|$)/]
|
|
367
|
+
};
|
|
368
|
+
var has = (patterns, text) => patterns.some((re) => re.test(text));
|
|
369
|
+
function modality(text, language) {
|
|
370
|
+
const prose = maskCode(text).replace(URL_RE, " ").replace(LINK_TARGET_RE, "] ").replace(/\{#[^}]*\}|\{\{[^{}]*\}\}|<[^>\n]+>/g, " ");
|
|
371
|
+
if (language === "en") {
|
|
372
|
+
const plain2 = prose.replace(/[_*]+/g, " ");
|
|
373
|
+
return {
|
|
374
|
+
permission: EN_PERMISSION.test(plain2),
|
|
375
|
+
prohibition: has(EN.prohibition, plain2),
|
|
376
|
+
weakProhibition: has(EN_WEAK_PROHIBITION, plain2),
|
|
377
|
+
obligation: has(EN.obligation, plain2)
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
const plain = prose.replace(/[_*]+/g, "");
|
|
381
|
+
return {
|
|
382
|
+
prohibition: has(KO.prohibition, plain),
|
|
383
|
+
weakProhibition: has(KO.weakProhibition, plain),
|
|
384
|
+
obligation: has(KO.obligation, plain),
|
|
385
|
+
permission: has(KO.permission, plain),
|
|
386
|
+
imperative: has(KO.imperative, plain),
|
|
387
|
+
explicitPermission: has(KO.explicitPermission, plain)
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
function comparePairModality(source, draft, sourceLanguage, add, where) {
|
|
391
|
+
const before = modality(source, sourceLanguage);
|
|
392
|
+
const after = modality(draft, "ko");
|
|
393
|
+
if (before.prohibition && !after.prohibition && !after.weakProhibition) {
|
|
394
|
+
if (after.permission) add("fail", "modality", "\uAE08\uC9C0 \uD45C\uD604\uC774 \uC0AC\uB77C\uC84C\uC2B5\uB2C8\uB2E4. \uD5C8\uC6A9 \uD45C\uD604\uC73C\uB85C \uBC14\uB00C\uC5C8\uC2B5\uB2C8\uB2E4.", where);
|
|
395
|
+
else if (after.obligation) add("fail", "modality", "\uAE08\uC9C0 \uD45C\uD604\uC774 \uC0AC\uB77C\uC84C\uC2B5\uB2C8\uB2E4. \uC758\uBB34 \uD45C\uD604\uC73C\uB85C \uBC14\uB00C\uC5C8\uC2B5\uB2C8\uB2E4.", where);
|
|
396
|
+
else add("warn", "modality", "\uAE08\uC9C0 \uD45C\uD604\uC774 \uBCF4\uC774\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. \uAE08\uC9C0\uC758 \uB73B\uC774 \uB0A8\uC544 \uC788\uB294\uC9C0 \uD655\uC778\uD558\uC138\uC694.", where);
|
|
397
|
+
}
|
|
398
|
+
if (before.obligation && !after.obligation && !after.prohibition) {
|
|
399
|
+
if (after.permission) {
|
|
400
|
+
add("fail", "modality", "\uC758\uBB34 \uD45C\uD604\uC774 \uD5C8\uC6A9 \uD45C\uD604\uC73C\uB85C \uC57D\uD574\uC84C\uC2B5\uB2C8\uB2E4.", where);
|
|
401
|
+
} else if (after.imperative) {
|
|
402
|
+
add("warn", "modality", "\uC758\uBB34 \uD45C\uD604\uC774 \uBA85\uB839\uD615\uC73C\uB85C \uBC14\uB00C\uC5C8\uC2B5\uB2C8\uB2E4. \uAC15\uB3C4\uAC00 \uC720\uC9C0\uB410\uB294\uC9C0 \uD655\uC778\uD558\uC138\uC694.", where);
|
|
403
|
+
} else {
|
|
404
|
+
add("warn", "modality", "\uC758\uBB34 \uD45C\uD604\uC774 \uBCF4\uC774\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. \uC758\uBB34\uC758 \uB73B\uC774 \uB0A8\uC544 \uC788\uB294\uC9C0 \uD655\uC778\uD558\uC138\uC694.", where);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (sourceLanguage === "en" && after.explicitPermission && !before.permission && (before.prohibition || before.obligation || before.weakProhibition)) {
|
|
408
|
+
add("fail", "modality", '\uC6D0\uBB38\uC5D0 \uC5C6\uB294 \uD5C8\uC6A9 \uD45C\uD604("~\uD574\uB3C4 \uB429\uB2C8\uB2E4")\uC774 \uC0DD\uACBC\uC2B5\uB2C8\uB2E4.', where);
|
|
409
|
+
}
|
|
410
|
+
if (!before.prohibition && !before.weakProhibition && after.prohibition) {
|
|
411
|
+
add("warn", "modality", "\uC6D0\uBB38\uC5D0 \uC5C6\uB294 \uAE08\uC9C0 \uD45C\uD604\uC774 \uC0DD\uACBC\uC2B5\uB2C8\uB2E4.", where);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
function checkModality(sourceBlocks, draftBlocks, pairs, sourceLanguage, add) {
|
|
415
|
+
if (pairs) {
|
|
416
|
+
pairs.forEach(([source, draft], index) => {
|
|
417
|
+
if (source.kind === "code") return;
|
|
418
|
+
comparePairModality(source.text, draft.text, sourceLanguage, add, {
|
|
419
|
+
block: index + 1,
|
|
420
|
+
source_excerpt: excerpt(source.text),
|
|
421
|
+
draft_excerpt: excerpt(draft.text)
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const prose = (blocks) => blocks.filter((b) => b.kind !== "code").map((b) => b.text);
|
|
427
|
+
const sourceTexts = prose(sourceBlocks);
|
|
428
|
+
const draftTexts = prose(draftBlocks);
|
|
429
|
+
const count = (texts, language, key) => texts.filter((t) => {
|
|
430
|
+
const m = modality(t, language);
|
|
431
|
+
return key === "prohibition" && language === "ko" ? m.prohibition || m.weakProhibition : m[key];
|
|
432
|
+
}).length;
|
|
433
|
+
let anySource = false;
|
|
434
|
+
for (const key of ["prohibition", "obligation"]) {
|
|
435
|
+
const s = count(sourceTexts, sourceLanguage, key);
|
|
436
|
+
const d = count(draftTexts, "ko", key);
|
|
437
|
+
if (s === 0) continue;
|
|
438
|
+
anySource = true;
|
|
439
|
+
if (d === 0) {
|
|
440
|
+
add("fail", "modality", key === "prohibition" ? "\uC6D0\uBB38\uC758 \uAE08\uC9C0 \uD45C\uD604\uC774 \uACB0\uACFC \uC804\uCCB4\uC5D0\uC11C \uC0AC\uB77C\uC84C\uC2B5\uB2C8\uB2E4." : "\uC6D0\uBB38\uC758 \uC758\uBB34 \uD45C\uD604\uC774 \uACB0\uACFC \uC804\uCCB4\uC5D0\uC11C \uC0AC\uB77C\uC84C\uC2B5\uB2C8\uB2E4.");
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if (anySource) {
|
|
444
|
+
add("undecidable", "pairing", `\uBB38\uB2E8 \uC9DD\uC744 \uB9DE\uCD94\uC9C0 \uBABB\uD574 \uBB38\uB2E8\uBCC4 \uC758\uBBF8 \uAC15\uB3C4\uB97C \uD655\uC778\uD558\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4 (\uC6D0\uBB38 ${sourceBlocks.length}\uAC1C, \uACB0\uACFC ${draftBlocks.length}\uAC1C). \uAE08\uC9C0\xB7\uC758\uBB34 \uBB38\uC7A5\uC744 \uC9C1\uC811 \uD655\uC778\uD558\uC138\uC694.`);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
function parseStandards(text) {
|
|
448
|
+
const terms = [];
|
|
449
|
+
let section = null;
|
|
450
|
+
for (const raw of text.replace(/\r\n?/g, "\n").split("\n")) {
|
|
451
|
+
const line = raw.trim();
|
|
452
|
+
if (!line || line.startsWith("#")) continue;
|
|
453
|
+
const header = line.match(/^\[([a-z_]+)\]$/);
|
|
454
|
+
if (header) {
|
|
455
|
+
section = header[1];
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
if (section !== "terms") continue;
|
|
459
|
+
const [term, target, reason = ""] = line.split("|").map((part) => part.trim());
|
|
460
|
+
if (term && target) terms.push({ term, target, reason });
|
|
461
|
+
}
|
|
462
|
+
return { terms };
|
|
463
|
+
}
|
|
464
|
+
var escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
465
|
+
var isLatin = (s) => /^[\x20-\x7e]+$/.test(s);
|
|
466
|
+
function termPattern(term) {
|
|
467
|
+
const body = escapeRegExp(term);
|
|
468
|
+
return isLatin(term) ? new RegExp(`(?<![A-Za-z0-9_-])${body}(?:s|es)?(?![A-Za-z0-9_-])`, "i") : new RegExp(body);
|
|
469
|
+
}
|
|
470
|
+
function containsTarget(text, target) {
|
|
471
|
+
return isLatin(target) ? termPattern(target).test(text) : text.includes(target);
|
|
472
|
+
}
|
|
473
|
+
function checkTerms(sourceBlocks, draftBlocks, pairs, mode, terms, add) {
|
|
474
|
+
if (!terms.length) return;
|
|
475
|
+
const prose = (text) => maskCode(text).replace(URL_RE, " ");
|
|
476
|
+
const units = pairs ? pairs.filter(([s]) => s.kind !== "code").map(([s, d], i) => ({ source: s.text, draft: d.text, block: i + 1 })) : [{ source: sourceBlocks.map((b) => b.text).join("\n"), draft: draftBlocks.map((b) => b.text).join("\n") }];
|
|
477
|
+
const seen = /* @__PURE__ */ new Set();
|
|
478
|
+
for (const unit of units) {
|
|
479
|
+
const source = prose(unit.source);
|
|
480
|
+
const draft = prose(unit.draft);
|
|
481
|
+
for (const { term, target, reason } of terms) {
|
|
482
|
+
const pattern = termPattern(term);
|
|
483
|
+
let wrong = false;
|
|
484
|
+
if (mode === "translate") {
|
|
485
|
+
wrong = pattern.test(source) && !containsTarget(draft, target);
|
|
486
|
+
} else {
|
|
487
|
+
wrong = term.toLowerCase() !== target.toLowerCase() && pattern.test(draft) && !containsTarget(draft, target);
|
|
488
|
+
}
|
|
489
|
+
const key = `${term}|${unit.block ?? 0}`;
|
|
490
|
+
if (wrong && !seen.has(key)) {
|
|
491
|
+
seen.add(key);
|
|
492
|
+
const why = reason ? ` (${reason})` : "";
|
|
493
|
+
add("warn", "term", `'${term}'\uC740(\uB294) '${target}'(\uC73C)\uB85C \uC501\uB2C8\uB2E4${why}.`, unit.block ? { block: unit.block, draft_excerpt: excerpt(unit.draft) } : {});
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
function excerpt(text) {
|
|
499
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
500
|
+
return flat.length > EXCERPT_LENGTH ? `${flat.slice(0, EXCERPT_LENGTH - 1)}\u2026` : flat;
|
|
501
|
+
}
|
|
502
|
+
function detectMode(source) {
|
|
503
|
+
const hangul = (source.match(/[가-힣]/g) ?? []).length;
|
|
504
|
+
const latin = (source.match(/[A-Za-z]/g) ?? []).length;
|
|
505
|
+
return hangul > latin * 0.2 ? "polish" : "translate";
|
|
506
|
+
}
|
|
507
|
+
function check({ source, draft, mode = detectMode(source), standards = { terms: [] } }) {
|
|
508
|
+
if (mode !== "translate" && mode !== "polish") throw new Error(`unknown mode: ${mode}`);
|
|
509
|
+
const sourceLanguage = mode === "translate" ? "en" : "ko";
|
|
510
|
+
const sourceBlocks = splitBlocks(source.normalize("NFC"));
|
|
511
|
+
const draftBlocks = splitBlocks(draft.normalize("NFC"));
|
|
512
|
+
const pairs = pairBlocks(sourceBlocks, draftBlocks);
|
|
513
|
+
const findings = [];
|
|
514
|
+
const add = (severity, checkName, message, extra = {}) => findings.push({ severity, check: checkName, message, ...extra });
|
|
515
|
+
const perParagraph = (run) => {
|
|
516
|
+
if (!pairs) {
|
|
517
|
+
run(sourceBlocks, draftBlocks, add);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
pairs.forEach(
|
|
521
|
+
([source2, draft2], index) => run(
|
|
522
|
+
[source2],
|
|
523
|
+
[draft2],
|
|
524
|
+
(severity, checkName, message, extra = {}) => add(severity, checkName, message, {
|
|
525
|
+
block: index + 1,
|
|
526
|
+
source_excerpt: excerpt(source2.text),
|
|
527
|
+
draft_excerpt: excerpt(draft2.text),
|
|
528
|
+
...extra
|
|
529
|
+
})
|
|
530
|
+
)
|
|
531
|
+
);
|
|
532
|
+
};
|
|
533
|
+
perParagraph((source2, draft2, report) => checkProtectedTokens(source2, draft2, report));
|
|
534
|
+
perParagraph((source2, draft2, report) => checkNumbers(source2, draft2, sourceLanguage, report));
|
|
535
|
+
checkModality(sourceBlocks, draftBlocks, pairs, sourceLanguage, add);
|
|
536
|
+
checkTerms(sourceBlocks, draftBlocks, pairs, mode, standards.terms, add);
|
|
537
|
+
const severities = new Set(findings.map((f) => f.severity));
|
|
538
|
+
const result = severities.has("fail") ? "fail" : severities.has("undecidable") ? "undecidable" : severities.has("warn") ? "warn" : "pass";
|
|
539
|
+
return {
|
|
540
|
+
schema_version: 1,
|
|
541
|
+
mode,
|
|
542
|
+
result,
|
|
543
|
+
exit_code: EXIT[result],
|
|
544
|
+
pairing: { source_blocks: sourceBlocks.length, draft_blocks: draftBlocks.length, paired: pairs !== null },
|
|
545
|
+
findings
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ../../skills/gyojeong/scripts/check.mjs
|
|
550
|
+
var MAX_INPUT_BYTES = 2 * 1024 * 1024;
|
|
551
|
+
var USAGE = "Usage: gyojeong check --source <file> --draft <file> [--mode translate|polish] [--standards <file>] [--json]";
|
|
552
|
+
function readInput(file) {
|
|
553
|
+
const stat = statSync(file, { throwIfNoEntry: false });
|
|
554
|
+
if (!stat?.isFile()) throw new Error(`not a file: ${file}`);
|
|
555
|
+
if (stat.size > MAX_INPUT_BYTES) throw new Error(`file larger than 2 MiB: ${file}`);
|
|
556
|
+
return readFileSync(file, "utf8");
|
|
557
|
+
}
|
|
558
|
+
function parseArgs(argv) {
|
|
559
|
+
const options = { json: false };
|
|
560
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
561
|
+
const arg = argv[i];
|
|
562
|
+
if (arg === "--json") options.json = true;
|
|
563
|
+
else if (["--source", "--draft", "--mode", "--standards"].includes(arg) && argv[i + 1] !== void 0) options[arg.slice(2)] = argv[++i];
|
|
564
|
+
else if (arg === "--help" || arg === "-h") options.help = true;
|
|
565
|
+
else throw new Error(`unknown argument: ${arg}`);
|
|
566
|
+
}
|
|
567
|
+
return options;
|
|
568
|
+
}
|
|
569
|
+
var SEVERITY_LABEL = { fail: "\uC2E4\uD328", warn: "\uACBD\uACE0", undecidable: "\uD310\uB2E8 \uBD88\uAC00" };
|
|
570
|
+
var RESULT_LABEL = { pass: "\uD1B5\uACFC", warn: "\uACBD\uACE0", fail: "\uC2E4\uD328", undecidable: "\uD310\uB2E8 \uBD88\uAC00" };
|
|
571
|
+
function formatReport(report) {
|
|
572
|
+
const lines = [`${RESULT_LABEL[report.result]} (${report.mode}, \uC885\uB8CC \uCF54\uB4DC ${report.exit_code})`];
|
|
573
|
+
for (const f of report.findings) {
|
|
574
|
+
const where = f.block ? ` [\uBB38\uB2E8 ${f.block}]` : "";
|
|
575
|
+
lines.push(`- ${SEVERITY_LABEL[f.severity]} ${f.check}${where}: ${f.message}`);
|
|
576
|
+
if (f.source_excerpt) lines.push(` \uC6D0\uBB38: ${f.source_excerpt}`);
|
|
577
|
+
if (f.draft_excerpt) lines.push(` \uACB0\uACFC: ${f.draft_excerpt}`);
|
|
578
|
+
}
|
|
579
|
+
return lines.join("\n");
|
|
580
|
+
}
|
|
581
|
+
function main(argv) {
|
|
582
|
+
let options;
|
|
583
|
+
try {
|
|
584
|
+
options = parseArgs(argv);
|
|
585
|
+
} catch (error) {
|
|
586
|
+
process.stderr.write(`${error.message}
|
|
587
|
+
${USAGE}
|
|
588
|
+
`);
|
|
589
|
+
return 64;
|
|
590
|
+
}
|
|
591
|
+
if (options.help) {
|
|
592
|
+
process.stdout.write(`${USAGE}
|
|
593
|
+
`);
|
|
594
|
+
return 0;
|
|
595
|
+
}
|
|
596
|
+
if (!options.source || !options.draft) {
|
|
597
|
+
process.stderr.write(`${USAGE}
|
|
598
|
+
`);
|
|
599
|
+
return 64;
|
|
600
|
+
}
|
|
601
|
+
try {
|
|
602
|
+
const source = readInput(options.source);
|
|
603
|
+
const draft = readInput(options.draft);
|
|
604
|
+
let standardsPath = options.standards;
|
|
605
|
+
if (!standardsPath && existsSync(path.join(".gyojeong", "standards"))) standardsPath = path.join(".gyojeong", "standards");
|
|
606
|
+
const standards = standardsPath ? parseStandards(readInput(standardsPath)) : { terms: [] };
|
|
607
|
+
const report = check({ source, draft, mode: options.mode ?? detectMode(source), standards });
|
|
608
|
+
process.stdout.write(`${options.json ? JSON.stringify(report) : formatReport(report)}
|
|
609
|
+
`);
|
|
610
|
+
return report.exit_code;
|
|
611
|
+
} catch (error) {
|
|
612
|
+
process.stderr.write(`${error.message}
|
|
613
|
+
`);
|
|
614
|
+
return 64;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
process.exitCode = main(process.argv.slice(2));
|