@qualisoft/ai-skills 1.0.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 +21 -0
- package/README.md +234 -0
- package/bin/cli.mjs +331 -0
- package/package.json +53 -0
- package/skills/erd-visual/SKILL.md +294 -0
- package/skills/erd-visual/build.mjs +404 -0
- package/skills/erd-visual/engine/dbml.mjs +133 -0
- package/skills/erd-visual/engine/ingest.mjs +273 -0
- package/skills/erd-visual/engine/layout.mjs +165 -0
- package/skills/erd-visual/engine/overview.mjs +314 -0
- package/skills/erd-visual/engine/render.mjs +144 -0
- package/skills/erd-visual/engine/router.mjs +401 -0
- package/skills/erd-visual/engine/verify.mjs +138 -0
- package/skills/erd-visual/engine/wire.mjs +115 -0
- package/skills/erd-visual/fixtures/crm-large.dbml +1337 -0
- package/skills/erd-visual/fixtures/edge-cases.dbml +43 -0
- package/skills/erd-visual/fixtures/map-dynamics.json +32 -0
- package/skills/erd-visual/fixtures/shop-basic.dbml +78 -0
- package/skills/erd-visual/fixtures/src-json/api.json +38 -0
- package/skills/erd-visual/fixtures/src-prisma/schema.prisma +44 -0
- package/skills/erd-visual/fixtures/src-sql/shop.sql +62 -0
- package/skills/erd-visual/readers/csv.mjs +81 -0
- package/skills/erd-visual/readers/index.mjs +55 -0
- package/skills/erd-visual/readers/jsonschema.mjs +86 -0
- package/skills/erd-visual/readers/prisma.mjs +102 -0
- package/skills/erd-visual/readers/sql.mjs +205 -0
- package/skills/erd-visual/readers/xlsx.mjs +220 -0
- package/skills/erd-visual/readers/xml.mjs +130 -0
- package/skills/erd-visual/readers/zip.mjs +74 -0
- package/skills/erd-visual/viewer/index.html +539 -0
- package/skills/project-build/SKILL.md +171 -0
- package/skills/project-build/templates/build-rules.md +88 -0
- package/skills/project-build/templates/change-log.md +29 -0
- package/skills/project-build/templates/coverage.md +49 -0
- package/skills/project-build/templates/parity.html +375 -0
- package/skills/project-build/templates/theme.css +45 -0
- package/skills/project-design/MEDIUMS.md +104 -0
- package/skills/project-design/QUESTIONS.md +126 -0
- package/skills/project-design/SKILL.md +365 -0
- package/skills/project-design/templates/audit.html +689 -0
- package/skills/project-design/templates/change-log.md +53 -0
- package/skills/project-design/templates/design-rules.md +118 -0
- package/skills/project-design/templates/mockup-app.html +96 -0
- package/skills/project-design/templates/mockup-slides.html +82 -0
- package/skills/project-design/templates/mockup-web.html +45 -0
- package/skills/project-design/templates/styleguide.html +436 -0
- package/skills/project-design/templates/tokens.css +69 -0
- package/skills/project-design/templates/tone-options.html +138 -0
- package/skills/project-init/GUIDE.md +370 -0
- package/skills/project-init/RUNBOOK.md +192 -0
- package/skills/project-init/SKILL.md +382 -0
- package/skills/project-init/build.mjs +2898 -0
- package/skills/project-init/evals/RUBRIC.md +81 -0
- package/skills/project-init/evals/cases/conflicting.expect.json +17 -0
- package/skills/project-init/evals/cases/conflicting.md +9 -0
- package/skills/project-init/evals/cases/vague-idea.expect.json +11 -0
- package/skills/project-init/evals/cases/vague-idea.md +7 -0
- package/skills/project-init/evals/cases/well-formed.expect.json +18 -0
- package/skills/project-init/evals/cases/well-formed.md +28 -0
- package/skills/project-init/markdown.mjs +0 -0
- package/skills/project-init/modules/a11y.json +46 -0
- package/skills/project-init/modules/ai.json +77 -0
- package/skills/project-init/modules/audience.json +48 -0
- package/skills/project-init/modules/backend.json +129 -0
- package/skills/project-init/modules/brand.json +78 -0
- package/skills/project-init/modules/core.json +132 -0
- package/skills/project-init/modules/design.json +72 -0
- package/skills/project-init/modules/engineering.json +86 -0
- package/skills/project-init/modules/mobile.json +22 -0
- package/skills/project-init/modules/ops.json +122 -0
- package/skills/project-init/modules/process.json +104 -0
- package/skills/project-init/modules/product.json +37 -0
- package/skills/project-init/modules/ux.json +52 -0
- package/skills/project-init/modules/web.json +129 -0
- package/skills/project-init/presets/ai-product.json +12 -0
- package/skills/project-init/presets/internal-system.json +15 -0
- package/skills/project-init/presets/mobile-app.json +20 -0
- package/skills/project-init/presets/web-corporate.json +116 -0
- package/skills/project-init/schema.json +80 -0
- package/skills/project-init/templates/log.md +54 -0
- package/skills/project-init/templates/readme.md +62 -0
- package/skills/project-init/templates/reference.md +34 -0
- package/skills/project-init/templates/register.md +63 -0
- package/skills/project-init/templates/spec.md +46 -0
- package/skills/project-interview/INTERVIEW.md +221 -0
- package/skills/project-interview/SKILL.md +156 -0
- package/skills/project-interview/fixtures/brief.md +49 -0
- package/skills/project-interview/fixtures/decisions.md +13 -0
- package/skills/project-interview/fixtures/open-questions.md +9 -0
- package/skills/project-interview/templates/brief.md +179 -0
- package/skills/project-interview/templates/decisions.md +55 -0
- package/skills/project-interview/templates/open-questions.md +40 -0
- package/skills/project-interview/validate.mjs +49 -0
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* erd-visual — DBML 을 시각 ERD HTML 로 만든다.
|
|
3
|
+
*
|
|
4
|
+
* node build.mjs build --dbml=ERD/schema.dbml [--out=ERD/erd.html] [--title="..."]
|
|
5
|
+
* node build.mjs check --dbml=ERD/schema.dbml
|
|
6
|
+
* node build.mjs selftest
|
|
7
|
+
*
|
|
8
|
+
* 엔진(engine/)은 Node 와 브라우저가 같이 쓴다. 두 벌로 나누면 반드시 갈라진다.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import { fileURLToPath } from 'url';
|
|
14
|
+
import { parseDbml } from './engine/dbml.mjs';
|
|
15
|
+
import { layout } from './engine/layout.mjs';
|
|
16
|
+
import { wire } from './engine/wire.mjs';
|
|
17
|
+
import { renderDetail, palette } from './engine/render.mjs';
|
|
18
|
+
import {
|
|
19
|
+
measureOverlap, checkPierce, checkCardOverlap, checkBounds, checkPathBounds,
|
|
20
|
+
checkTextFit, fitText, report,
|
|
21
|
+
} from './engine/verify.mjs';
|
|
22
|
+
import { toDbml, reconcile, reportMd } from './engine/ingest.mjs';
|
|
23
|
+
import { renderOverview, verifyOverview, overviewFit, autoOverview } from './engine/overview.mjs';
|
|
24
|
+
import { detect, READERS } from './readers/index.mjs';
|
|
25
|
+
|
|
26
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const argv = process.argv.slice(2);
|
|
28
|
+
const cmd = argv[0];
|
|
29
|
+
const arg = (k, d) => {
|
|
30
|
+
const m = argv.find(a => a.startsWith('--' + k + '='));
|
|
31
|
+
return m ? m.slice(k.length + 3) : d;
|
|
32
|
+
};
|
|
33
|
+
const die = m => { console.error(m); process.exit(1); };
|
|
34
|
+
|
|
35
|
+
/* ── 모델 준비 ─────────────────────────────────────────── */
|
|
36
|
+
function loadOverview(dbmlPath) {
|
|
37
|
+
const explicit = arg('overview');
|
|
38
|
+
const p = explicit || path.join(path.dirname(dbmlPath), 'overview.json');
|
|
39
|
+
if (!fs.existsSync(p)) {
|
|
40
|
+
if (explicit) die('없는 파일: ' + p);
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
try { return { path: p, spec: JSON.parse(fs.readFileSync(p, 'utf8')) }; }
|
|
44
|
+
catch (e) { die('조감도 명세를 읽지 못했습니다 (' + p + '): ' + e.message); }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 손으로 그린 명세를 쓸지, DBML 에서 자동 생성할지 고른다.
|
|
48
|
+
* 스키마를 바꾸면 옛 명세는 '고칠 N건'이 아니라 해당 없는 그림이다. */
|
|
49
|
+
function pickOverview(dbmlPath, model, lay) {
|
|
50
|
+
const cur = loadOverview(dbmlPath);
|
|
51
|
+
if (cur) {
|
|
52
|
+
const fit = overviewFit(cur.spec, model);
|
|
53
|
+
if (fit.applicable) return { ...cur, fit, auto: false };
|
|
54
|
+
console.log('조감도 명세가 이 스키마와 맞지 않습니다 (' + cur.path + ')');
|
|
55
|
+
console.log(' 노드 ' + fit.total + '개 중 ' + fit.alive + '개만 이 DBML 에 존재합니다.');
|
|
56
|
+
console.log(' 다른 스키마용 그림으로 보고 자동 생성으로 대체합니다.');
|
|
57
|
+
return { path: '(자동 생성)', spec: autoOverview(model, lay), fit, auto: true };
|
|
58
|
+
}
|
|
59
|
+
return { path: '(자동 생성)', spec: autoOverview(model, lay), fit: null, auto: true };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function printOverview(ov, ovc) {
|
|
63
|
+
console.log('조감도: ' + ov.path + (ov.auto ? ' — DBML 에서 자동 생성' : ''));
|
|
64
|
+
console.log(' 노드 ' + (ov.spec.nodes || []).length +
|
|
65
|
+
' · 연결 ' + ovc.edges + ' · 없는연결 ' + ovc.gaps);
|
|
66
|
+
const rows = [
|
|
67
|
+
['근거 없는 연결', ovc.edgeNoEvidence.length],
|
|
68
|
+
['사실과 어긋난 없는연결', ovc.gapContradicted.length],
|
|
69
|
+
['존재하지 않는 테이블', ovc.missingTables.length],
|
|
70
|
+
['정의되지 않은 노드 참조', ovc.badRef.length],
|
|
71
|
+
];
|
|
72
|
+
for (const [k, n] of rows) console.log(' ' + k.padEnd(22) + (n === 0 ? '0' : n + ' ← 실패'));
|
|
73
|
+
ovc.edgeNoEvidence.slice(0, 5).forEach(e =>
|
|
74
|
+
console.log(' 근거없음: ' + e.from + ' → ' + e.to + (e.label ? ' (' + e.label + ')' : '')));
|
|
75
|
+
ovc.gapContradicted.slice(0, 5).forEach(g =>
|
|
76
|
+
console.log(' 실제로는 있음: ' + g.from + ' ↔ ' + g.to + ' ' +
|
|
77
|
+
g.found.map(r => r.from + '.' + r.col + '>' + r.to).join(', ')));
|
|
78
|
+
ovc.missingTables.slice(0, 5).forEach(m =>
|
|
79
|
+
console.log(' 없는 테이블: ' + m.node + ' → ' + m.table));
|
|
80
|
+
console.log(' ' + (ovc.ok ? '조감도 대조 통과' : '조감도 대조 실패'));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function prepare(dbmlPath) {
|
|
84
|
+
if (!fs.existsSync(dbmlPath)) die('없는 파일: ' + dbmlPath);
|
|
85
|
+
const src = fs.readFileSync(dbmlPath, 'utf8');
|
|
86
|
+
const model = parseDbml(src);
|
|
87
|
+
if (!model.tables.size) die('Table 을 하나도 읽지 못했습니다: ' + dbmlPath);
|
|
88
|
+
const lay = layout(model);
|
|
89
|
+
const w = wire(model, lay);
|
|
90
|
+
return { src, model, lay, w };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* ── 검증 ──────────────────────────────────────────────── */
|
|
94
|
+
function verify({ model, lay, w }) {
|
|
95
|
+
const g = lay.geo, INNER = g.CW - 30;
|
|
96
|
+
|
|
97
|
+
// 실패 판정은 '렌더된' 문자열로 한다. 원본을 재면 자르기가 필요했던 것까지
|
|
98
|
+
// 넘침으로 잡혀 거짓 실패가 난다 — 실제로 한 번 그렇게 났다.
|
|
99
|
+
const items = [];
|
|
100
|
+
let truncated = 0;
|
|
101
|
+
for (const [t] of lay.pos) {
|
|
102
|
+
const m = model.tables.get(t);
|
|
103
|
+
const label = fitText(m.label, INNER, 15.5);
|
|
104
|
+
const logical = fitText(t, INNER - 26, 11, 1.0, 0.62);
|
|
105
|
+
if (label !== m.label || logical !== t) truncated++;
|
|
106
|
+
items.push({ key: t, text: label, px: 15.5, maxPx: INNER });
|
|
107
|
+
items.push({ key: t, text: logical, px: 11, asc: 0.62, maxPx: INNER - 26 });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
pierce: checkPierce(w.paths, w.RECT, w.skips).map(i => model.refs[i]),
|
|
112
|
+
bundlePierce: checkPierce(w.bundlePaths, w.bundleRect, w.bundleSkips),
|
|
113
|
+
cardOverlap: checkCardOverlap(w.RECT, w.names),
|
|
114
|
+
bounds: checkBounds(w.RECT, lay.W, lay.H),
|
|
115
|
+
pathBounds: checkPathBounds(w.paths, lay.W, lay.H),
|
|
116
|
+
bundleBounds: checkPathBounds(w.bundlePaths, lay.W, lay.H),
|
|
117
|
+
textFit: checkTextFit(items),
|
|
118
|
+
truncated,
|
|
119
|
+
overlap: measureOverlap(w.paths),
|
|
120
|
+
bundleOverlap: measureOverlap(w.bundlePaths),
|
|
121
|
+
routeFail: w.fail,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* ── 엔진을 브라우저용으로 인라인 ───────────────────────── */
|
|
126
|
+
const ENGINE_ORDER = ['router.mjs', 'verify.mjs', 'dbml.mjs', 'layout.mjs', 'wire.mjs',
|
|
127
|
+
'render.mjs', 'overview.mjs'];
|
|
128
|
+
// ingest.mjs 만 Node 전용이다 (파일을 읽는다).
|
|
129
|
+
// overview.mjs 는 뷰어에도 싣는다 — DBML 을 고치면 조감도의 근거도 다시 대조해야 한다.
|
|
130
|
+
// 배치는 사람이 정한 것이라 그대로 두고, '근거가 맞는지'만 다시 판정한다.
|
|
131
|
+
function inlineEngine() {
|
|
132
|
+
return ENGINE_ORDER.map(f => {
|
|
133
|
+
let s = fs.readFileSync(path.join(HERE, 'engine', f), 'utf8');
|
|
134
|
+
s = s.replace(/^\s*import[\s\S]*?from\s*['"][^'"]+['"];?\s*$/gm, '');
|
|
135
|
+
s = s.replace(/^export\s+/gm, '');
|
|
136
|
+
return '/* ===== engine/' + f + ' ===== */\n' + s;
|
|
137
|
+
}).join('\n');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/* ── build ─────────────────────────────────────────────── */
|
|
141
|
+
function cmdBuild() {
|
|
142
|
+
const dbmlPath = arg('dbml') || die('--dbml=<경로> 가 필요합니다');
|
|
143
|
+
const out = arg('out') || path.join(path.dirname(dbmlPath), 'erd.html');
|
|
144
|
+
const ctx = prepare(dbmlPath);
|
|
145
|
+
const { model, lay, w, src } = ctx;
|
|
146
|
+
|
|
147
|
+
const title = arg('title') || path.basename(dbmlPath, '.dbml') + ' — ERD';
|
|
148
|
+
const subtitle = '테이블 ' + model.tables.size + ' · 관계 ' + model.refs.length +
|
|
149
|
+
' · 그룹 ' + lay.order.length + ' · 직각 배선';
|
|
150
|
+
|
|
151
|
+
const svg = renderDetail(model, lay, w, { title, subtitle, palette: palette(lay.order) });
|
|
152
|
+
const v = verify(ctx);
|
|
153
|
+
const rep = report(v);
|
|
154
|
+
|
|
155
|
+
const ov = pickOverview(dbmlPath, model, lay);
|
|
156
|
+
const ovc = verifyOverview(ov.spec, model);
|
|
157
|
+
const ovSvg = renderOverview(ov.spec, model);
|
|
158
|
+
|
|
159
|
+
const tpl = fs.readFileSync(path.join(HERE, 'viewer', 'index.html'), 'utf8');
|
|
160
|
+
const meta = {
|
|
161
|
+
title,
|
|
162
|
+
tables: model.tables.size,
|
|
163
|
+
refs: model.refs.length,
|
|
164
|
+
groups: lay.order.length,
|
|
165
|
+
biz: w.isSysRef.filter(x => !x).length,
|
|
166
|
+
sys: w.isSysRef.filter(x => x).length,
|
|
167
|
+
bundles: w.bundleKeys.length,
|
|
168
|
+
order: lay.order,
|
|
169
|
+
palette: Object.fromEntries(lay.order.map((d, i) => [d, palette(lay.order).get(d)])),
|
|
170
|
+
check: rep,
|
|
171
|
+
hasOverview: true,
|
|
172
|
+
overviewAuto: ov.auto,
|
|
173
|
+
overviewCheck: ovc,
|
|
174
|
+
};
|
|
175
|
+
const nbr = {};
|
|
176
|
+
for (const r of model.refs) {
|
|
177
|
+
(nbr[r.from] ||= []).push({ o: r.to, col: r.col, dir: 'out' });
|
|
178
|
+
(nbr[r.to] ||= []).push({ o: r.from, col: r.col, dir: 'in' });
|
|
179
|
+
}
|
|
180
|
+
const tmeta = {};
|
|
181
|
+
for (const [t, p] of lay.pos) {
|
|
182
|
+
const m = model.tables.get(t);
|
|
183
|
+
tmeta[t] = { ko: m.label, dom: p.dom, total: m.total, stub: m.stub, custom: m.custom };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const html = tpl
|
|
187
|
+
.replace('/*__ENGINE__*/', () => inlineEngine())
|
|
188
|
+
.replace('__DBML__', () => JSON.stringify(src))
|
|
189
|
+
.replace('__SVG__', () => svg)
|
|
190
|
+
.replace('__OVSVG__', () => ovSvg)
|
|
191
|
+
.replace('__OVSPEC__', () => JSON.stringify(ov.auto ? null : ov.spec))
|
|
192
|
+
.replace('__META__', () => JSON.stringify(meta))
|
|
193
|
+
.replace('__NBR__', () => JSON.stringify(nbr))
|
|
194
|
+
.replace('__TMETA__', () => JSON.stringify(tmeta));
|
|
195
|
+
|
|
196
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
197
|
+
fs.writeFileSync(out, html);
|
|
198
|
+
const svgOut = out.replace(/\.html$/, '.svg');
|
|
199
|
+
fs.writeFileSync(svgOut, svg);
|
|
200
|
+
fs.writeFileSync(out.replace(/\.html$/, '-overview.svg'), ovSvg);
|
|
201
|
+
|
|
202
|
+
console.log('생성됨: ' + out + ' (' + (fs.statSync(out).size / 1024).toFixed(0) + ' KB)');
|
|
203
|
+
console.log(' ' + svgOut + ' (Figma import 용)');
|
|
204
|
+
console.log('테이블 ' + model.tables.size + ' · 관계 ' + model.refs.length +
|
|
205
|
+
' · 번들 ' + w.bundleKeys.length + ' · 캔버스 ' + lay.W + 'x' + lay.H);
|
|
206
|
+
printCheck(v, rep);
|
|
207
|
+
console.log(''); printOverview(ov, ovc);
|
|
208
|
+
if (!rep.ok || !ovc.ok) process.exitCode = 1;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function printCheck(v, rep) {
|
|
212
|
+
for (const [k, n] of rep.must) console.log(' ' + k.padEnd(12) + (n === 0 ? '0' : n + ' ← 실패'));
|
|
213
|
+
console.log(' ' + '겹침'.padEnd(12) + v.overlap.overlapPairs + '쌍 / ' +
|
|
214
|
+
v.overlap.overlapLen + 'px (구간 ' + v.overlap.segments + ')');
|
|
215
|
+
console.log(' ' + '번들 겹침'.padEnd(11) + v.bundleOverlap.overlapPairs + '쌍 / ' +
|
|
216
|
+
v.bundleOverlap.overlapLen + 'px');
|
|
217
|
+
console.log(' ' + '이름 잘림'.padEnd(11) + v.truncated + '개 (카드 폭에 맞춰 … 로 자름 · 전체 이름은 툴팁)');
|
|
218
|
+
if (v.routeFail) console.log(' 배선 실패 ' + v.routeFail + '건');
|
|
219
|
+
console.log(rep.ok ? '검증 통과' : '검증 실패 — ' + rep.failed.map(f => f[0]).join(', '));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/* ── check ─────────────────────────────────────────────── */
|
|
223
|
+
function cmdCheck() {
|
|
224
|
+
const dbmlPath = arg('dbml') || die('--dbml=<경로> 가 필요합니다');
|
|
225
|
+
const ctx = prepare(dbmlPath);
|
|
226
|
+
if (ctx.model.warnings.length) {
|
|
227
|
+
console.log('DBML 경고 ' + ctx.model.warnings.length + '건:');
|
|
228
|
+
ctx.model.warnings.slice(0, 10).forEach(x => console.log(' · ' + x));
|
|
229
|
+
}
|
|
230
|
+
const v = verify(ctx);
|
|
231
|
+
const rep = report(v);
|
|
232
|
+
console.log('테이블 ' + ctx.model.tables.size + ' · 관계 ' + ctx.model.refs.length);
|
|
233
|
+
printCheck(v, rep);
|
|
234
|
+
if (v.pierce.length)
|
|
235
|
+
v.pierce.slice(0, 5).forEach(r => console.log(' 관통: ' + r.from + '.' + r.col + ' -> ' + r.to));
|
|
236
|
+
if (v.textFit.length)
|
|
237
|
+
v.textFit.slice(0, 5).forEach(t => console.log(' 넘침: ' + t.key + ' "' + t.text + '" +' + t.over + 'px'));
|
|
238
|
+
v.pathBounds.slice(0, 3).forEach(b =>
|
|
239
|
+
console.log(' 배선 이탈: ' + ctx.model.refs[b.i].from + ' -> ' + ctx.model.refs[b.i].to +
|
|
240
|
+
' @ ' + b.at.join(',')));
|
|
241
|
+
v.bundleBounds.slice(0, 3).forEach(b =>
|
|
242
|
+
console.log(' 번들 이탈: ' + w0(ctx).bundleKeys[b.i].join(' -> ') + ' @ ' + b.at.join(',')));
|
|
243
|
+
|
|
244
|
+
const ov = pickOverview(dbmlPath, ctx.model, ctx.lay);
|
|
245
|
+
const ovc = verifyOverview(ov.spec, ctx.model);
|
|
246
|
+
console.log('');
|
|
247
|
+
printOverview(ov, ovc);
|
|
248
|
+
if (!rep.ok || !ovc.ok) process.exitCode = 1;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const w0 = ctx => ctx.w;
|
|
252
|
+
|
|
253
|
+
/* ── selftest ──────────────────────────────────────────── */
|
|
254
|
+
function cmdSelftest() {
|
|
255
|
+
const dir = path.join(HERE, 'fixtures');
|
|
256
|
+
const files = fs.existsSync(dir) ? fs.readdirSync(dir).filter(f => f.endsWith('.dbml')) : [];
|
|
257
|
+
if (!files.length) { console.log('fixtures 없음 — 건너뜀'); return; }
|
|
258
|
+
let bad = 0;
|
|
259
|
+
for (const f of files) {
|
|
260
|
+
const ctx = prepare(path.join(dir, f));
|
|
261
|
+
const rep = report(verify(ctx));
|
|
262
|
+
console.log((rep.ok ? 'PASS ' : 'FAIL ') + f +
|
|
263
|
+
' (테이블 ' + ctx.model.tables.size + ' · 관계 ' + ctx.model.refs.length + ')');
|
|
264
|
+
if (!rep.ok) { bad++; rep.failed.forEach(x => console.log(' ' + x[0] + ' ' + x[1])); }
|
|
265
|
+
}
|
|
266
|
+
console.log(bad ? bad + '건 실패' : files.length + '건 전부 통과');
|
|
267
|
+
if (bad) process.exitCode = 1;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/* ── ingest ────────────────────────────────────────────── */
|
|
271
|
+
function collectFiles(src) {
|
|
272
|
+
const st = fs.statSync(src);
|
|
273
|
+
if (st.isFile()) return [src];
|
|
274
|
+
const out = [];
|
|
275
|
+
for (const e of fs.readdirSync(src, { withFileTypes: true })) {
|
|
276
|
+
const p = path.join(src, e.name);
|
|
277
|
+
if (e.isDirectory()) out.push(...collectFiles(p));
|
|
278
|
+
else out.push(p);
|
|
279
|
+
}
|
|
280
|
+
return out.sort();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function readAll(src, map) {
|
|
284
|
+
const files = collectFiles(src);
|
|
285
|
+
const irs = [], skipped = [];
|
|
286
|
+
for (const f of files) {
|
|
287
|
+
const r = detect(f);
|
|
288
|
+
if (!r) { skipped.push(f); continue; }
|
|
289
|
+
const rel = path.relative(process.cwd(), f);
|
|
290
|
+
const label = rel.startsWith('..') ? path.basename(f) : rel;
|
|
291
|
+
const data = r.binary ? fs.readFileSync(f) : fs.readFileSync(f, 'utf8');
|
|
292
|
+
irs.push(r.read(data, label, map ? { map: map[label] || map[path.basename(f)] || map } : {}));
|
|
293
|
+
}
|
|
294
|
+
return { irs, skipped };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/* 구조만 보고한다. 스스로 스키마를 만들지 않는다 —
|
|
298
|
+
어느 열이 무엇인지는 자료마다 다르고, 그건 판단의 영역이다. */
|
|
299
|
+
function cmdInspect() {
|
|
300
|
+
const src = arg('src') || die('--src=<파일 또는 폴더> 가 필요합니다');
|
|
301
|
+
if (!fs.existsSync(src)) die('없는 경로: ' + src);
|
|
302
|
+
const { irs, skipped } = readAll(src, null);
|
|
303
|
+
if (!irs.length) {
|
|
304
|
+
console.log('읽을 수 있는 파일이 없습니다. 지원: ' + READERS.flatMap(r => r.ext).join(' '));
|
|
305
|
+
skipped.slice(0, 10).forEach(f => console.log(' 건너뜀 · ' + path.basename(f)));
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
for (const ir of irs) {
|
|
309
|
+
console.log('');
|
|
310
|
+
console.log('■ ' + ir.source + ' [' + ir.format + ']');
|
|
311
|
+
if (ir.tables.length) {
|
|
312
|
+
console.log(' 테이블 ' + ir.tables.length + ' · 관계 ' + ir.refs.length + ' (근거가 명시된 형식)');
|
|
313
|
+
ir.tables.slice(0, 12).forEach(t =>
|
|
314
|
+
console.log(' · ' + t.name + ' 컬럼 ' + t.columns.length + (t.group ? ' [' + t.group + ']' : '')));
|
|
315
|
+
if (ir.tables.length > 12) console.log(' … 외 ' + (ir.tables.length - 12) + '개');
|
|
316
|
+
}
|
|
317
|
+
for (const sh of ir.sheets || []) {
|
|
318
|
+
console.log(' 시트 "' + sh.name + '" 데이터 ' + sh.dataRows + '행 · 열 ' + sh.columns.length + '개');
|
|
319
|
+
sh.columns.filter(c => c.header).forEach(c =>
|
|
320
|
+
console.log(' ' + c.col.padEnd(4) + c.header));
|
|
321
|
+
if (sh.sample.length) {
|
|
322
|
+
const first = sh.sample[0];
|
|
323
|
+
const shown = sh.columns.filter(c => c.header).slice(0, 4)
|
|
324
|
+
.map(c => c.col + '=' + String(first[c.col] || '').slice(0, 28));
|
|
325
|
+
console.log(' 예시행: ' + shown.join(' | '));
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
ir.warnings.forEach(w => console.log(' ⚠ ' + w));
|
|
329
|
+
}
|
|
330
|
+
console.log('');
|
|
331
|
+
if (irs.some(ir => ir.needsMapping)) {
|
|
332
|
+
console.log('매핑이 필요한 형식이 있습니다 — --map=map.json 으로 어느 열/요소가 무엇인지 알려주세요.');
|
|
333
|
+
console.log(' xlsx·csv 규격: readers/xlsx.mjs 상단 주석 · xml 규격: readers/xml.mjs 상단 주석');
|
|
334
|
+
console.log(' 실례: fixtures/map-dynamics.json');
|
|
335
|
+
} else {
|
|
336
|
+
console.log('매핑 없이 바로 흡수할 수 있습니다: node build.mjs ingest --src=<경로>');
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function cmdIngest() {
|
|
341
|
+
const src = arg('src') || die('--src=<파일 또는 폴더> 가 필요합니다');
|
|
342
|
+
if (!fs.existsSync(src)) die('없는 경로: ' + src);
|
|
343
|
+
const outDir = arg('out') || 'ERD';
|
|
344
|
+
const project = arg('project') || path.basename(path.resolve(src)).replace(/^_+/, '');
|
|
345
|
+
const infer = argv.includes('--infer');
|
|
346
|
+
|
|
347
|
+
const mapPath = arg('map');
|
|
348
|
+
const map = mapPath ? JSON.parse(fs.readFileSync(mapPath, 'utf8')) : null;
|
|
349
|
+
|
|
350
|
+
const { irs, skipped } = readAll(src, map);
|
|
351
|
+
|
|
352
|
+
if (!irs.length) {
|
|
353
|
+
console.error('읽을 수 있는 파일이 없습니다.');
|
|
354
|
+
console.error('지원 형식: ' + READERS.flatMap(r => r.ext).join(' '));
|
|
355
|
+
if (skipped.length) {
|
|
356
|
+
console.error('건너뛴 파일 ' + skipped.length + '개:');
|
|
357
|
+
skipped.slice(0, 10).forEach(f => console.error(' · ' + path.basename(f)));
|
|
358
|
+
console.error('아직 판독기가 없는 형식입니다. 스킬에 맡기면 수동으로 분석합니다.');
|
|
359
|
+
}
|
|
360
|
+
process.exit(1);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const result = toDbml(irs, { infer, project });
|
|
364
|
+
const rec = reconcile(irs, result);
|
|
365
|
+
|
|
366
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
367
|
+
const dbmlPath = path.join(outDir, 'schema.dbml');
|
|
368
|
+
fs.writeFileSync(dbmlPath, result.dbml);
|
|
369
|
+
fs.writeFileSync(path.join(outDir, 'report.md'), reportMd(irs, result, rec, { project }));
|
|
370
|
+
|
|
371
|
+
console.log('읽음: ' + irs.map(i => i.source + ' (' + i.format + ')').join(', '));
|
|
372
|
+
if (skipped.length) console.log('건너뜀: ' + skipped.length + '개 — 판독기 없는 형식');
|
|
373
|
+
console.log('생성됨: ' + dbmlPath);
|
|
374
|
+
console.log(' ' + path.join(outDir, 'report.md'));
|
|
375
|
+
console.log('테이블 ' + result.tables.size + ' · 관계 ' + result.refs.length +
|
|
376
|
+
' · 그룹 ' + result.groups.size + (result.stubs.length ? ' · 스텁 ' + result.stubs.length : ''));
|
|
377
|
+
console.log('');
|
|
378
|
+
console.log('1:1 대조 — 원본 사실 ' + rec.sourceFacts + ' · 내보낸 Ref ' + rec.emitted);
|
|
379
|
+
console.log(' 빠짐 ' + rec.missing.length + ' · 창작 ' + rec.invented.length +
|
|
380
|
+
' · 근거없음 ' + rec.noEvidence.length);
|
|
381
|
+
if (rec.droppedEstimates)
|
|
382
|
+
console.log(' 추정이라 제외 ' + rec.droppedEstimates + '건 (report.md 확인 목록 참조)');
|
|
383
|
+
if (result.warnings.length) console.log('경고 ' + result.warnings.length + '건 — report.md 참조');
|
|
384
|
+
console.log(rec.ok ? '대조 통과' : '대조 실패 — report.md 를 보세요');
|
|
385
|
+
|
|
386
|
+
if (!rec.ok) { process.exitCode = 1; return; }
|
|
387
|
+
console.log('');
|
|
388
|
+
console.log('다음: node build.mjs build --dbml=' + dbmlPath);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
switch (cmd) {
|
|
392
|
+
case 'inspect': cmdInspect(); break;
|
|
393
|
+
case 'ingest': cmdIngest(); break;
|
|
394
|
+
case 'build': cmdBuild(); break;
|
|
395
|
+
case 'check': cmdCheck(); break;
|
|
396
|
+
case 'selftest': cmdSelftest(); break;
|
|
397
|
+
default:
|
|
398
|
+
console.log('사용: inspect | ingest | build | check | selftest');
|
|
399
|
+
console.log(' node build.mjs inspect --src=_자료 구조만 보고');
|
|
400
|
+
console.log(' node build.mjs ingest --src=_자료 [--map=map.json] [--out=ERD] [--project=이름] [--infer]');
|
|
401
|
+
console.log(' node build.mjs build --dbml=ERD/schema.dbml [--out=...] [--title="..."] [--overview=overview.json]');
|
|
402
|
+
console.log(' node build.mjs check --dbml=ERD/schema.dbml');
|
|
403
|
+
console.log(' node build.mjs selftest');
|
|
404
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/* DBML 파서 · 직렬화 — Node 와 브라우저가 같이 쓴다.
|
|
2
|
+
범용 파서가 아니다. 이 체계가 만드는 문법만 읽는다. */
|
|
3
|
+
|
|
4
|
+
const reTable = /^Table\s+("?)([^"\s{]+)\1\s*\{([\s\S]*?)^\}/gm;
|
|
5
|
+
const reGroup = /^TableGroup\s+("?)([^"{]+?)\1\s*\{([\s\S]*?)^\}/gm;
|
|
6
|
+
const reRef = /^Ref:\s*(\S+)\.(\S+)\s*([<>-])\s*(\S+)\.(\S+)\s*$/gm;
|
|
7
|
+
|
|
8
|
+
function unq(s) { return (s || '').trim().replace(/^"(.*)"$/, '$1'); }
|
|
9
|
+
|
|
10
|
+
/** Note: '...' 를 꺼낸다. 값 안의 \' 는 살린다. */
|
|
11
|
+
function pickNote(body) {
|
|
12
|
+
const m = body.match(/^\s*Note:\s*'((?:[^'\\]|\\.)*)'/m);
|
|
13
|
+
return m ? m[1].replace(/\\'/g, "'") : '';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function parseColumns(body) {
|
|
17
|
+
const out = [];
|
|
18
|
+
for (const raw of body.split('\n')) {
|
|
19
|
+
const line = raw.trim();
|
|
20
|
+
if (!line || line.startsWith('//') || /^Note:/.test(line)) continue;
|
|
21
|
+
// name type [settings]
|
|
22
|
+
const m = line.match(/^([A-Za-z_][\w]*)\s+([^\[]+?)(?:\s*\[([\s\S]*)\])?$/);
|
|
23
|
+
if (!m) continue;
|
|
24
|
+
const settings = m[3] || '';
|
|
25
|
+
out.push({
|
|
26
|
+
name: m[1],
|
|
27
|
+
type: m[2].trim(),
|
|
28
|
+
pk: /\bpk\b/.test(settings),
|
|
29
|
+
notNull: /not null/.test(settings),
|
|
30
|
+
note: (settings.match(/note:\s*'((?:[^'\\]|\\.)*)'/) || [, ''])[1].replace(/\\'/g, "'"),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 열린 채 닫히지 않은 블록을 줄 번호와 함께 찾는다.
|
|
37
|
+
* 편집기에서 "실패"만 뜨고 어디가 문제인지 모르면 고칠 수가 없다. */
|
|
38
|
+
export function findBlockErrors(src) {
|
|
39
|
+
const lines = src.split('\n');
|
|
40
|
+
const errs = [];
|
|
41
|
+
let open = null, depth = 0;
|
|
42
|
+
lines.forEach((raw, i) => {
|
|
43
|
+
const line = raw.replace(/\/\/.*$/, '');
|
|
44
|
+
const head = line.match(/^\s*(Table|TableGroup|Enum)\b/);
|
|
45
|
+
if (head && depth === 0 && line.includes('{')) { open = { kw: head[1], line: i + 1 }; depth = 1; return; }
|
|
46
|
+
if (!open) {
|
|
47
|
+
if (/^\s*\}/.test(line)) errs.push({ line: i + 1, msg: '짝이 없는 닫는 괄호' });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
for (const ch of line) { if (ch === '{') depth++; else if (ch === '}') depth--; }
|
|
51
|
+
if (depth <= 0) { open = null; depth = 0; }
|
|
52
|
+
});
|
|
53
|
+
if (open) errs.push({ line: open.line, msg: open.kw + ' 블록이 닫히지 않았습니다' });
|
|
54
|
+
return errs;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function parseDbml(src) {
|
|
58
|
+
const tables = new Map(), groupOf = new Map(), groups = [];
|
|
59
|
+
const refs = [], warnings = [];
|
|
60
|
+
for (const e of findBlockErrors(src)) warnings.push(e.line + '행: ' + e.msg);
|
|
61
|
+
|
|
62
|
+
let m;
|
|
63
|
+
reTable.lastIndex = 0;
|
|
64
|
+
while ((m = reTable.exec(src))) {
|
|
65
|
+
const name = m[2], body = m[3];
|
|
66
|
+
if (tables.has(name)) { warnings.push(`중복 Table: ${name}`); continue; }
|
|
67
|
+
const note = pickNote(body);
|
|
68
|
+
const stub = /^문서 밖/.test(note);
|
|
69
|
+
const tot = note.match(/총 속성 (\d+)개/);
|
|
70
|
+
tables.set(name, {
|
|
71
|
+
name,
|
|
72
|
+
label: stub ? name : (note ? note.split(' (')[0] : name),
|
|
73
|
+
note,
|
|
74
|
+
stub,
|
|
75
|
+
custom: /커스텀/.test(note),
|
|
76
|
+
activity: /·활동/.test(note),
|
|
77
|
+
total: tot ? +tot[1] : 0,
|
|
78
|
+
columns: parseColumns(body),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
reGroup.lastIndex = 0;
|
|
83
|
+
while ((m = reGroup.exec(src))) {
|
|
84
|
+
const gname = unq(m[2]), members = [];
|
|
85
|
+
for (const raw of m[3].split('\n')) {
|
|
86
|
+
const t = raw.trim();
|
|
87
|
+
if (!t || t.startsWith('//') || /^Note:/.test(t)) continue;
|
|
88
|
+
if (/^[A-Za-z_][\w]*$/.test(t)) { members.push(t); groupOf.set(t, gname); }
|
|
89
|
+
}
|
|
90
|
+
groups.push({ name: gname, members });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
reRef.lastIndex = 0;
|
|
94
|
+
while ((m = reRef.exec(src))) {
|
|
95
|
+
let [, a, ac, dir, b, bc] = m;
|
|
96
|
+
if (dir === '<') { [a, ac, b, bc] = [b, bc, a, ac]; } // 방향 정규화: 항상 다 → 일
|
|
97
|
+
if (!tables.has(a)) { warnings.push(`Ref 출발 테이블 없음: ${a}`); continue; }
|
|
98
|
+
if (!tables.has(b)) { warnings.push(`Ref 대상 테이블 없음: ${b}`); continue; }
|
|
99
|
+
refs.push({ from: a, col: ac, to: b, toCol: bc });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for (const r of refs) {
|
|
103
|
+
const t = tables.get(r.from);
|
|
104
|
+
if (t.columns.length && !t.columns.some(c => c.name === r.col))
|
|
105
|
+
warnings.push(`Ref 출발 컬럼 없음: ${r.from}.${r.col}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { tables, groups, groupOf, refs, warnings };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 파싱 결과를 다시 DBML 로. 뷰어에서 편집분을 내보낼 때 쓴다. */
|
|
112
|
+
export function toDbml(model) {
|
|
113
|
+
const L = [];
|
|
114
|
+
for (const t of model.tables.values()) {
|
|
115
|
+
L.push(`Table ${t.name} {`);
|
|
116
|
+
for (const c of t.columns) {
|
|
117
|
+
const s = [];
|
|
118
|
+
if (c.pk) s.push('pk');
|
|
119
|
+
if (c.notNull) s.push('not null');
|
|
120
|
+
if (c.note) s.push(`note: '${c.note.replace(/'/g, "\\'")}'`);
|
|
121
|
+
L.push(` ${c.name} ${c.type}${s.length ? ` [${s.join(', ')}]` : ''}`);
|
|
122
|
+
}
|
|
123
|
+
if (t.note) L.push(` Note: '${t.note.replace(/'/g, "\\'")}'`);
|
|
124
|
+
L.push('}', '');
|
|
125
|
+
}
|
|
126
|
+
for (const g of model.groups) {
|
|
127
|
+
L.push(`TableGroup "${g.name}" {`);
|
|
128
|
+
for (const x of g.members) L.push(` ${x}`);
|
|
129
|
+
L.push('}', '');
|
|
130
|
+
}
|
|
131
|
+
for (const r of model.refs) L.push(`Ref: ${r.from}.${r.col} > ${r.to}.${r.toCol}`);
|
|
132
|
+
return L.join('\n') + '\n';
|
|
133
|
+
}
|