@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.
Files changed (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +234 -0
  3. package/bin/cli.mjs +331 -0
  4. package/package.json +53 -0
  5. package/skills/erd-visual/SKILL.md +294 -0
  6. package/skills/erd-visual/build.mjs +404 -0
  7. package/skills/erd-visual/engine/dbml.mjs +133 -0
  8. package/skills/erd-visual/engine/ingest.mjs +273 -0
  9. package/skills/erd-visual/engine/layout.mjs +165 -0
  10. package/skills/erd-visual/engine/overview.mjs +314 -0
  11. package/skills/erd-visual/engine/render.mjs +144 -0
  12. package/skills/erd-visual/engine/router.mjs +401 -0
  13. package/skills/erd-visual/engine/verify.mjs +138 -0
  14. package/skills/erd-visual/engine/wire.mjs +115 -0
  15. package/skills/erd-visual/fixtures/crm-large.dbml +1337 -0
  16. package/skills/erd-visual/fixtures/edge-cases.dbml +43 -0
  17. package/skills/erd-visual/fixtures/map-dynamics.json +32 -0
  18. package/skills/erd-visual/fixtures/shop-basic.dbml +78 -0
  19. package/skills/erd-visual/fixtures/src-json/api.json +38 -0
  20. package/skills/erd-visual/fixtures/src-prisma/schema.prisma +44 -0
  21. package/skills/erd-visual/fixtures/src-sql/shop.sql +62 -0
  22. package/skills/erd-visual/readers/csv.mjs +81 -0
  23. package/skills/erd-visual/readers/index.mjs +55 -0
  24. package/skills/erd-visual/readers/jsonschema.mjs +86 -0
  25. package/skills/erd-visual/readers/prisma.mjs +102 -0
  26. package/skills/erd-visual/readers/sql.mjs +205 -0
  27. package/skills/erd-visual/readers/xlsx.mjs +220 -0
  28. package/skills/erd-visual/readers/xml.mjs +130 -0
  29. package/skills/erd-visual/readers/zip.mjs +74 -0
  30. package/skills/erd-visual/viewer/index.html +539 -0
  31. package/skills/project-build/SKILL.md +171 -0
  32. package/skills/project-build/templates/build-rules.md +88 -0
  33. package/skills/project-build/templates/change-log.md +29 -0
  34. package/skills/project-build/templates/coverage.md +49 -0
  35. package/skills/project-build/templates/parity.html +375 -0
  36. package/skills/project-build/templates/theme.css +45 -0
  37. package/skills/project-design/MEDIUMS.md +104 -0
  38. package/skills/project-design/QUESTIONS.md +126 -0
  39. package/skills/project-design/SKILL.md +365 -0
  40. package/skills/project-design/templates/audit.html +689 -0
  41. package/skills/project-design/templates/change-log.md +53 -0
  42. package/skills/project-design/templates/design-rules.md +118 -0
  43. package/skills/project-design/templates/mockup-app.html +96 -0
  44. package/skills/project-design/templates/mockup-slides.html +82 -0
  45. package/skills/project-design/templates/mockup-web.html +45 -0
  46. package/skills/project-design/templates/styleguide.html +436 -0
  47. package/skills/project-design/templates/tokens.css +69 -0
  48. package/skills/project-design/templates/tone-options.html +138 -0
  49. package/skills/project-init/GUIDE.md +370 -0
  50. package/skills/project-init/RUNBOOK.md +192 -0
  51. package/skills/project-init/SKILL.md +382 -0
  52. package/skills/project-init/build.mjs +2898 -0
  53. package/skills/project-init/evals/RUBRIC.md +81 -0
  54. package/skills/project-init/evals/cases/conflicting.expect.json +17 -0
  55. package/skills/project-init/evals/cases/conflicting.md +9 -0
  56. package/skills/project-init/evals/cases/vague-idea.expect.json +11 -0
  57. package/skills/project-init/evals/cases/vague-idea.md +7 -0
  58. package/skills/project-init/evals/cases/well-formed.expect.json +18 -0
  59. package/skills/project-init/evals/cases/well-formed.md +28 -0
  60. package/skills/project-init/markdown.mjs +0 -0
  61. package/skills/project-init/modules/a11y.json +46 -0
  62. package/skills/project-init/modules/ai.json +77 -0
  63. package/skills/project-init/modules/audience.json +48 -0
  64. package/skills/project-init/modules/backend.json +129 -0
  65. package/skills/project-init/modules/brand.json +78 -0
  66. package/skills/project-init/modules/core.json +132 -0
  67. package/skills/project-init/modules/design.json +72 -0
  68. package/skills/project-init/modules/engineering.json +86 -0
  69. package/skills/project-init/modules/mobile.json +22 -0
  70. package/skills/project-init/modules/ops.json +122 -0
  71. package/skills/project-init/modules/process.json +104 -0
  72. package/skills/project-init/modules/product.json +37 -0
  73. package/skills/project-init/modules/ux.json +52 -0
  74. package/skills/project-init/modules/web.json +129 -0
  75. package/skills/project-init/presets/ai-product.json +12 -0
  76. package/skills/project-init/presets/internal-system.json +15 -0
  77. package/skills/project-init/presets/mobile-app.json +20 -0
  78. package/skills/project-init/presets/web-corporate.json +116 -0
  79. package/skills/project-init/schema.json +80 -0
  80. package/skills/project-init/templates/log.md +54 -0
  81. package/skills/project-init/templates/readme.md +62 -0
  82. package/skills/project-init/templates/reference.md +34 -0
  83. package/skills/project-init/templates/register.md +63 -0
  84. package/skills/project-init/templates/spec.md +46 -0
  85. package/skills/project-interview/INTERVIEW.md +221 -0
  86. package/skills/project-interview/SKILL.md +156 -0
  87. package/skills/project-interview/fixtures/brief.md +49 -0
  88. package/skills/project-interview/fixtures/decisions.md +13 -0
  89. package/skills/project-interview/fixtures/open-questions.md +9 -0
  90. package/skills/project-interview/templates/brief.md +179 -0
  91. package/skills/project-interview/templates/decisions.md +55 -0
  92. package/skills/project-interview/templates/open-questions.md +40 -0
  93. 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
+ }