@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,205 @@
1
+ /* DDL SQL 판독기 — CREATE TABLE + FOREIGN KEY
2
+ *
3
+ * 관계 근거가 제약조건에 명시돼 있으므로 이 형식은 전부 confidence:'fact' 다.
4
+ * 추측이 끼어들 자리가 없다. 그래서 가장 먼저 만들었다.
5
+ *
6
+ * 읽는 것: CREATE TABLE (인라인 REFERENCES · 테이블 레벨 FOREIGN KEY · PRIMARY KEY),
7
+ * ALTER TABLE ADD CONSTRAINT FOREIGN KEY, COMMENT ON.
8
+ * 못 읽은 문장은 버리지 않고 unparsed 에 남긴다.
9
+ */
10
+
11
+ import { emptyIR } from './index.mjs';
12
+
13
+ /** 문자열 리터럴을 지키면서 주석만 제거한다 */
14
+ function stripComments(src) {
15
+ let out = '', i = 0, n = src.length;
16
+ while (i < n) {
17
+ const c = src[i], d = src[i + 1];
18
+ if (c === "'" || c === '"' || c === '`') {
19
+ const q = c;
20
+ out += c; i++;
21
+ while (i < n) {
22
+ out += src[i];
23
+ if (src[i] === q && src[i - 1] !== '\\') { i++; break; }
24
+ i++;
25
+ }
26
+ continue;
27
+ }
28
+ if (c === '-' && d === '-') { while (i < n && src[i] !== '\n') i++; continue; }
29
+ if (c === '/' && d === '*') { i += 2; while (i < n && !(src[i] === '*' && src[i + 1] === '/')) i++; i += 2; continue; }
30
+ out += c; i++;
31
+ }
32
+ return out;
33
+ }
34
+
35
+ const unquote = s => (s || '').trim()
36
+ .replace(/^[`"\[]/, '').replace(/[`"\]]$/, '');
37
+
38
+ /** schema.table -> {schema, table} */
39
+ function splitName(raw) {
40
+ const parts = String(raw).trim().split('.').map(unquote).filter(Boolean);
41
+ if (parts.length >= 2) return { schema: parts[parts.length - 2], table: parts[parts.length - 1] };
42
+ return { schema: null, table: parts[0] || '' };
43
+ }
44
+
45
+ /** 여는 괄호 위치에서 짝이 맞는 닫는 괄호를 찾는다 (문자열 무시) */
46
+ function matchParen(src, open) {
47
+ let depth = 0;
48
+ for (let i = open; i < src.length; i++) {
49
+ const c = src[i];
50
+ if (c === "'" || c === '"' || c === '`') {
51
+ const q = c; i++;
52
+ while (i < src.length && !(src[i] === q && src[i - 1] !== '\\')) i++;
53
+ continue;
54
+ }
55
+ if (c === '(') depth++;
56
+ else if (c === ')') { depth--; if (depth === 0) return i; }
57
+ }
58
+ return -1;
59
+ }
60
+
61
+ /** 괄호 깊이 0 의 콤마로 나눈다 */
62
+ function splitTop(body) {
63
+ const out = []; let depth = 0, cur = '';
64
+ for (let i = 0; i < body.length; i++) {
65
+ const c = body[i];
66
+ if (c === "'" || c === '"' || c === '`') {
67
+ const q = c; cur += c; i++;
68
+ while (i < body.length) { cur += body[i]; if (body[i] === q && body[i - 1] !== '\\') break; i++; }
69
+ continue;
70
+ }
71
+ if (c === '(') depth++;
72
+ if (c === ')') depth--;
73
+ if (c === ',' && depth === 0) { out.push(cur); cur = ''; continue; }
74
+ cur += c;
75
+ }
76
+ if (cur.trim()) out.push(cur);
77
+ return out.map(s => s.trim()).filter(Boolean);
78
+ }
79
+
80
+ const colList = s => splitTop(s).map(unquote).filter(Boolean);
81
+
82
+ export function readSql(src, source = 'input.sql') {
83
+ const ir = emptyIR(source, 'sql');
84
+ const sql = stripComments(src);
85
+ const byName = new Map();
86
+
87
+ const addRef = (from, col, to, toCol, evidence) => {
88
+ if (!from || !col || !to) return;
89
+ ir.refs.push({ from, col, to, toCol: toCol || null, evidence: evidence.replace(/\s+/g, ' ').trim(), confidence: 'fact' });
90
+ };
91
+
92
+ // ── CREATE TABLE ────────────────────────────────────────
93
+ const reCreate = /CREATE\s+(?:GLOBAL\s+|LOCAL\s+|TEMP(?:ORARY)?\s+|UNLOGGED\s+)*TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([`"\[\]\w.]+)\s*\(/gi;
94
+ let m;
95
+ while ((m = reCreate.exec(sql))) {
96
+ const open = sql.indexOf('(', m.index + m[0].length - 1);
97
+ const close = matchParen(sql, open);
98
+ if (close < 0) { ir.unparsed.push(m[0].trim()); continue; }
99
+ const { schema, table } = splitName(m[1]);
100
+ if (!table) continue;
101
+ const body = sql.slice(open + 1, close);
102
+ const t = { name: table, group: schema || null, columns: [] };
103
+ if (byName.has(table)) ir.warnings.push('중복 CREATE TABLE: ' + table);
104
+ byName.set(table, t);
105
+
106
+ for (const item of splitTop(body)) {
107
+ const head = item.replace(/^\s+/, '');
108
+ const kw = head.toUpperCase();
109
+
110
+ // 테이블 레벨 제약
111
+ if (/^(CONSTRAINT\b|PRIMARY\s+KEY\b|FOREIGN\s+KEY\b|UNIQUE\b|CHECK\b|KEY\b|INDEX\b)/.test(kw)) {
112
+ const fk = head.match(/FOREIGN\s+KEY\s*\(([^)]*)\)\s*REFERENCES\s+([`"\[\]\w.]+)\s*(?:\(([^)]*)\))?/i);
113
+ if (fk) {
114
+ const cols = colList(fk[1]);
115
+ const ref = splitName(fk[2]);
116
+ const rcols = fk[3] ? colList(fk[3]) : [];
117
+ cols.forEach((c, i) => addRef(table, c, ref.table, rcols[i] || rcols[0] || null, head));
118
+ continue;
119
+ }
120
+ const pk = head.match(/PRIMARY\s+KEY\s*\(([^)]*)\)/i);
121
+ if (pk) {
122
+ for (const c of colList(pk[1])) {
123
+ const col = t.columns.find(x => x.name === c);
124
+ if (col) { col.pk = true; col.notNull = true; }
125
+ }
126
+ }
127
+ continue;
128
+ }
129
+
130
+ // 컬럼 정의
131
+ const cm = head.match(/^([`"\[\]\w]+)\s+([\s\S]+)$/);
132
+ if (!cm) { ir.unparsed.push(head); continue; }
133
+ const name = unquote(cm[1]);
134
+ const rest = cm[2];
135
+ // 타입은 '첫 제약 키워드 앞까지'로 잡는다. 게으른 매칭을 쓰면
136
+ // `uuid NOT NULL` 에서 타입이 'u' 한 글자로 잘린다 — 실제로 그랬다.
137
+ // 'character varying(80)', 'double precision' 같은 다단어 타입도 살아야 한다.
138
+ const STOP = /\b(NOT\s+NULL|NULL|PRIMARY\s+KEY|REFERENCES|DEFAULT|UNIQUE|CHECK|CONSTRAINT|GENERATED|AUTO_INCREMENT|AUTOINCREMENT|IDENTITY|COLLATE|COMMENT|ON\s+UPDATE|ON\s+DELETE)\b/i;
139
+ const stop = rest.match(STOP);
140
+ const type = (stop ? rest.slice(0, stop.index) : rest)
141
+ .trim().replace(/,\s*$/, '').replace(/\s+/g, ' ');
142
+ const col = {
143
+ name, type: type || 'unknown',
144
+ pk: /\bPRIMARY\s+KEY\b/i.test(rest),
145
+ notNull: /\bNOT\s+NULL\b/i.test(rest) || /\bPRIMARY\s+KEY\b/i.test(rest),
146
+ };
147
+ t.columns.push(col);
148
+
149
+ // 인라인 REFERENCES
150
+ const inl = rest.match(/\bREFERENCES\s+([`"\[\]\w.]+)\s*(?:\(([^)]*)\))?/i);
151
+ if (inl) {
152
+ const ref = splitName(inl[1]);
153
+ addRef(table, name, ref.table, inl[2] ? colList(inl[2])[0] : null, name + ' ' + rest);
154
+ }
155
+ }
156
+ }
157
+
158
+ // ── ALTER TABLE ... FOREIGN KEY ─────────────────────────
159
+ const reAlter = /ALTER\s+TABLE\s+(?:ONLY\s+)?([`"\[\]\w.]+)([\s\S]*?);/gi;
160
+ while ((m = reAlter.exec(sql))) {
161
+ const { table } = splitName(m[1]);
162
+ const tail = m[2];
163
+ const reFk = /FOREIGN\s+KEY\s*\(([^)]*)\)\s*REFERENCES\s+([`"\[\]\w.]+)\s*(?:\(([^)]*)\))?/gi;
164
+ let f;
165
+ while ((f = reFk.exec(tail))) {
166
+ const cols = colList(f[1]);
167
+ const ref = splitName(f[2]);
168
+ const rcols = f[3] ? colList(f[3]) : [];
169
+ cols.forEach((c, i) =>
170
+ addRef(table, c, ref.table, rcols[i] || rcols[0] || null, 'ALTER TABLE ' + m[1] + ' ' + f[0]));
171
+ }
172
+ const pk = tail.match(/PRIMARY\s+KEY\s*\(([^)]*)\)/i);
173
+ if (pk && byName.has(table)) {
174
+ for (const c of colList(pk[1])) {
175
+ const col = byName.get(table).columns.find(x => x.name === c);
176
+ if (col) { col.pk = true; col.notNull = true; }
177
+ }
178
+ }
179
+ }
180
+
181
+ // ── COMMENT ON — 카드에 보일 한글명으로 쓴다 ─────────────
182
+ const reComment = /COMMENT\s+ON\s+(TABLE|COLUMN)\s+([`"\[\]\w.]+)\s+IS\s+'((?:[^']|'')*)'/gi;
183
+ while ((m = reComment.exec(sql))) {
184
+ const text = m[3].replace(/''/g, "'").trim();
185
+ if (m[1].toUpperCase() === 'TABLE') {
186
+ const { table } = splitName(m[2]);
187
+ if (byName.has(table)) byName.get(table).label = text;
188
+ } else {
189
+ const parts = String(m[2]).split('.').map(unquote);
190
+ const cname = parts.pop(), tname = parts.pop();
191
+ const t = byName.get(tname);
192
+ const c = t && t.columns.find(x => x.name === cname);
193
+ if (c) c.note = text;
194
+ }
195
+ }
196
+
197
+ ir.tables = [...byName.values()];
198
+
199
+ // 대상이 정의되지 않은 참조는 버리지 않는다 — 스텁으로 남길 수 있게 표시만 한다
200
+ const known = new Set(ir.tables.map(t => t.name));
201
+ for (const r of ir.refs) if (!known.has(r.to)) r.missingTarget = true;
202
+
203
+ if (!ir.tables.length) ir.warnings.push('CREATE TABLE 을 하나도 찾지 못했습니다');
204
+ return ir;
205
+ }
@@ -0,0 +1,220 @@
1
+ /* xlsx 판독기 — zip + XML.
2
+ *
3
+ * 중요: 엑셀에는 외래키 개념이 없다. 어느 열이 관계 근거인지는 자료마다 다르다.
4
+ * 그래서 이 판독기는 **시트를 읽어 구조를 보고할 뿐** 스스로 스키마를 만들지 않는다.
5
+ * 매핑(--map)이 주어졌을 때만 테이블·관계를 뽑는다.
6
+ * 이걸 자동으로 추측하게 만들면 그럴듯한 거짓 ERD 가 조용히 나온다.
7
+ */
8
+
9
+ import { unzip } from './zip.mjs';
10
+ import { emptyIR } from './index.mjs';
11
+
12
+ const ENT = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
13
+ export const unesc = s => String(s).replace(/&(#x?[0-9a-fA-F]+|\w+);/g, (m, e) => {
14
+ if (e[0] === '#') return String.fromCodePoint(e[1] === 'x' || e[1] === 'X'
15
+ ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10));
16
+ return ENT[e] ?? m;
17
+ });
18
+
19
+ /** <t>…</t> 안의 글자를 모두 모은다 (리치 텍스트는 run 으로 쪼개져 있다) */
20
+ function joinT(xml) {
21
+ let out = '';
22
+ const re = /<t\b[^>]*>([\s\S]*?)<\/t>|<t\b[^>]*\/>/g;
23
+ let m;
24
+ while ((m = re.exec(xml))) out += m[1] ? unesc(m[1]) : '';
25
+ return out;
26
+ }
27
+
28
+ function sharedStrings(zip) {
29
+ if (!zip.has('xl/sharedStrings.xml')) return [];
30
+ const xml = zip.text('xl/sharedStrings.xml');
31
+ const out = [];
32
+ const re = /<si\b[^>]*>([\s\S]*?)<\/si>|<si\b[^>]*\/>/g;
33
+ let m;
34
+ while ((m = re.exec(xml))) out.push(m[1] ? joinT(m[1]) : '');
35
+ return out;
36
+ }
37
+
38
+ function sheetTargets(zip) {
39
+ const wb = zip.text('xl/workbook.xml');
40
+ const rels = zip.has('xl/_rels/workbook.xml.rels') ? zip.text('xl/_rels/workbook.xml.rels') : '';
41
+ const rmap = new Map();
42
+ let m;
43
+ const reRel = /<Relationship\b[^>]*Id="([^"]+)"[^>]*Target="([^"]+)"[^>]*\/?>/g;
44
+ while ((m = reRel.exec(rels))) rmap.set(m[1], m[2]);
45
+
46
+ const out = [];
47
+ const reSheet = /<sheet\b[^>]*\/?>/g;
48
+ while ((m = reSheet.exec(wb))) {
49
+ const tag = m[0];
50
+ const name = (tag.match(/name="([^"]*)"/) || [, ''])[1];
51
+ const rid = (tag.match(/r:id="([^"]*)"/) || [, ''])[1];
52
+ let target = rmap.get(rid) || '';
53
+ if (!target) continue;
54
+ target = target.replace(/^\//, '');
55
+ if (!target.startsWith('xl/')) target = 'xl/' + target.replace(/^\.\//, '');
56
+ out.push({ name: unesc(name), path: target });
57
+ }
58
+ return out;
59
+ }
60
+
61
+ const colOf = ref => (String(ref).match(/^([A-Z]+)/) || [, ''])[1];
62
+
63
+ /** 시트를 행 배열로. 각 행은 { A: '값', B: '값' } */
64
+ export function readSheet(zip, sheetPath, ss, limit = Infinity) {
65
+ const xml = zip.text(sheetPath);
66
+ const rows = [];
67
+ const reRow = /<row\b[^>]*>([\s\S]*?)<\/row>|<row\b[^>]*\/>/g;
68
+ let m;
69
+ while ((m = reRow.exec(xml)) && rows.length < limit) {
70
+ const body = m[1] || '';
71
+ const cells = {};
72
+ const reCell = /<c\b([^>]*)(?:\/>|>([\s\S]*?)<\/c>)/g;
73
+ let c;
74
+ while ((c = reCell.exec(body))) {
75
+ const attrs = c[1], inner = c[2] || '';
76
+ const ref = (attrs.match(/r="([A-Z]+\d+)"/) || [, ''])[1];
77
+ if (!ref) continue;
78
+ const t = (attrs.match(/t="([^"]*)"/) || [, ''])[1];
79
+ let val = '';
80
+ if (t === 's') {
81
+ const v = inner.match(/<v>([\s\S]*?)<\/v>/);
82
+ if (v) val = ss[+v[1]] ?? '';
83
+ } else if (t === 'inlineStr') {
84
+ val = joinT(inner);
85
+ } else if (t === 'str') {
86
+ const v = inner.match(/<v>([\s\S]*?)<\/v>/);
87
+ val = v ? unesc(v[1]) : '';
88
+ } else {
89
+ const v = inner.match(/<v>([\s\S]*?)<\/v>/);
90
+ val = v ? unesc(v[1]) : '';
91
+ }
92
+ cells[colOf(ref)] = String(val).trim();
93
+ }
94
+ rows.push(cells);
95
+ }
96
+ return rows;
97
+ }
98
+
99
+ /**
100
+ * @param buf 파일 버퍼
101
+ * @param source 표시용 이름
102
+ * @param opt { map } — 매핑이 없으면 구조만 보고한다
103
+ */
104
+ export function readXlsx(buf, source = 'input.xlsx', opt = {}) {
105
+ const ir = emptyIR(source, 'xlsx');
106
+ let zip;
107
+ try { zip = unzip(buf); }
108
+ catch (e) { ir.warnings.push('열 수 없습니다: ' + e.message); return ir; }
109
+
110
+ const ss = sharedStrings(zip);
111
+ const sheets = [];
112
+ for (const s of sheetTargets(zip)) {
113
+ if (!zip.has(s.path)) { ir.warnings.push('시트 파일 없음: ' + s.path); continue; }
114
+ const rows = readSheet(zip, s.path, ss);
115
+ const header = rows.length ? rows[0] : {};
116
+ const cols = Object.keys(header).sort((a, b) => a.length - b.length || (a < b ? -1 : 1));
117
+ sheets.push({
118
+ name: s.name,
119
+ rows: rows.length,
120
+ dataRows: Math.max(0, rows.length - 1),
121
+ columns: cols.map(c => ({ col: c, header: header[c] || '' })),
122
+ sample: rows.slice(1, 4),
123
+ all: rows,
124
+ });
125
+ }
126
+ ir.sheets = sheets;
127
+
128
+ if (!opt.map) {
129
+ ir.needsMapping = true;
130
+ ir.warnings.push('엑셀에는 외래키 정보가 없습니다. 어느 열이 무엇인지 정해줘야 테이블·관계를 뽑습니다 '
131
+ + '(시트 ' + sheets.length + '개: ' + sheets.map(s => s.name + ' ' + s.dataRows + '행').join(', ') + ')');
132
+ return ir;
133
+ }
134
+
135
+ applyMap(ir, sheets, opt.map);
136
+ return ir;
137
+ }
138
+
139
+ /**
140
+ * 매핑 규격 (map.json)
141
+ * {
142
+ * "entities": { "sheet":"Entities list", "name":"E", "label":"A", "group":"H" },
143
+ * "attributes": {
144
+ * "sheet":"Metadata", "table":"B", "name":"C", "type":"F", "label":"E",
145
+ * "required":"J", "extra":"L",
146
+ * "refPattern":"Targets:\\s*(.*)" // extra 열에서 대상 엔티티를 뽑는 정규식
147
+ * }
148
+ * }
149
+ */
150
+ export function applyMap(ir, sheets, map) {
151
+ const find = n => sheets.find(s => s.name === n);
152
+ const get = (row, col) => (col && row[col] != null ? String(row[col]).trim() : '');
153
+ const clean = s => s.replace(/_x000D_/g, '').replace(/\r/g, '').trim();
154
+
155
+ const tables = new Map();
156
+
157
+ if (map.entities) {
158
+ const sh = find(map.entities.sheet);
159
+ if (!sh) { ir.warnings.push('시트를 찾지 못함: ' + map.entities.sheet); return ir; }
160
+ for (const row of sh.all.slice(1)) {
161
+ const name = get(row, map.entities.name);
162
+ if (!name) continue;
163
+ tables.set(name, {
164
+ name,
165
+ label: get(row, map.entities.label) || name,
166
+ group: get(row, map.entities.group) || null,
167
+ columns: [],
168
+ });
169
+ }
170
+ }
171
+
172
+ if (map.attributes) {
173
+ const sh = find(map.attributes.sheet);
174
+ if (!sh) { ir.warnings.push('시트를 찾지 못함: ' + map.attributes.sheet); return ir; }
175
+ const A = map.attributes;
176
+ const reRef = A.refPattern ? new RegExp(A.refPattern, 's') : null;
177
+
178
+ for (const row of sh.all.slice(1)) {
179
+ const tname = get(row, A.table);
180
+ const cname = get(row, A.name);
181
+ if (!tname || !cname) continue;
182
+ if (!tables.has(tname)) {
183
+ tables.set(tname, { name: tname, label: tname, group: null, columns: [] });
184
+ }
185
+ const t = tables.get(tname);
186
+ const req = get(row, A.required);
187
+ const col = {
188
+ name: cname,
189
+ type: get(row, A.type) || 'unknown',
190
+ pk: false,
191
+ notNull: /Required/i.test(req),
192
+ note: get(row, A.label) || cname,
193
+ };
194
+ t.columns.push(col);
195
+
196
+ if (!reRef) continue;
197
+ const extra = clean(get(row, A.extra));
198
+ const m = extra.match(reRef);
199
+ if (!m) continue;
200
+ const targets = m[1].split('\n').map(x => x.trim()).filter(Boolean);
201
+ if (targets.length === 1) {
202
+ ir.refs.push({
203
+ from: tname, col: cname, to: targets[0], toCol: null,
204
+ evidence: A.extra + ': ' + extra.slice(0, 120),
205
+ confidence: 'fact',
206
+ });
207
+ } else if (targets.length > 1) {
208
+ // 다형 참조 — 하나를 고르면 없는 사실을 만드는 것이다. 선을 긋지 않는다.
209
+ col.note += ' → 다형: ' + (targets.length <= 4 ? targets.join(', ')
210
+ : targets.slice(0, 4).join(', ') + ' 외 ' + (targets.length - 4));
211
+ ir.polymorphic = (ir.polymorphic || 0) + 1;
212
+ }
213
+ }
214
+ }
215
+
216
+ ir.tables = [...tables.values()];
217
+ if (ir.polymorphic)
218
+ ir.warnings.push('다형 참조 ' + ir.polymorphic + '건은 Ref 로 그리지 않고 컬럼 note 에만 적었습니다');
219
+ return ir;
220
+ }
@@ -0,0 +1,130 @@
1
+ /* XML 판독기.
2
+ *
3
+ * XML 에는 표준 스키마 형태가 없다. 자료마다 뜻이 다르므로
4
+ * 매핑 없이는 **구조만 보고**한다.
5
+ *
6
+ * 가장 쓸모 있는 쓰임은 테이블 추출이 아니라 **그룹 힌트**다.
7
+ * Dynamics SiteMap 처럼 "어느 엔티티가 어느 업무 영역에 속하는가"를 담은 자료가 있으면
8
+ * 그걸로 TableGroup 을 만들 수 있다. 그룹은 배선 품질과 속도를 크게 좌우한다.
9
+ */
10
+
11
+ import { emptyIR } from './index.mjs';
12
+
13
+ const ENT = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
14
+ const unesc = s => String(s).replace(/&(#x?[0-9a-fA-F]+|\w+);/g, (m, e) => {
15
+ if (e[0] === '#') return String.fromCodePoint(e[1] === 'x' || e[1] === 'X'
16
+ ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10));
17
+ return ENT[e] ?? m;
18
+ });
19
+
20
+ const localName = n => String(n).replace(/^.*:/, '');
21
+
22
+ function parseAttrs(s) {
23
+ const a = {};
24
+ const re = /([\w:.-]+)\s*=\s*"([^"]*)"|([\w:.-]+)\s*=\s*'([^']*)'/g;
25
+ let m;
26
+ while ((m = re.exec(s))) {
27
+ const k = localName(m[1] || m[3]);
28
+ a[k] = unesc(m[2] ?? m[4] ?? '');
29
+ }
30
+ return a;
31
+ }
32
+
33
+ /** 태그 스트림을 훑으며 스택으로 깊이를 추적한다 (외부 파서 없이) */
34
+ export function walk(xml, onOpen, onClose) {
35
+ const clean = xml
36
+ .replace(/<!--[\s\S]*?-->/g, '')
37
+ .replace(/<!\[CDATA\[[\s\S]*?\]\]>/g, '')
38
+ .replace(/<\?[\s\S]*?\?>/g, '')
39
+ .replace(/<!DOCTYPE[^>]*>/gi, '');
40
+ const re = /<\s*(\/)?\s*([\w:.-]+)((?:"[^"]*"|'[^']*'|[^>"'])*?)(\/)?>/g;
41
+ let m;
42
+ while ((m = re.exec(clean))) {
43
+ const [, closing, rawName, attrStr, selfClose] = m;
44
+ const name = localName(rawName);
45
+ if (closing) { onClose(name); continue; }
46
+ onOpen(name, parseAttrs(attrStr || ''));
47
+ if (selfClose) onClose(name);
48
+ }
49
+ }
50
+
51
+ /** 구조 보고 — 어떤 요소가 몇 번 나오고 어떤 속성을 갖는지 */
52
+ function survey(xml) {
53
+ const els = new Map();
54
+ const stack = [];
55
+ walk(xml,
56
+ (name, attrs) => {
57
+ if (!els.has(name)) els.set(name, { name, count: 0, attrs: new Map(), parents: new Set() });
58
+ const e = els.get(name);
59
+ e.count++;
60
+ if (stack.length) e.parents.add(stack[stack.length - 1]);
61
+ for (const [k, v] of Object.entries(attrs)) {
62
+ if (!e.attrs.has(k)) e.attrs.set(k, { count: 0, sample: v });
63
+ e.attrs.get(k).count++;
64
+ }
65
+ stack.push(name);
66
+ },
67
+ () => { stack.pop(); });
68
+ return [...els.values()]
69
+ .sort((a, b) => b.count - a.count)
70
+ .map(e => ({
71
+ name: e.name, count: e.count,
72
+ parents: [...e.parents].slice(0, 3),
73
+ attrs: [...e.attrs].map(([k, v]) => ({ name: k, count: v.count, sample: v.sample })),
74
+ }));
75
+ }
76
+
77
+ /**
78
+ * 매핑 규격
79
+ * {
80
+ * "groups": { // 그룹 힌트 추출
81
+ * "container": "Area", // 영역을 뜻하는 요소
82
+ * "containerName": "Id", // 그 요소의 어느 속성이 이름인가
83
+ * "member": "SubArea", // 그 안에서 엔티티를 가리키는 요소
84
+ * "memberEntity": "Entity" // 그 요소의 어느 속성이 엔티티명인가
85
+ * }
86
+ * }
87
+ */
88
+ export function readXml(text, source = 'input.xml', opt = {}) {
89
+ const ir = emptyIR(source, 'xml');
90
+ const map = opt.map || null;
91
+
92
+ if (!map || !map.groups) {
93
+ ir.elements = survey(text);
94
+ ir.needsMapping = true;
95
+ const top = ir.elements.slice(0, 6).map(e => e.name + '×' + e.count).join(', ');
96
+ ir.warnings.push('XML 은 자료마다 뜻이 달라 매핑이 필요합니다. 주요 요소: ' + top
97
+ + ' (그룹 힌트를 뽑으려면 --map 의 groups 를 채우세요)');
98
+ return ir;
99
+ }
100
+
101
+ const G = map.groups;
102
+ const hints = new Map();
103
+ const stack = [];
104
+ let curGroup = null, groupDepth = -1;
105
+
106
+ walk(text,
107
+ (name, attrs) => {
108
+ stack.push(name);
109
+ if (name === G.container) {
110
+ const gname = (attrs[G.containerName] || '').trim();
111
+ if (gname) { curGroup = gname; groupDepth = stack.length; }
112
+ } else if (curGroup && name === G.member) {
113
+ const ent = (attrs[G.memberEntity] || '').trim();
114
+ // 엔티티명이 아닌 URL·웹리소스는 거른다
115
+ if (ent && /^[A-Za-z_][\w]*$/.test(ent) && !hints.has(ent)) hints.set(ent, curGroup);
116
+ }
117
+ },
118
+ name => {
119
+ if (name === G.container && stack.length === groupDepth) { curGroup = null; groupDepth = -1; }
120
+ stack.pop();
121
+ });
122
+
123
+ ir.groupHints = [...hints].map(([entity, group]) => ({ entity, group }));
124
+ ir.elements = survey(text);
125
+ if (!ir.groupHints.length)
126
+ ir.warnings.push('그룹 힌트를 하나도 뽑지 못했습니다 — container/member 요소 이름을 확인하세요');
127
+ else
128
+ ir.warnings.push('그룹 힌트 ' + ir.groupHints.length + '건을 뽑았습니다 (테이블·관계는 없음)');
129
+ return ir;
130
+ }
@@ -0,0 +1,74 @@
1
+ /* 최소 ZIP 리더 — Node 에 zip 이 내장돼 있지 않아서 직접 만든다.
2
+ * xlsx 는 XML 을 담은 zip 이다. 외부 의존성을 붙이지 않기 위한 것이므로
3
+ * 필요한 만큼만 읽는다: 저장(0)과 deflate(8) 두 방식.
4
+ */
5
+
6
+ import zlib from 'zlib';
7
+
8
+ const EOCD = 0x06054b50;
9
+ const EOCD64_LOC = 0x07064b50;
10
+ const CEN = 0x02014b50;
11
+ const LOC = 0x04034b50;
12
+
13
+ /** 끝에서부터 EOCD 를 찾는다 (주석이 붙어 있을 수 있다) */
14
+ function findEocd(buf) {
15
+ const min = Math.max(0, buf.length - 66 * 1024);
16
+ for (let i = buf.length - 22; i >= min; i--) {
17
+ if (buf.readUInt32LE(i) === EOCD) return i;
18
+ }
19
+ return -1;
20
+ }
21
+
22
+ export function unzip(buf) {
23
+ const eocd = findEocd(buf);
24
+ if (eocd < 0) throw new Error('ZIP 이 아닙니다 (EOCD 없음)');
25
+
26
+ let count = buf.readUInt16LE(eocd + 10);
27
+ let cdOff = buf.readUInt32LE(eocd + 16);
28
+
29
+ // ZIP64 — 항목이 65535 를 넘거나 4GB 를 넘으면 여기로 온다
30
+ if (count === 0xffff || cdOff === 0xffffffff) {
31
+ for (let i = eocd - 20; i >= 0; i--) {
32
+ if (buf.readUInt32LE(i) === EOCD64_LOC) {
33
+ const z64 = Number(buf.readBigUInt64LE(i + 8));
34
+ count = Number(buf.readBigUInt64LE(z64 + 32));
35
+ cdOff = Number(buf.readBigUInt64LE(z64 + 48));
36
+ break;
37
+ }
38
+ }
39
+ }
40
+
41
+ const files = new Map();
42
+ let p = cdOff;
43
+ for (let n = 0; n < count; n++) {
44
+ if (buf.readUInt32LE(p) !== CEN) break;
45
+ const method = buf.readUInt16LE(p + 10);
46
+ const csize = buf.readUInt32LE(p + 20);
47
+ const nameLen = buf.readUInt16LE(p + 28);
48
+ const extraLen = buf.readUInt16LE(p + 30);
49
+ const cmtLen = buf.readUInt16LE(p + 32);
50
+ const lho = buf.readUInt32LE(p + 42);
51
+ const name = buf.toString('utf8', p + 46, p + 46 + nameLen);
52
+ files.set(name, { method, csize, lho });
53
+ p += 46 + nameLen + extraLen + cmtLen;
54
+ }
55
+
56
+ return {
57
+ names: () => [...files.keys()],
58
+ has: n => files.has(n),
59
+ read(name) {
60
+ const e = files.get(name);
61
+ if (!e) throw new Error('zip 안에 없는 항목: ' + name);
62
+ // 로컬 헤더의 길이는 중앙 디렉터리와 다를 수 있다. 반드시 로컬을 읽는다.
63
+ if (buf.readUInt32LE(e.lho) !== LOC) throw new Error('손상된 로컬 헤더: ' + name);
64
+ const nl = buf.readUInt16LE(e.lho + 26);
65
+ const el = buf.readUInt16LE(e.lho + 28);
66
+ const start = e.lho + 30 + nl + el;
67
+ const raw = buf.subarray(start, start + e.csize);
68
+ if (e.method === 0) return raw;
69
+ if (e.method === 8) return zlib.inflateRawSync(raw);
70
+ throw new Error('지원하지 않는 압축 방식 ' + e.method + ': ' + name);
71
+ },
72
+ text(name) { return this.read(name).toString('utf8'); },
73
+ };
74
+ }