genetics-mcp 2.18.0 → 2.18.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/bin/aarch64-apple-darwin/gcell +0 -0
- package/bin/manifest.json +2 -2
- package/bin/x86_64-apple-darwin/gcell +0 -0
- package/package.json +1 -1
- package/skills/genetics-report/SKILL.md +140 -133
- package/skills/genetics-report/assets/example/cases-briefing-designs.json +32 -0
- package/skills/genetics-report/assets/example/plate-pair-after.png +0 -0
- package/skills/genetics-report/assets/example/plate-pair-before.png +0 -0
- package/skills/genetics-report/assets/example/plate-pair.html +25 -0
- package/skills/genetics-report/assets/example/report-briefing-designs.json +736 -0
- package/skills/genetics-report/assets/example/report-briefing.json +27 -22
- package/skills/genetics-report/assets/example/report.json +4 -4
- package/skills/genetics-report/assets/figure.mjs +390 -63
- package/skills/genetics-report/assets/mockshot.mjs +3 -2
- package/skills/genetics-report/assets/pair.mjs +138 -0
- package/skills/genetics-report/assets/shot.mjs +87 -19
- package/skills/genetics-report/references/content-pipeline.md +34 -33
- package/skills/genetics-report/references/history.md +117 -0
- package/skills/genetics-report/references/illustrations.md +16 -12
- package/skills/genetics-report/references/lim-portal-format.md +171 -89
- package/skills/genetics-report/references/linear-read-test.workflow.js +47 -0
- package/skills/genetics-report/references/process.md +141 -91
- package/skills/genetics-report/references/readability.md +46 -23
- package/skills/genetics-report/references/reader-review.workflow.js +157 -29
- package/skills/genetics-report/references/visual-first.md +94 -34
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// 두 칸 비교 도판 조립기 — 「그대로 두면」 / 「바꾸면」 두 컷을 좌우 같은 틀·같은 배율로 앉힌 도판 HTML 을 쓰고,
|
|
3
|
+
// (보이는 이름표는 이 둘이다 — 앞 컷이 지금 화면이고 뒤 컷이 재현이라는 **사실**은 머리 주석과 origin 에만 적는다, readability R12e·R13d)
|
|
4
|
+
// figure.mjs 를 자식으로 불러 판정까지 찍는다(readability.md §1 R13). 좌표·틀 높이·배율을 손으로 계산하지 않는다.
|
|
5
|
+
//
|
|
6
|
+
// node pair.mjs --layout wide|column --left a.png --right b.png --dpr N --min-text N
|
|
7
|
+
// --tag-left <칩> --label-left <이름표> --tag-right <칩> --label-right <이름표>
|
|
8
|
+
// [--crop-top N] [--crop-h N] [--mark x,y,w,h] [--seam y] [--geom geom.json] --out 도판.html
|
|
9
|
+
//
|
|
10
|
+
// --layout : wide = 전폭(assets[].wide: true), column = 본문 단. 칸 폭은 figure.mjs --spec 의 PAIR 에서 받는다.
|
|
11
|
+
// --dpr : 두 캡처의 픽셀 밀도(mockshot·shot 2, 휴대폰 실기기 캡처는 기기 배율). --min-text 는 화면의 가장 작은 글자 CSS px.
|
|
12
|
+
// --crop-top / --crop-h : 원본 CSS px — 두 칸을 **같은 구간**으로 자른다(위아래로 가르지 않는다).
|
|
13
|
+
// --mark : 왼쪽 칸의 검정 실선(있는·바뀔 자리), 원본 CSS px. --seam : 오른쪽 칸의 회색 점선 윗선(빠진 자리), 원본 CSS px.
|
|
14
|
+
// --geom : shot.mjs --after --geom 이 쓴 기하 — 숨긴 블록의 합집합이 mark, 그 윗선이 seam. --mark/--seam 과 함께 쓰지 않는다.
|
|
15
|
+
// 틀 높이는 두 칸 중 큰 쪽이고 짧은 쪽은 흰 여백이다. 상한은 R9 표시 높이에서 이름표 줄·여백을 뺀 값 — 넘으면 exit 3.
|
|
16
|
+
// img src 는 출력 HTML 기준 상대경로라 폴더째 옮기면 다른 기계에서도 다시 굽힌다.
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { spawnSync } from 'node:child_process';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
|
|
22
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const FIGURE = path.join(HERE, 'figure.mjs');
|
|
24
|
+
// 도판 조판 — 칸 폭·사이·좌우 여백은 figure.mjs PAIR 가 정본, 여기는 그 안의 위아래 여백과 이름표 줄만
|
|
25
|
+
const PAD_TOP = 16, PAD_BOTTOM = 20, LABEL_H = 28, LABEL_GAP = 8, MARK_INSET = 3, SEAM_INSET = 10;
|
|
26
|
+
|
|
27
|
+
const KNOWN = {
|
|
28
|
+
'--layout': 'str', '--left': 'str', '--right': 'str', '--dpr': 'num', '--min-text': 'num',
|
|
29
|
+
'--tag-left': 'str', '--label-left': 'str', '--tag-right': 'str', '--label-right': 'str',
|
|
30
|
+
'--crop-top': 'num', '--crop-h': 'num', '--mark': 'str', '--seam': 'num', '--geom': 'str', '--out': 'str',
|
|
31
|
+
};
|
|
32
|
+
const REQUIRED = ['--layout', '--left', '--right', '--dpr', '--min-text', '--tag-left', '--label-left', '--tag-right', '--label-right', '--out'];
|
|
33
|
+
const USAGE = 'usage: node pair.mjs ' + Object.entries(KNOWN).map(([k, t]) => REQUIRED.includes(k) ? `${k} <${t}>` : `[${k} <${t}>]`).join(' ');
|
|
34
|
+
const SHELL_HINT = '셸이 여러 플래그를 한 낱말로 넘겼을 수 있음(zsh 는 $VAR 를 가르지 않는다), 플래그를 그대로 적는다';
|
|
35
|
+
const stop = (code, msg) => { console.error(msg); if (code === 2) console.error(USAGE); process.exit(code); };
|
|
36
|
+
|
|
37
|
+
const A = {};
|
|
38
|
+
const argv = process.argv.slice(2);
|
|
39
|
+
for (let i = 0; i < argv.length; i++) {
|
|
40
|
+
const a = argv[i];
|
|
41
|
+
if (!a.startsWith('--') || /\s/.test(a) || !KNOWN[a]) stop(2, `모르는 인자 「${a}」 — ${SHELL_HINT}`);
|
|
42
|
+
const v = argv[++i];
|
|
43
|
+
if (v === undefined) stop(2, `인자 ${a} 의 값이 없음 — ${a} 뒤에 값을 적는다`);
|
|
44
|
+
if (KNOWN[a] === 'num' && !Number.isFinite(Number(v))) stop(2, `인자 ${a} 의 값 「${v}」 이 수가 아님 — ${SHELL_HINT}`);
|
|
45
|
+
A[a] = KNOWN[a] === 'num' ? Number(v) : v;
|
|
46
|
+
}
|
|
47
|
+
const missing = REQUIRED.filter(k => A[k] === undefined);
|
|
48
|
+
if (missing.length) stop(2, `빠진 인자 ${missing.join(' ')}`);
|
|
49
|
+
if (A['--geom'] && (A['--mark'] !== undefined || A['--seam'] !== undefined)) stop(2, '--geom 과 --mark/--seam 을 함께 주었음 — 기하는 한 원천만(shot.mjs --geom 이면 --geom 하나)');
|
|
50
|
+
|
|
51
|
+
const spec = JSON.parse(spawnSync(process.execPath, [FIGURE, '--spec'], { encoding: 'utf8' }).stdout);
|
|
52
|
+
const P = spec.PAIR[A['--layout']];
|
|
53
|
+
if (!P) stop(2, `--layout 「${A['--layout']}」 — wide 또는 column`);
|
|
54
|
+
const dpr = A['--dpr'], minText = A['--min-text'];
|
|
55
|
+
const cropTop = A['--crop-top'] ?? 0;
|
|
56
|
+
const d2 = (x) => Math.round(x * 100) / 100;
|
|
57
|
+
|
|
58
|
+
// PNG 머리(IHDR)에서 원본 크기를 읽는다
|
|
59
|
+
function pngSize(file) {
|
|
60
|
+
let buf;
|
|
61
|
+
try { buf = fs.readFileSync(file); } catch (e) { stop(2, `캡처를 못 읽음 — ${file}: ${e.message}`); }
|
|
62
|
+
if (buf.length < 24 || buf.readUInt32BE(0) !== 0x89504e47 || buf.toString('ascii', 12, 16) !== 'IHDR') stop(2, `PNG 가 아님 — ${file} (mockshot·shot 이 쓴 PNG 를 준다)`);
|
|
63
|
+
return { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) };
|
|
64
|
+
}
|
|
65
|
+
const L = pngSize(A['--left']), Rt = pngSize(A['--right']);
|
|
66
|
+
if (L.w !== Rt.w) stop(2, `두 캡처의 원본 폭이 다름(${L.w} · ${Rt.w}px) — 같은 뷰포트·같은 요소로 찍어야 두 칸이 같은 배율이 된다`);
|
|
67
|
+
const s = P.img / (L.w / dpr); // 배율 = 칸 이미지 폭 ÷ 원본 CSS 폭
|
|
68
|
+
const cssH = (img) => img.h / dpr;
|
|
69
|
+
const shown = (img) => { const rest = cssH(img) - cropTop; if (rest <= 0) stop(2, `--crop-top ${cropTop} 이 캡처 높이 ${d2(cssH(img))} CSS px 이상 — 자를 구간이 없다`); return Math.min(A['--crop-h'] ?? Infinity, rest); };
|
|
70
|
+
const frameH = Math.ceil(Math.max(shown(L), shown(Rt)) * s);
|
|
71
|
+
const cap = spec.maxDisplayH - PAD_TOP - PAD_BOTTOM - LABEL_H - LABEL_GAP - 2 * P.border;
|
|
72
|
+
if (frameH > cap) stop(3, `틀 높이 ${frameH}px 이 상한 ${cap}px(R9 표시 높이 ${spec.maxDisplayH} − 이름표 줄·여백)을 넘습니다 — crop-h 를 줄인다(원본 CSS px 로 ${Math.floor(cap / s)} 이하)`);
|
|
73
|
+
|
|
74
|
+
// 표식 — 원본 좌표 × 배율, 자른 구간 기준. 틀 가장자리에 먹히지 않게 안쪽으로 붙인다
|
|
75
|
+
let mark = null, seam = null;
|
|
76
|
+
if (A['--geom']) {
|
|
77
|
+
let g;
|
|
78
|
+
try { g = JSON.parse(fs.readFileSync(A['--geom'], 'utf8')); } catch (e) { stop(2, `기하 JSON 을 못 읽음 — ${A['--geom']}: ${e.message}`); }
|
|
79
|
+
if (g.dpr !== dpr) stop(2, `기하의 dpr ${g.dpr} 와 --dpr ${dpr} 가 다름 — 찍은 밀도 그대로`);
|
|
80
|
+
if (!Array.isArray(g.hidden) || !g.hidden.length) stop(2, '기하에 숨긴 블록(hidden)이 없음 — shot.mjs --hide … --after … --geom 으로 찍는다');
|
|
81
|
+
const x0 = Math.min(...g.hidden.map(r => r.x)), y0 = Math.min(...g.hidden.map(r => r.y));
|
|
82
|
+
const x1 = Math.max(...g.hidden.map(r => r.x + r.w)), y1 = Math.max(...g.hidden.map(r => r.y + r.h));
|
|
83
|
+
mark = [x0, y0, x1 - x0, y1 - y0]; seam = y0;
|
|
84
|
+
} else {
|
|
85
|
+
if (A['--mark'] !== undefined) {
|
|
86
|
+
mark = A['--mark'].split(',').map(Number);
|
|
87
|
+
if (mark.length !== 4 || mark.some(n => !Number.isFinite(n))) stop(2, `--mark 「${A['--mark']}」 이 x,y,w,h 가 아님 — 원본 CSS px 넷을 쉼표로`);
|
|
88
|
+
}
|
|
89
|
+
if (A['--seam'] !== undefined) seam = A['--seam'];
|
|
90
|
+
}
|
|
91
|
+
const inCrop = (y) => y >= cropTop && y <= cropTop + frameH / s;
|
|
92
|
+
let markCss = '', seamCss = '';
|
|
93
|
+
if (mark) {
|
|
94
|
+
const [x, y, w, h] = mark;
|
|
95
|
+
if (!inCrop(y) && !inCrop(y + h)) stop(2, `--mark 자리(y ${y}~${y + h})가 자른 구간 밖 — crop-top·crop-h 를 표식이 들어오게`);
|
|
96
|
+
const left = Math.max(MARK_INSET, x * s), right = Math.min(P.img - MARK_INSET, (x + w) * s);
|
|
97
|
+
const top = Math.max(MARK_INSET, (y - cropTop) * s), bottom = Math.min(frameH - MARK_INSET, (y + h - cropTop) * s);
|
|
98
|
+
markCss = `left:${d2(left)}px;top:${d2(top)}px;width:${d2(right - left)}px;height:${d2(bottom - top)}px`;
|
|
99
|
+
}
|
|
100
|
+
if (seam !== null) {
|
|
101
|
+
if (!inCrop(seam)) stop(2, `--seam 자리(y ${seam})가 자른 구간 밖 — crop-top·crop-h 를 표식이 들어오게`);
|
|
102
|
+
seamCss = `top:${d2(Math.max(1, (seam - cropTop) * s - 1))}px`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const out = path.resolve(A['--out']);
|
|
106
|
+
const srcOf = (f) => path.relative(path.dirname(out), path.resolve(f)).split(path.sep).join('/');
|
|
107
|
+
const esc = (t) => String(t).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
108
|
+
const imgTag = (f) => `<img src="${esc(srcOf(f))}" alt="" data-dpr="${dpr}" data-min-text="${minText}" style="top:${d2(-cropTop * s)}px">`;
|
|
109
|
+
const col = (tag, label, f, extra) => ` <div class="col">
|
|
110
|
+
<p class="lab"><span class="tag">${esc(tag)}</span>${esc(label)}</p>
|
|
111
|
+
<div class="shot">${imgTag(f)}${extra}</div>
|
|
112
|
+
</div>`;
|
|
113
|
+
const html = `<!doctype html><meta charset="utf-8">
|
|
114
|
+
<!-- pair.mjs 가 쓴 두 칸 비교 도판 — 손으로 고치지 말고 인자를 바꿔 다시 쓴다.
|
|
115
|
+
layout ${A['--layout']} · 칸 ${P.cell} · 사이 ${P.gap} · 좌우 ${P.side} · 배율 ${d2(s)}(원본 ${L.w}px ÷ dpr ${dpr}) · 틀 높이 ${frameH}
|
|
116
|
+
left ${path.basename(A['--left'])} ${L.w}×${L.h} · right ${path.basename(A['--right'])} ${Rt.w}×${Rt.h} · crop-top ${cropTop}${A['--crop-h'] !== undefined ? ` · crop-h ${A['--crop-h']}` : ''}${mark ? ` · mark ${mark.map(d2).join(',')}` : ''}${seam !== null ? ` · seam ${d2(seam)}` : ''} -->
|
|
117
|
+
<style>
|
|
118
|
+
body{margin:0;background:#fff;font-family:Pretendard,"Apple SD Gothic Neo","Noto Sans KR",sans-serif;color:#111}
|
|
119
|
+
.wrap{width:${P.display}px;padding:${PAD_TOP}px ${P.side}px ${PAD_BOTTOM}px;box-sizing:border-box;background:#fff;display:flex;gap:${P.gap}px;align-items:flex-start}
|
|
120
|
+
.col{width:${P.cell}px;flex:none}
|
|
121
|
+
.lab{font-size:17px;font-weight:700;height:${LABEL_H}px;line-height:${LABEL_H}px;margin:0 0 ${LABEL_GAP}px;display:flex;align-items:center;gap:10px;white-space:nowrap}
|
|
122
|
+
.tag{display:inline-block;background:#e5e7eb;color:#111;font-size:15px;font-weight:700;line-height:24px;padding:0 10px;border-radius:2px;flex:none}
|
|
123
|
+
.shot{position:relative;width:${P.cell}px;height:${frameH + 2 * P.border}px;border:${P.border}px solid #bbb;border-radius:4px;overflow:hidden;box-sizing:border-box;background:#fff}
|
|
124
|
+
.shot img{position:absolute;left:0;display:block;width:${P.img}px}
|
|
125
|
+
.mark{position:absolute;border:3px solid #111;border-radius:6px;box-sizing:border-box}
|
|
126
|
+
.seam{position:absolute;left:${SEAM_INSET}px;right:${SEAM_INSET}px;border-top:2px dashed #595959}
|
|
127
|
+
</style>
|
|
128
|
+
<div class="wrap">
|
|
129
|
+
${col(A['--tag-left'], A['--label-left'], A['--left'], mark ? `<div class="mark" style="${markCss}"></div>` : '')}
|
|
130
|
+
${col(A['--tag-right'], A['--label-right'], A['--right'], seam !== null ? `<div class="seam" style="${seamCss}"></div>` : '')}
|
|
131
|
+
</div>
|
|
132
|
+
`;
|
|
133
|
+
fs.writeFileSync(out, html);
|
|
134
|
+
console.log(`${path.basename(out)} — layout ${A['--layout']} · 배율 ${d2(s)} · 틀 높이 ${frameH}px(상한 ${cap}) · 왼쪽 ${d2(shown(L) * s)} · 오른쪽 ${d2(shown(Rt) * s)}px`);
|
|
135
|
+
|
|
136
|
+
const png = out.replace(/\.html?$/i, '') + '.png';
|
|
137
|
+
const r = spawnSync(process.execPath, [FIGURE, out, png, ...(A['--layout'] === 'wide' ? ['--wide'] : [])], { stdio: 'inherit' });
|
|
138
|
+
process.exit(r.status ?? 2);
|
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// 실제 화면 캡처 — 페이지의 한 요소를 번호 표식(①②③…)을 얹은 채 찍는다(visual-first.md §4).
|
|
3
|
-
// node shot.mjs <url> <selector> <out.png> [--badge "<selector>=①" ...] [--mask "<원문>=<대체>" ...]
|
|
4
|
-
// [--
|
|
5
|
-
//
|
|
3
|
+
// node shot.mjs <url> <selector> <out.png> [--badge "<selector>=①" ...] [--badge-color <css 색>] [--mask "<원문>=<대체>" ...]
|
|
4
|
+
// [--hide "<selector>" ...] [--hide-text "<글자>" ...] [--after <out2.png>] [--style "<css>"]
|
|
5
|
+
// [--geom <geom.json>] [--clip x,y,w,h] [--unclip] [--scale 2] [--wait <ms>] [--viewport 1400]
|
|
6
|
+
// --badge : 그 요소의 왼쪽 위에 검정 원 배지(흰 링, 읽는 순서)를 얹고 테두리를 두른다. 보고서 표의 번호와 같게 둔다.
|
|
7
|
+
// 캡처에 구운 배지는 figure.mjs 가 못 보므로 이 기본값이 표식 문법(readability R13c)의 집행자다.
|
|
8
|
+
// --badge-color : 배지·테두리 색 탈출구. 유채색은 캡처 없는 개념도의 뜻 색과 섞이므로 사유가 있을 때만.
|
|
6
9
|
// --mask : 정확히 그 글자인 텍스트 노드를 대체 글자로 바꾼다(닉네임 가림).
|
|
10
|
+
// --hide : 대상 요소 안에서 그 선택자인 블록을 숨긴다. --hide-text 는 정확히 그 글자인 잎 요소를 품은
|
|
11
|
+
// **대상 요소의 직계 자식 블록**을 숨긴다. --after 가 없으면 숨긴 뒤 한 번 찍는다.
|
|
12
|
+
// --after : 전후 촬영 — 첫 장은 그대로 찍고, 숨길 블록의 자리(top·height)를 잰 뒤 숨기고 같은 요소를 다시 찍는다.
|
|
13
|
+
// 좌표를 손으로 옮기지 않는다 — --geom 으로 받아 pair.mjs 에 넘긴다.
|
|
14
|
+
// --style : 찍기 전에 CSS 를 얹는다(옆 열의 넘친 글자 가리기 등).
|
|
15
|
+
// --geom : 촬영 기하 JSON — { selector, dpr, clip, before:{w,h}, after:{w,h}|null, hidden:[{by,x,y,w,h}] } (CSS px, 대상 요소 기준 — clip 이 있으면 clip 원점 기준).
|
|
16
|
+
// --clip : 대상 요소 기준 x,y,w,h(CSS px) 만 찍는다 — 전후 두 장이 같은 구간으로 잘린다. 외부 크롭 도구는 백지를 낸 적이 있다.
|
|
7
17
|
// --unclip : 고정(sticky/fixed) 요소를 풀고 스크롤 컨테이너를 펼친다 — 스크롤 영역 안의 요소를 통째로 찍을 때.
|
|
8
18
|
// ⚠ absolute 는 건드리지 않는다. 전부 static 으로 풀면 막대·표식이 왼쪽으로 몰린다(2026-09-08 실사고).
|
|
9
19
|
import fs from 'node:fs';
|
|
@@ -11,20 +21,39 @@ import os from 'node:os';
|
|
|
11
21
|
import path from 'node:path';
|
|
12
22
|
import { pathToFileURL } from 'node:url';
|
|
13
23
|
|
|
24
|
+
const USAGE = 'usage: node shot.mjs <url> <selector> <out.png> [--badge sel=①] [--badge-color <색>] [--mask 원문=대체] [--hide sel] [--hide-text 글자] [--after out2.png] [--style css] [--geom geom.json] [--clip x,y,w,h] [--unclip] [--scale 2] [--wait ms] [--viewport 1400]';
|
|
25
|
+
const SHELL_HINT = '셸이 여러 플래그를 한 낱말로 넘겼을 수 있음(zsh 는 $VAR 를 가르지 않는다), 플래그를 그대로 적는다';
|
|
26
|
+
const fail = (msg) => { console.error(msg); console.error(USAGE); process.exit(2); };
|
|
14
27
|
const args = process.argv.slice(2);
|
|
15
|
-
const pos = [], badges = [], masks = []
|
|
28
|
+
const pos = [], badges = [], masks = [], hides = [], hideTexts = [];
|
|
29
|
+
let unclip = false, scale = 2, wait = 1200, viewport = 1400, badgeColor = '#111', after = null, style = null, geomOut = null, clip = null;
|
|
30
|
+
const numArg = (k, v) => { if (v === undefined || !Number.isFinite(Number(v))) fail(`인자 ${k} 의 값 「${v}」 이 수가 아님 — ${SHELL_HINT}`); return Number(v); };
|
|
31
|
+
const strArg = (k, v) => { if (v === undefined) fail(`인자 ${k} 의 값이 없음 — ${k} 뒤에 값을 적는다`); return v; };
|
|
16
32
|
for (let i = 0; i < args.length; i++) {
|
|
17
33
|
const a = args[i];
|
|
18
|
-
if (a === '--viewport') viewport =
|
|
19
|
-
else if (a === '--badge') badges.push(args[++i]);
|
|
20
|
-
else if (a === '--
|
|
34
|
+
if (a === '--viewport') viewport = numArg(a, args[++i]); // 웹 캡처는 뷰포트 = 표시 폭(760)으로 찍어야 글자가 1:1 (readability.md R1·R8)
|
|
35
|
+
else if (a === '--badge') badges.push(strArg(a, args[++i]));
|
|
36
|
+
else if (a === '--badge-color') badgeColor = strArg(a, args[++i]);
|
|
37
|
+
else if (a === '--mask') masks.push(strArg(a, args[++i]));
|
|
38
|
+
else if (a === '--hide') hides.push(strArg(a, args[++i]));
|
|
39
|
+
else if (a === '--hide-text') hideTexts.push(strArg(a, args[++i]));
|
|
40
|
+
else if (a === '--after') after = strArg(a, args[++i]);
|
|
41
|
+
else if (a === '--style') style = strArg(a, args[++i]);
|
|
42
|
+
else if (a === '--geom') geomOut = strArg(a, args[++i]);
|
|
43
|
+
else if (a === '--clip') {
|
|
44
|
+
const v = strArg(a, args[++i]).split(',').map(Number);
|
|
45
|
+
if (v.length !== 4 || v.some(n => !Number.isFinite(n)) || v[2] <= 0 || v[3] <= 0) fail(`인자 --clip 의 값 「${args[i]}」 이 x,y,w,h 가 아님 — 대상 요소 기준 CSS px 넷을 쉼표로`);
|
|
46
|
+
clip = { x: v[0], y: v[1], w: v[2], h: v[3] };
|
|
47
|
+
}
|
|
21
48
|
else if (a === '--unclip') unclip = true;
|
|
22
|
-
else if (a === '--scale') scale =
|
|
23
|
-
else if (a === '--wait') wait =
|
|
49
|
+
else if (a === '--scale') scale = numArg(a, args[++i]);
|
|
50
|
+
else if (a === '--wait') wait = numArg(a, args[++i]);
|
|
51
|
+
else if (a.startsWith('--') || /(^|\s)--[a-z]/.test(a)) fail(`모르는 인자 「${a}」 — ${SHELL_HINT}`);
|
|
24
52
|
else pos.push(a);
|
|
25
53
|
}
|
|
26
54
|
const [url, selector, outPng] = pos;
|
|
27
|
-
if (!url || !selector || !outPng
|
|
55
|
+
if (!url || !selector || !outPng || pos.length > 3) fail(pos.length > 3 ? `위치 인자가 ${pos.length}개 — <url> <selector> <out.png> 셋만 받는다` : '위치 인자 셋이 필요함');
|
|
56
|
+
if (after && !hides.length && !hideTexts.length) fail('--after 는 숨길 블록(--hide 또는 --hide-text)과 함께 쓴다');
|
|
28
57
|
|
|
29
58
|
const CANDIDATES = [process.env.PLAYWRIGHT_CORE,
|
|
30
59
|
path.join(os.homedir(), '.claude/skills/gazette-report/scripts/node_modules/playwright-core/index.mjs'),
|
|
@@ -40,7 +69,8 @@ const pg = await ctx.newPage();
|
|
|
40
69
|
await pg.goto(url, { waitUntil: 'networkidle', timeout: 60000 });
|
|
41
70
|
await pg.waitForSelector(selector, { timeout: 60000 });
|
|
42
71
|
await pg.waitForTimeout(wait);
|
|
43
|
-
|
|
72
|
+
if (style) await pg.addStyleTag({ content: style });
|
|
73
|
+
const info = await pg.evaluate(({ selector, badges, masks, unclip, badgeColor, hides, hideTexts }) => {
|
|
44
74
|
if (unclip) {
|
|
45
75
|
for (const el of document.querySelectorAll('*')) { const p = getComputedStyle(el).position; if (p === 'sticky' || p === 'fixed') el.style.setProperty('position', 'static', 'important'); }
|
|
46
76
|
for (const el of document.querySelectorAll('*')) { const cs = getComputedStyle(el); if ((cs.overflowY === 'auto' || cs.overflowY === 'scroll') && el.scrollHeight > el.clientHeight) { el.style.setProperty('overflow', 'visible', 'important'); el.style.setProperty('max-height', 'none', 'important'); el.style.setProperty('height', 'auto', 'important'); } }
|
|
@@ -53,14 +83,52 @@ const info = await pg.evaluate(({ selector, badges, masks, unclip }) => {
|
|
|
53
83
|
for (const bd of badges) {
|
|
54
84
|
const i = bd.lastIndexOf('='); const sel = bd.slice(0, i), label = bd.slice(i + 1);
|
|
55
85
|
const el = root.querySelector(sel); if (!el) { placed.push([label, null]); continue; }
|
|
56
|
-
const r = el.getBoundingClientRect(); el.style.outline =
|
|
57
|
-
const d = document.createElement('div'); d.textContent = label;
|
|
58
|
-
Object.assign(d.style, { position: 'absolute', left: (r.left - R.left - 14) + 'px', top: (r.top - R.top - 14) + 'px', width: '30px', height: '30px', borderRadius: '50%', background:
|
|
86
|
+
const r = el.getBoundingClientRect(); el.style.outline = `3px solid ${badgeColor}`; el.style.outlineOffset = '2px';
|
|
87
|
+
const d = document.createElement('div'); d.textContent = label; d.setAttribute('data-shot-badge', sel);
|
|
88
|
+
Object.assign(d.style, { position: 'absolute', left: (r.left - R.left - 14) + 'px', top: (r.top - R.top - 14) + 'px', width: '30px', height: '30px', borderRadius: '50%', background: badgeColor, color: '#fff', font: '700 18px/30px sans-serif', textAlign: 'center', zIndex: '99', boxShadow: '0 0 0 3px #fff' });
|
|
59
89
|
root.appendChild(d); placed.push([label, Math.round(r.top - R.top)]);
|
|
60
90
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
91
|
+
// 숨길 블록 — 찾기만 하고 표시해 둔다(숨기는 것은 찍는 순서에 따라 뒤에서)
|
|
92
|
+
const hidden = [], missing = [];
|
|
93
|
+
const mark = (el, by) => { const r = el.getBoundingClientRect(); el.setAttribute('data-shot-hide', ''); hidden.push({ by, x: r.left - R.left, y: r.top - R.top, w: r.width, h: r.height }); };
|
|
94
|
+
for (const sel of hides) { const els = [...root.querySelectorAll(sel)]; if (!els.length) missing.push(sel); els.forEach(el => mark(el, sel)); }
|
|
95
|
+
for (const t of hideTexts) {
|
|
96
|
+
const leaf = [...root.querySelectorAll('*')].find(el => el.children.length === 0 && el.textContent.trim() === t);
|
|
97
|
+
if (!leaf) { missing.push(t); continue; }
|
|
98
|
+
let blk = leaf; while (blk.parentElement && blk.parentElement !== root) blk = blk.parentElement;
|
|
99
|
+
mark(blk, `text:${t}`);
|
|
100
|
+
}
|
|
101
|
+
return { w: R.width, h: R.height, masked, placed, hidden, missing };
|
|
102
|
+
}, { selector, badges, masks, unclip, badgeColor, hides, hideTexts });
|
|
103
|
+
if (info.missing.length) { console.error(`숨길 블록을 못 찾음 — ${info.missing.map(s => `「${s}」`).join(' · ')}`); await b.close(); process.exit(2); }
|
|
104
|
+
|
|
105
|
+
const shoot = async (file) => {
|
|
106
|
+
await pg.waitForTimeout(300);
|
|
107
|
+
if (!clip) { await (await pg.$(selector)).screenshot({ path: file }); return; }
|
|
108
|
+
const o = await pg.$eval(selector, el => { const r = el.getBoundingClientRect(); return { x: r.left + scrollX, y: r.top + scrollY }; });
|
|
109
|
+
await pg.screenshot({ path: file, fullPage: true, clip: { x: o.x + clip.x, y: o.y + clip.y, width: clip.w, height: clip.h } });
|
|
110
|
+
};
|
|
111
|
+
const hideMarked = () => pg.evaluate((selector) => {
|
|
112
|
+
const root = document.querySelector(selector);
|
|
113
|
+
for (const el of root.querySelectorAll('[data-shot-hide]')) el.style.setProperty('display', 'none', 'important');
|
|
114
|
+
const R = root.getBoundingClientRect();
|
|
115
|
+
// 배지는 absolute 라 블록이 빠져도 제자리에 남는다 — 대상의 새 자리로 옮기고, 대상이 숨었으면 배지도 숨긴다
|
|
116
|
+
for (const d of root.querySelectorAll('[data-shot-badge]')) {
|
|
117
|
+
const t = root.querySelector(d.getAttribute('data-shot-badge'));
|
|
118
|
+
if (!t || t.closest('[data-shot-hide]')) { d.style.display = 'none'; continue; }
|
|
119
|
+
const r = t.getBoundingClientRect(); d.style.left = (r.left - R.left - 14) + 'px'; d.style.top = (r.top - R.top - 14) + 'px';
|
|
120
|
+
}
|
|
121
|
+
return { w: R.width, h: R.height };
|
|
122
|
+
}, selector);
|
|
123
|
+
|
|
124
|
+
let beforeSize = info, afterSize = null;
|
|
125
|
+
if (after) { await shoot(outPng); afterSize = await hideMarked(); await shoot(after); }
|
|
126
|
+
else { if (info.hidden.length) beforeSize = await hideMarked(); await shoot(outPng); }
|
|
127
|
+
const size = (s) => ({ w: Math.round((clip ? clip.w : s.w) * 100) / 100, h: Math.round((clip ? clip.h : s.h) * 100) / 100 });
|
|
128
|
+
const rel = (r) => ({ ...r, x: r.x - (clip ? clip.x : 0), y: r.y - (clip ? clip.y : 0) });
|
|
129
|
+
// hidden 은 숨기기 전 자리다(--after 가 없으면 찍힌 그림에는 이미 없다)
|
|
130
|
+
const geom = { selector, dpr: scale, clip, before: size(beforeSize), after: afterSize ? size(afterSize) : null, hidden: info.hidden.map(rel) };
|
|
131
|
+
if (geomOut) fs.writeFileSync(geomOut, JSON.stringify(geom, null, 2) + '\n');
|
|
132
|
+
console.log(JSON.stringify({ out: outPng, bytes: fs.statSync(outPng).size, ...(after ? { after, afterBytes: fs.statSync(after).size } : {}),
|
|
133
|
+
w: Math.round(info.w), h: Math.round(info.h), masked: info.masked, placed: info.placed, hidden: geom.hidden.length }));
|
|
66
134
|
await b.close();
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
### 1.1 다섯 칸 — 이것이 본체다
|
|
49
49
|
|
|
50
50
|
건마다 **증상 · 원인 · 조치 · 확인 방법 · 확인 요청** 다섯 칸을 채움.
|
|
51
|
-
관보 렌더러는 이 다섯을 `.sheet__grid` 표의 다섯 행으로 세움(`gazette.py
|
|
51
|
+
관보 렌더러는 이 다섯을 `.sheet__grid` 표의 다섯 행으로 세움(`gazette.py Doc.sheets_block()`).
|
|
52
52
|
|
|
53
53
|
| 칸 | 필드 | 독자 관점의 뜻 | 비면 |
|
|
54
54
|
|---|---|---|---|
|
|
@@ -56,13 +56,13 @@
|
|
|
56
56
|
| 원인 | `cause` | 「왜 그랬나」 — 조치를 신뢰할지 판단할 근거 | 같음 |
|
|
57
57
|
| 조치 | `fix` | 「무엇을 바꿨고 어디까지 확인했나」 — 본문 최장 | 같음 |
|
|
58
58
|
| 확인 방법 | `how_to_check` | **독자가 직접 할 일.** 「이 순서로 열어보면 확인된다」 | 같음 |
|
|
59
|
-
| 확인 요청 | `remaining` |
|
|
59
|
+
| 확인 요청 | `remaining` | **아직 닫히지 않은 것** — 독자가 결정해 줘야 할 일, 그리고 우리 작업이지만 운영에 아직 안 나간 것(출시 전 수정). 대조표의 「미결」 열이 이것으로 갈림 | 그 행이 빠지고 대조표는 「완료」로 표시 |
|
|
60
60
|
|
|
61
61
|
| 규칙 | 근거 | 어기면 |
|
|
62
62
|
|---|---|---|
|
|
63
63
|
| 모든 건에 `how_to_check` 를 채움 | 고객이 자기 화면에서 직접 밟을 수 있는 순서가 없으면 검수 요청이 아니라 통보임 | **렌더러가 조용히 그 행만 빼고 표를 세움.** 전 건 중 하나만 반쪽인 것을 눈으로는 못 찾음 |
|
|
64
|
-
| `remaining` 은
|
|
65
|
-
| 다섯 칸이 다 비면 그 건은 **아예 안 실림** | `if not rows: continue`(`gazette.py
|
|
64
|
+
| `remaining` 은 **아직 닫히지 않은 것만** — ① 우리가 끝낼 수 없어 상대의 판단·확인이 필요한 것 ② 우리 작업이지만 **운영에 아직 안 나간 것**(출시 전 수정). 없으면 비움. 이 칸의 뜻은 여기가 정본임 | 대조표의 「미결/완료」가 독자의 할 일 목록임(`gazette.py Doc.board_block()`). ② 를 비우면 대조표·일감이 **완료로 셈** | 전건에 뭔가 적으면 그 열이 무의미해지고, 안 나간 건을 비우면 문서가 끝났다고 말함. 집행자는 **사람 눈** |
|
|
65
|
+
| 다섯 칸이 다 비면 그 건은 **아예 안 실림** | `if not rows: continue`(`gazette.py Doc.sheets_block()`) | 건수는 세는데 문서에는 없음 |
|
|
66
66
|
|
|
67
67
|
### 1.2 나머지 칸
|
|
68
68
|
|
|
@@ -74,11 +74,11 @@
|
|
|
74
74
|
| `source_url` | 선택 | 고객이 **아는 유일한 외부 참조.** `http(s)` 가 아니면 서버가 거절 |
|
|
75
75
|
| `source_text` | 선택 | 그 링크의 글자. 생략하면 `제보 원본 보기` |
|
|
76
76
|
|
|
77
|
-
`grade` 값이 `grades` 에 없어도 **죽지 않음** — 자리 판별이 실패해 가장 약한 표식으로 떨어짐(`gazette.py
|
|
77
|
+
`grade` 값이 `grades` 에 없어도 **죽지 않음** — 자리 판별이 실패해 가장 약한 표식으로 떨어짐(`gazette.py Doc._grade()`).
|
|
78
78
|
오타가 조용히 넘어가는 자리라 발행 뒤 눈으로 확인해야 함.
|
|
79
79
|
|
|
80
80
|
`group` 이 `groups[].key` 중 하나가 아니면 그 건은 **어디에도 안 실림.**
|
|
81
|
-
렌더러는 ⚠ 로 세고(`gazette.py
|
|
81
|
+
렌더러는 ⚠ 로 세고(`gazette.py Doc.build()`), 서버는 발행을 거절함(`report_publish.php report_data_validate()`).
|
|
82
82
|
|
|
83
83
|
### 1.3 스키마에 **넣지 않는** 것
|
|
84
84
|
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
등급을 **어떻게 매기는가**는 `references/process.md` 3단계가 원전임.
|
|
100
100
|
여기서는 데이터 모양만 적음.
|
|
101
101
|
|
|
102
|
-
`grades` 는 `{이름: [클래스, 설명]}` 이고 **키 선언 순서가 곧 강도 순서**임(`gazette.py
|
|
102
|
+
`grades` 는 `{이름: [클래스, 설명]}` 이고 **키 선언 순서가 곧 강도 순서**임(`gazette.py` 의 `RANK_LVL` 주석 · `Doc._grade()`).
|
|
103
103
|
|
|
104
104
|
| 사실 | 귀결 |
|
|
105
105
|
|---|---|
|
|
@@ -117,15 +117,15 @@
|
|
|
117
117
|
|
|
118
118
|
| 층위 | 형식 | 누가 정하는가 |
|
|
119
119
|
|---|---|---|
|
|
120
|
-
| 절 | `제1절` `제2절` … | **`sections` 배열의 자리.** `ch = '%02d' % i`(`gazette.py
|
|
121
|
-
| 절 앵커 | `#sec-1` … | 같음. `'id': 'sec-%d' % i`(`
|
|
122
|
-
| 건 번호 | `04-1` `05-1` … | 그 절의 자리 + 그룹 안 순번(`
|
|
120
|
+
| 절 | `제1절` `제2절` … | **`sections` 배열의 자리.** `ch = '%02d' % i`(`gazette.py Doc.build()`) |
|
|
121
|
+
| 절 앵커 | `#sec-1` … | 같음. `'id': 'sec-%d' % i`(같은 `Doc.build()`) |
|
|
122
|
+
| 건 번호 | `04-1` `05-1` … | 그 절의 자리 + 그룹 안 순번(같은 `Doc.build()` 의 `anchor`) |
|
|
123
123
|
| 건 앵커 | `#case-04-1` … | 같음 |
|
|
124
124
|
|
|
125
125
|
**⚠ 정정 — 데이터의 `sections[].no` 와 `sections[].id` 는 렌더러가 읽지 않음.**
|
|
126
126
|
실측: `"no": "01"` · `"id": "sec-1"` 만 주고 렌더하면 화면에는 `제1절` 이 찍힘.
|
|
127
127
|
`no` 를 바꿔도 아무 일도 일어나지 않음.
|
|
128
|
-
절 번호를 `01` 로 찍고 싶으면 **`label` 에 적음**(`
|
|
128
|
+
절 번호를 `01` 로 찍고 싶으면 **`label` 에 적음**(같은 `Doc.build()`) — 예시가 그렇게 함.
|
|
129
129
|
데이터의 `id` 로 문서 안 링크를 걸면 **깨짐** — 앵커는 언제나 `sec-{자리}` 임.
|
|
130
130
|
|
|
131
131
|
### 2.2 `groups[].no` 를 절 자리에 맞춘다 ★ 이것이 유일한 정합 규칙
|
|
@@ -134,8 +134,8 @@
|
|
|
134
134
|
|
|
135
135
|
| 어디 | 무엇으로 계산 | 코드 |
|
|
136
136
|
|---|---|---|
|
|
137
|
-
| 렌더러(앵커·본문 표기) | 그 `kind:"cases"` 절이 `sections` 에서 **몇 번째인가** | `gazette.py
|
|
138
|
-
| 서버(요약 근거 게이트) | `groups[].no` | `report_publish.php
|
|
137
|
+
| 렌더러(앵커·본문 표기) | 그 `kind:"cases"` 절이 `sections` 에서 **몇 번째인가** | `gazette.py Doc.build()` |
|
|
138
|
+
| 서버(요약 근거 게이트) | `groups[].no` | `report_publish.php report_data_validate()` |
|
|
139
139
|
|
|
140
140
|
둘이 어긋나면 이렇게 됨.
|
|
141
141
|
|
|
@@ -151,14 +151,14 @@
|
|
|
151
151
|
### 2.3 `§` 마커 — 원고 문법
|
|
152
152
|
|
|
153
153
|
원고에는 `§4.1` 처럼 적음.
|
|
154
|
-
렌더러가 링크로 바꾸고 기호를 버림(`gazette.py
|
|
154
|
+
렌더러가 링크로 바꾸고 기호를 버림(`gazette.py _link()`).
|
|
155
155
|
`§` 는 법령·논문 조판 기호이지 업무 보고서의 활자가 아님.
|
|
156
156
|
|
|
157
157
|
| 단계 | 하는 일 |
|
|
158
158
|
|---|---|
|
|
159
159
|
| 집필 | `§4.1` — 앞자리는 그 절의 자리, 뒷자리는 그룹 안 순번 |
|
|
160
160
|
| 렌더 | `<a class="ref" href="#case-04-1">4-1</a>`. `§` 소멸 |
|
|
161
|
-
| 검사 | 링크로 안 바뀐 `§n.n` 이 남으면 **빌드가 죽음**(`build.py
|
|
161
|
+
| 검사 | 링크로 안 바뀐 `§n.n` 이 남으면 **빌드가 죽음**(`build.py guard()`) |
|
|
162
162
|
|
|
163
163
|
코드 블록 안의 `§3.1` 은 검사에서 제외됨 — 거기서는 그냥 글자임.
|
|
164
164
|
|
|
@@ -174,12 +174,12 @@
|
|
|
174
174
|
|
|
175
175
|
링크는 셋 다 맞게 걸림.
|
|
176
176
|
그런데 **같은 건이 문서 안에서 `4-1` 과 `04-1` 두 이름으로 불림.**
|
|
177
|
-
`_link()` 는 자리수를 안 채우고(`gazette.py
|
|
177
|
+
`_link()` 는 자리수를 안 채우고(`gazette.py _link()`), `ref_text()` 는 채움(`gazette.py Doc.ref_text()`).
|
|
178
178
|
원고에서 고칠 수 있는 것이 아님 — `§04.1` 이라 적어도 결과는 `4-1` 임.
|
|
179
179
|
|
|
180
180
|
> **귀결: 요약과 확인 요청의 근거는 `refs[]` 로 단다.**
|
|
181
181
|
> 본문 안 인라인 `§n.n` 은 문장 흐름상 꼭 필요할 때만 씀.
|
|
182
|
-
> `refs` 는 `§` 를 떼고 `["4.1"]` 로 적어도 되고 `["04-1"]`·`["01절"]` 도 받음(`gazette.py
|
|
182
|
+
> `refs` 는 `§` 를 떼고 `["4.1"]` 로 적어도 되고 `["04-1"]`·`["01절"]` 도 받음(`gazette.py Doc.ref_href()`).
|
|
183
183
|
|
|
184
184
|
---
|
|
185
185
|
|
|
@@ -190,7 +190,7 @@
|
|
|
190
190
|
한글 보고서는 마침표에서 줄을 바꿈.
|
|
191
191
|
문장을 이어 붙인 한글 문단은 기계가 쓴 글처럼 읽힘.
|
|
192
192
|
|
|
193
|
-
렌더러가 마크업을 다 세운 뒤 `sentbreak.py` 를 태워 문장마다 `<span class="sn">` 을 새김(`gazette.py
|
|
193
|
+
렌더러가 마크업을 다 세운 뒤 `sentbreak.py` 를 태워 문장마다 `<span class="sn">` 을 새김(`gazette.py mark_sentences()`).
|
|
194
194
|
`.sn` 은 블록으로 서고 이어지는 줄만 내어씀.
|
|
195
195
|
**낱말은 하나도 바뀌지 않음** — 원고를 고치는 도구가 아님.
|
|
196
196
|
|
|
@@ -240,7 +240,7 @@
|
|
|
240
240
|
|
|
241
241
|
**⚠ 정정 — `(1)(2)` 를 문단 안에 적어도 목록으로 서지 않음.**
|
|
242
242
|
v1 의 `chunk()` 가 하던 일임.
|
|
243
|
-
관보 렌더러는 그 함수를 부르지 않음 — 본문 조각은
|
|
243
|
+
관보 렌더러는 그 함수를 부르지 않음 — 본문 조각은 이스케이프·절 참조 링크·그림 가리킴·인라인 표기만 통과함(`gazette.py Doc.t()`).
|
|
244
244
|
문단 안의 `(1)(2)` 는 그냥 글자로 남음.
|
|
245
245
|
|
|
246
246
|
목록으로 서는 자리는 넷뿐임.
|
|
@@ -263,8 +263,8 @@ v1 의 `chunk()` 가 하던 일임.
|
|
|
263
263
|
|
|
264
264
|
**SKILL.md 와 예시 데이터가 아직 반대로 적고 있는 지점이라 먼저 적음.**
|
|
265
265
|
|
|
266
|
-
`build.py
|
|
267
|
-
그런데 그 코드는 `build_gazette()` 가 돌아간 **뒤**의 옛 경로에 있음(`build
|
|
266
|
+
`build.py build()` 가 치환을 적용함.
|
|
267
|
+
그런데 그 코드는 `build_gazette()` 가 돌아간 **뒤**의 옛 경로에 있음(같은 `build()` 의 `render == 'gazette'` 분기에서 갈림).
|
|
268
268
|
`build_gazette()` 는 `text_fix` 를 한 번도 읽지 않음.
|
|
269
269
|
|
|
270
270
|
실측(예시 데이터에 `{"from":"조회 색인","to":"ZZZTESTZZZ"}` 를 넣고 렌더).
|
|
@@ -275,7 +275,7 @@ ZZZTESTZZZ 0회
|
|
|
275
275
|
```
|
|
276
276
|
|
|
277
277
|
MCP 발행 스키마에도 `text_fix` 칸이 없음(`mcp/genetics.mjs`).
|
|
278
|
-
서버 화이트리스트는 통과시키지만(`report_publish.php
|
|
278
|
+
서버 화이트리스트는 통과시키지만(`report_publish.php` 의 `REPORT_CLIENT_KEYS`) 렌더러가 안 읽으므로 결과는 같음.
|
|
279
279
|
|
|
280
280
|
> **귀결: 고객 언어 치환은 이제 원고 단계에서 사람이 한다.**
|
|
281
281
|
> `cases.json` 에 처음부터 고객 언어로 적음.
|
|
@@ -287,12 +287,12 @@ MCP 발행 스키마에도 `text_fix` 칸이 없음(`mcp/genetics.mjs`).
|
|
|
287
287
|
내부 기록을 그대로 옮기면 걸리는 자리 열 가지임.
|
|
288
288
|
**여기는 기록 자리이므로 전부 개조식으로 적음.**
|
|
289
289
|
요청 자리(`cta`·`open_items`·PART I 간지·판권지 회신 안내)는 이 표가 아니라 SKILL 5a 를 따름 —
|
|
290
|
-
거기서는 하십시오체로
|
|
290
|
+
거기서는 하십시오체로 맺음. 문체 관문은 요청 자리를 빼고 셈(6.1).
|
|
291
291
|
|
|
292
292
|
| # | 유형 | 방향 | 왜 | 예 (내부 → 고객) |
|
|
293
293
|
|---|---|---|---|---|
|
|
294
294
|
| A | 검수 환경·계정 지칭어 | 조직 내부 명칭 → **용도**로 지칭 | 고객에게 「테스트 매장」은 **자기 매장 중 하나**로 읽힘 | `테스트 매장의 테스트 고객에 올려` → `검수용 매장·고객으로 올려` |
|
|
295
|
-
| B | 배포 이벤트 →
|
|
295
|
+
| B | 배포 이벤트 → **반영 단계** 서술 | 「배포했다」·「커밋했다」 → 반영 단계 넷 가운데 하나: 「앱에 반영함 — 시험판 전달 전」 · 「시험판에서 확인함」 · 「운영에 반영됨」 · 「반영 전」. 실기기 확인이 없으면 같은 문장에 「실기기 미확인」을 붙임. ISO 날짜 → 한국어 날짜 | 고객은 릴리스 절차를 모르고 알 필요도 없음. 알아야 할 것은 「지금 내 쪽에 적용돼 있나」뿐. 동사는 원고 밖 상태 표(무엇 · 코드 반영 · 시험판 전달 · 실기기 · 운영, 칸마다 근거)의 확인 수준을 넘지 않게 고름. 판정 규칙은 이용자가 확인할 수 있는 동작으로만 적고 구현 규칙(「기록에 키가 없으면 프로필 키로 판정」)은 싣지 않음. 커밋·브랜치·파일·시험 도구·시험 건수는 상태 표에만 — 원고에 새면 렌더러 ⚠(lim-portal-format §10-1 「개발 식별자」). 확인 수준 동사는 사람 눈 | `2026-08-09 운영 배포를 마쳤고` → `2026년 8월 9일 운영 환경에 반영함` · `브랜치에 커밋, 시험 7/7 통과` → `앱에 반영함 — 시험판 전달 전, 실기기 미확인` |
|
|
296
296
|
| C | 티켓 분리 서술 → **문서 내 절 참조** | 「별도 티켓으로 뺐다」 → 「§5.1 에 정리함」 | 티켓은 고객이 볼 수 없는 곳임. 같은 문서 안의 좌표로 바꾸면 **「빠진 것」이 「다른 데 적힌 것」이 됨** | `별도 티켓으로 분리해 처리함` → `§5.1 에 따로 정리함` |
|
|
297
297
|
| D | 내부 작업 규모 수치 제거 | 코드·테스트 카운트 → **보장 범위** 서술 | 「6곳」은 고객이 검증할 수 없는 수치라 신뢰를 못 만들고 **「왜 6곳뿐?」이라는 불안만** 만듦 | `저장 경로 6곳을 수정함` → `저장이 일어나는 모든 경로를 수정함` |
|
|
298
298
|
| E | 배포 채널 용어 → 사용자 체감 사건 | 스토어 빌드·버전 → 「앱 업데이트」 | 현장 인원의 세계에서 일어나는 사건은 「앱이 업데이트된다」뿐임 | `다음 스토어 빌드부터 적용됨` → `다음 앱 업데이트부터 적용됨` |
|
|
@@ -304,7 +304,7 @@ MCP 발행 스키마에도 `text_fix` 칸이 없음(`mcp/genetics.mjs`).
|
|
|
304
304
|
| K | 배제·무력 서술 → **조건 주어** + 원인·기준·재검토 시점 + 선택지 | 「[누구]는 할 수 없음/제외됨」 → 「[조건]이면 [기준] 때문에 [결과]. [재검토 시점]. 그동안은 [선택지]」 | 사람·조직·지역을 주어로 세운 부정은 배제 통보로 읽힘. 이유·선택지가 붙으면 같은 사실이 판단 근거로 읽힘(lim-portal-format §12.7b) | `[지역명] 은 라이선스에서 제외됨` → `[조건]에 해당하면 [라이선스 조항] 때문에 그 경로는 쓸 수 없음. 조항이 바뀌면 재검토. 그동안은 [대안 경로]` |
|
|
305
305
|
|
|
306
306
|
**경계선 둘.**
|
|
307
|
-
K 는 **우리 근거의 약함을 지우라는 뜻이 아님** — 약한 근거는 등급을 달아 노출함(process.md
|
|
307
|
+
K 는 **우리 근거의 약함을 지우라는 뜻이 아님** — 약한 근거는 등급을 달아 노출함(process.md 「같은 사실이 두 문서로 갈리는 지점」 원리 3). 지우는 것은 「독자의 무력함」 서술임.
|
|
308
308
|
브리핑(§12.3b)에서는 J 보다 K 가 앞섬 — 고르지 않을 길도 지우지 않고, 선택지 표의 「정할 자리」·「다시 볼 때」 행에 조건을 주어로 적음.
|
|
309
309
|
|
|
310
310
|
**경계선 하나.**
|
|
@@ -314,7 +314,7 @@ K 는 **우리 근거의 약함을 지우라는 뜻이 아님** — 약한 근
|
|
|
314
314
|
### 4.3 `forbid` — 로컬 렌더에서만 산다
|
|
315
315
|
|
|
316
316
|
`forbid` 는 산출물 전체 문자열에 거는 금칙어 정규식 목록임.
|
|
317
|
-
`guard()` 가 **파일을 쓰기 전에** 검사하고, 걸리면 빌드가 죽음(`build.py
|
|
317
|
+
`guard()` 가 **파일을 쓰기 전에** 검사하고, 걸리면 빌드가 죽음(`build.py guard()`).
|
|
318
318
|
|
|
319
319
|
실측(예시에 `"forbid": ["조회 색인"]` 을 넣고 렌더).
|
|
320
320
|
|
|
@@ -325,7 +325,7 @@ AssertionError: 금지 표현 유출: 조회 색인 → ['조회 색인']
|
|
|
325
325
|
| 경로 | `forbid` 가 도는가 |
|
|
326
326
|
|---|---|
|
|
327
327
|
| `python3 assets/render.py report.json` | **돈다.** 위 실측 |
|
|
328
|
-
| MCP 발행(`publish_report`·`update_report`) | **안 돈다.** `REPORT_CLIENT_KEYS` 에 없어 서버가 입구에서 깎아 냄(`report_publish.php
|
|
328
|
+
| MCP 발행(`publish_report`·`update_report`) | **안 돈다.** `REPORT_CLIENT_KEYS` 에 없어 서버가 입구에서 깎아 냄(`report_publish.php report_client_fields()`) |
|
|
329
329
|
|
|
330
330
|
> **귀결: 포털 발행만 하는 문서에는 금칙어 관문이 없다.**
|
|
331
331
|
> 그 자리를 메우는 것은 `dry_run` 의 `warnings` 와 사람의 눈임.
|
|
@@ -418,14 +418,15 @@ AssertionError: 금지 표현 유출: 조회 색인 → ['조회 색인']
|
|
|
418
418
|
| **한 문장 한 줄** | 문장 경계가 있는데 `<span class="sn">` 도 `<br>` 도 없는 블록 → **0개** |
|
|
419
419
|
|
|
420
420
|
⚠ **경어체 0건은 기록 자리에만 거는 기준임.**
|
|
421
|
-
요청 절(`cta`·`open_items`)·PART I 간지·판권지 회신 안내는 **하십시오체로 맺어야
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
로컬 `tone.mjs` 는 판단 항목 본문(`p.ask__body`)과 「」 인용을 빼고 셈(2026-09-12) —
|
|
421
|
+
요청 절(`cta`·`open_items`)·PART I 간지·판권지 회신 안내는 **하십시오체로 맺어야 함**(SKILL 5a).
|
|
422
|
+
두 문체 관문이 자리를 가림 — 서버 `report_tone.php report_tone_warnings()`(dry_run)는 `open_items[].body`·`cta`·`colophon.note`·`asks` 절 `marg` 와 「」 인용을 빼고 셈(운영 반영 확인 전 — lim-portal-format §10).
|
|
423
|
+
`open_items[].title` 은 셈(제목은 「~ 요청」·물음이 규칙). 건의 `how_to_check` 에서 걸린 요청문은 「요청 절로 옮김」 처방이 따로 섬.
|
|
424
|
+
로컬 `tone.mjs` 는 판단 항목 본문(`p.ask__body`)과 「」 인용을 빼고 셈(2026-09-12) — `cta`·판권지 회신 안내에서 나는 경고는 로컬에서 사람이 거름(의도).
|
|
425
|
+
두 관문 모두 PART I 간지의 요청 문장은 셈 — 그 한 종류는 의도이고 고치지 않음(통과 기준은 lim-portal-format §10).
|
|
425
426
|
|
|
426
427
|
개조식 `~함 / ~했음 / ~임` 은 **우리·사물이 주어인 기록 자리**의 문체임.
|
|
427
428
|
명사형 종결(`-ㅁ/-음`)은 청자 높임 등급이 없어, 상대에게 말 거는 자리에 쓰면 하대로 읽힘 —
|
|
428
|
-
「부탁드림」도 존대가 아님. 자리를 안 가리고 개조식을 걸어 **네 번 반려됨**(
|
|
429
|
+
「부탁드림」도 존대가 아님. 자리를 안 가리고 개조식을 걸어 **네 번 반려됨**(`references/history.md` §5 표).
|
|
429
430
|
|
|
430
431
|
### 6.2 S2 — 늘어짐 지표 (천 자당 밀도)
|
|
431
432
|
|
|
@@ -491,7 +492,7 @@ AssertionError: 금지 표현 유출: 조회 색인 → ['조회 색인']
|
|
|
491
492
|
## 8. 미확정 · 확인 못 한 것
|
|
492
493
|
|
|
493
494
|
1. **서버가 실제로 422 를 돌려주는지 런타임으로 확인하지 못함.**
|
|
494
|
-
`report_publish.php
|
|
495
|
+
`report_publish.php report_data_validate()` 의 검증 코드는 읽었고, 어긋난 `kind`·없는 그룹·근거 없는 요약이 오류 목록에 담기는 것까지는 코드로 확인함.
|
|
495
496
|
응답 코드 자체는 확인 못 함.
|
|
496
497
|
2. **`dry_run` 의 `warnings` 가 렌더러의 ⚠ 와 같은 목록인지 확인 못 함.**
|
|
497
498
|
로컬 렌더의 ⚠ 는 실측했음.
|