@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,43 @@
1
+ // 회귀용 익명 샘플 ② — 깨지기 쉬운 것만 모았다.
2
+ // · 자기참조 · 그룹이 하나뿐인 경우 · 그룹에 안 들어간 테이블
3
+ // · 아주 긴 이름(자르기) · 관계가 하나도 없는 고립 테이블
4
+ // · 정의 없는 참조 대상(스텁) · 이름에 특수문자
5
+
6
+ Table a_very_long_table_name_that_will_not_fit_in_a_card {
7
+ id uniqueidentifier [pk, not null, note: '기본 키']
8
+ parent_id uniqueidentifier [note: '자기참조 → a_very_long_table_name_that_will_not_fit_in_a_card']
9
+ Note: '아주 긴 이름을 가진 테이블이라 카드 폭에 절대 안 들어간다 (LongName) · 커스텀 · 총 속성 12개 · 표시 2개'
10
+ }
11
+
12
+ Table lonely {
13
+ lonely_id uniqueidentifier [pk, not null, note: '기본 키']
14
+ Note: '고립 테이블 — 관계가 하나도 없다 (Lonely) · 표준 · 총 속성 1개 · 표시 1개'
15
+ }
16
+
17
+ Table quoted_note {
18
+ id uniqueidentifier [pk, not null, note: '따옴표 \'테스트\' 포함']
19
+ target_id uniqueidentifier [note: '스텁 → ghost_table']
20
+ Note: '노트에 따옴표가 있는 테이블 (Quoted) · 표준 · 총 속성 2개 · 표시 2개'
21
+ }
22
+
23
+ Table ghost_table {
24
+ ghost_tableid uniqueidentifier [pk, note: '기본 키 (추정 — 문서에 없음)']
25
+ Note: '문서 밖 엔티티 · 이 문서에 정의 없음 · 피참조 1건'
26
+ }
27
+
28
+ Table ungrouped {
29
+ ungrouped_id uniqueidentifier [pk, not null, note: '기본 키']
30
+ long_ref uniqueidentifier [note: '긴 이름 → a_very_long_table_name_that_will_not_fit_in_a_card']
31
+ Note: '그룹에 안 들어간 테이블 (Ungrouped) · 표준 · 총 속성 2개 · 표시 2개'
32
+ }
33
+
34
+ TableGroup "유일한 그룹" {
35
+ a_very_long_table_name_that_will_not_fit_in_a_card
36
+ lonely
37
+ quoted_note
38
+ ghost_table
39
+ }
40
+
41
+ Ref: a_very_long_table_name_that_will_not_fit_in_a_card.parent_id > a_very_long_table_name_that_will_not_fit_in_a_card.id
42
+ Ref: quoted_note.target_id > ghost_table.ghost_tableid
43
+ Ref: ungrouped.long_ref > a_very_long_table_name_that_will_not_fit_in_a_card.id
@@ -0,0 +1,32 @@
1
+ {
2
+ "_설명": [
3
+ "Dynamics 365 자료 매핑. 파일 확장자별로 갈라 쓴다.",
4
+ "xlsx: 관계 근거는 Metadata 시트 L열(Additional data)의 'Targets:' 값.",
5
+ " 대상이 하나면 사실(Ref), 여럿이면 다형 참조라 선을 긋지 않는다.",
6
+ "xml : SiteMap 은 테이블이 아니라 '어느 엔티티가 어느 업무 영역인가'를 준다.",
7
+ " 그룹이 배선 품질과 속도를 좌우하므로 이 힌트가 중요하다."
8
+ ],
9
+
10
+ "entities": {
11
+ "sheet": "Entities list",
12
+ "name": "E",
13
+ "label": "A"
14
+ },
15
+ "attributes": {
16
+ "sheet": "Metadata",
17
+ "table": "B",
18
+ "name": "C",
19
+ "type": "F",
20
+ "label": "E",
21
+ "required": "J",
22
+ "extra": "L",
23
+ "refPattern": "Targets:\\s*(.*)"
24
+ },
25
+
26
+ "groups": {
27
+ "container": "Area",
28
+ "containerName": "Id",
29
+ "member": "SubArea",
30
+ "memberEntity": "Entity"
31
+ }
32
+ }
@@ -0,0 +1,78 @@
1
+ // 회귀용 익명 샘플 ① — 작은 커머스 스키마. 그룹 3개.
2
+ // 검증기가 정상 동작하는지 보는 최소 케이스다.
3
+
4
+ Table customer {
5
+ customer_id uniqueidentifier [pk, not null, note: '고객 ID']
6
+ name nvarchar(80) [not null, note: '고객명']
7
+ email nvarchar(160) [note: '이메일']
8
+ Note: '고객 (Customer) · 표준 · 총 속성 6개 · 표시 3개'
9
+ }
10
+
11
+ Table address {
12
+ address_id uniqueidentifier [pk, not null, note: '주소 ID']
13
+ customer_id uniqueidentifier [not null, note: '고객 → customer']
14
+ line1 nvarchar(200) [note: '도로명']
15
+ Note: '배송지 (Address) · 표준 · 총 속성 7개 · 표시 3개'
16
+ }
17
+
18
+ Table product {
19
+ product_id uniqueidentifier [pk, not null, note: '상품 ID']
20
+ name nvarchar(160) [not null, note: '상품명']
21
+ category_id uniqueidentifier [note: '분류 → category']
22
+ Note: '상품 (Product) · 표준 · 총 속성 9개 · 표시 3개'
23
+ }
24
+
25
+ Table category {
26
+ category_id uniqueidentifier [pk, not null, note: '분류 ID']
27
+ name nvarchar(80) [not null, note: '분류명']
28
+ parent_id uniqueidentifier [note: '상위 분류 → category']
29
+ Note: '분류 (Category) · 표준 · 총 속성 4개 · 표시 3개'
30
+ }
31
+
32
+ Table orders {
33
+ order_id uniqueidentifier [pk, not null, note: '주문 ID']
34
+ customer_id uniqueidentifier [not null, note: '고객 → customer']
35
+ address_id uniqueidentifier [note: '배송지 → address']
36
+ placed_at datetime [not null, note: '주문일시']
37
+ Note: '주문 (Orders) · 표준 · 총 속성 11개 · 표시 4개'
38
+ }
39
+
40
+ Table order_line {
41
+ order_line_id uniqueidentifier [pk, not null, note: '주문상세 ID']
42
+ order_id uniqueidentifier [not null, note: '주문 → orders']
43
+ product_id uniqueidentifier [not null, note: '상품 → product']
44
+ qty int [not null, note: '수량']
45
+ Note: '주문 상세 (OrderLine) · 표준 · 총 속성 6개 · 표시 4개'
46
+ }
47
+
48
+ Table payment {
49
+ payment_id uniqueidentifier [pk, not null, note: '결제 ID']
50
+ order_id uniqueidentifier [not null, note: '주문 → orders']
51
+ amount money [not null, note: '금액']
52
+ Note: '결제 (Payment) · 표준 · 총 속성 8개 · 표시 3개'
53
+ }
54
+
55
+ TableGroup "고객" {
56
+ customer
57
+ address
58
+ }
59
+
60
+ TableGroup "상품" {
61
+ product
62
+ category
63
+ }
64
+
65
+ TableGroup "주문" {
66
+ orders
67
+ order_line
68
+ payment
69
+ }
70
+
71
+ Ref: address.customer_id > customer.customer_id
72
+ Ref: product.category_id > category.category_id
73
+ Ref: category.parent_id > category.category_id
74
+ Ref: orders.customer_id > customer.customer_id
75
+ Ref: orders.address_id > address.address_id
76
+ Ref: order_line.order_id > orders.order_id
77
+ Ref: order_line.product_id > product.product_id
78
+ Ref: payment.order_id > orders.order_id
@@ -0,0 +1,38 @@
1
+ {
2
+ "openapi": "3.0.0",
3
+ "info": { "title": "Demo API", "version": "1.0" },
4
+ "components": {
5
+ "schemas": {
6
+ "Customer": {
7
+ "type": "object",
8
+ "title": "고객",
9
+ "required": ["id", "name"],
10
+ "properties": {
11
+ "id": { "type": "string", "format": "uuid" },
12
+ "name": { "type": "string", "description": "고객명" },
13
+ "address": { "$ref": "#/components/schemas/Address" }
14
+ }
15
+ },
16
+ "Address": {
17
+ "type": "object",
18
+ "properties": {
19
+ "id": { "type": "string" },
20
+ "line1": { "type": "string" }
21
+ }
22
+ },
23
+ "Order": {
24
+ "type": "object",
25
+ "required": ["id"],
26
+ "properties": {
27
+ "id": { "type": "string" },
28
+ "customer": { "$ref": "#/components/schemas/Customer" },
29
+ "lines": { "type": "array", "items": { "$ref": "#/components/schemas/OrderLine" } }
30
+ }
31
+ },
32
+ "OrderLine": {
33
+ "type": "object",
34
+ "properties": { "sku": { "type": "string" }, "qty": { "type": "integer" } }
35
+ }
36
+ }
37
+ }
38
+ }
@@ -0,0 +1,44 @@
1
+ // Prisma 판독기 회귀용. 함정: 목록 관계(그리면 안 됨) · fields 없는 관계 · enum · @@map
2
+ generator client { provider = "prisma-client-js" }
3
+ datasource db { provider = "postgresql"; url = env("DATABASE_URL") }
4
+
5
+ enum Role { USER ADMIN }
6
+
7
+ model User {
8
+ id String @id @default(uuid())
9
+ email String @unique
10
+ role Role @default(USER)
11
+ createdAt DateTime @default(now())
12
+ posts Post[] // 목록 — FK 는 반대편에 있다. 여기선 안 그린다
13
+ profile Profile? // fields 없음 — 컬럼 특정 불가
14
+ @@map("users")
15
+ }
16
+
17
+ model Profile {
18
+ id String @id @default(uuid())
19
+ bio String?
20
+ userId String @unique
21
+ user User @relation(fields: [userId], references: [id])
22
+ }
23
+
24
+ model Post {
25
+ id String @id @default(uuid())
26
+ title String
27
+ authorId String
28
+ author User @relation(fields: [authorId], references: [id])
29
+ categoryId String?
30
+ category Category? @relation(fields: [categoryId], references: [id])
31
+ tags Tag[]
32
+ }
33
+
34
+ model Category {
35
+ id String @id @default(uuid())
36
+ name String
37
+ posts Post[]
38
+ }
39
+
40
+ model Tag {
41
+ id String @id @default(uuid())
42
+ name String
43
+ posts Post[]
44
+ }
@@ -0,0 +1,62 @@
1
+ -- 판독기 회귀용 DDL. 까다로운 것만 모았다.
2
+ -- · 인라인 REFERENCES · 테이블 레벨 FOREIGN KEY · ALTER TABLE ADD CONSTRAINT
3
+ -- · 복합 PK · 스키마 접두 · 따옴표/백틱 식별자 · 자기참조
4
+ -- · 정의 없는 테이블 참조(스텁) · 주석 안의 가짜 FK
5
+
6
+ CREATE SCHEMA sales;
7
+
8
+ CREATE TABLE sales.customer (
9
+ customer_id uuid NOT NULL,
10
+ name varchar(80) NOT NULL,
11
+ email varchar(160),
12
+ referred_by uuid REFERENCES sales.customer(customer_id), -- 자기참조
13
+ CONSTRAINT pk_customer PRIMARY KEY (customer_id)
14
+ );
15
+ COMMENT ON TABLE sales.customer IS '고객';
16
+ COMMENT ON COLUMN sales.customer.email IS '이메일';
17
+
18
+ CREATE TABLE sales."order" (
19
+ order_id uuid NOT NULL PRIMARY KEY,
20
+ customer_id uuid NOT NULL,
21
+ /* 아래 주석 안의 FOREIGN KEY 는 읽으면 안 된다:
22
+ FOREIGN KEY (ghost_col) REFERENCES ghost_never(id) */
23
+ placed_at timestamp NOT NULL,
24
+ coupon_id uuid,
25
+ FOREIGN KEY (customer_id) REFERENCES sales.customer (customer_id)
26
+ );
27
+ COMMENT ON TABLE sales."order" IS '주문';
28
+
29
+ CREATE TABLE IF NOT EXISTS sales.order_line (
30
+ order_id uuid NOT NULL,
31
+ line_no int NOT NULL,
32
+ product_id uuid NOT NULL,
33
+ qty int NOT NULL DEFAULT 1,
34
+ PRIMARY KEY (order_id, line_no),
35
+ CONSTRAINT fk_line_order FOREIGN KEY (order_id) REFERENCES sales."order"(order_id),
36
+ CONSTRAINT fk_line_product FOREIGN KEY (product_id) REFERENCES catalog.product(product_id)
37
+ );
38
+ COMMENT ON TABLE sales.order_line IS '주문 상세';
39
+
40
+ CREATE TABLE catalog.product (
41
+ `product_id` uuid NOT NULL PRIMARY KEY,
42
+ `name` varchar(160) NOT NULL,
43
+ category_id uuid
44
+ );
45
+ COMMENT ON TABLE catalog.product IS '상품';
46
+
47
+ CREATE TABLE catalog.category (
48
+ category_id uuid NOT NULL PRIMARY KEY,
49
+ name varchar(80) NOT NULL,
50
+ parent_id uuid
51
+ );
52
+ COMMENT ON TABLE catalog.category IS '분류';
53
+
54
+ ALTER TABLE ONLY catalog.product
55
+ ADD CONSTRAINT fk_product_category FOREIGN KEY (category_id) REFERENCES catalog.category(category_id);
56
+
57
+ ALTER TABLE catalog.category
58
+ ADD CONSTRAINT fk_category_parent FOREIGN KEY (parent_id) REFERENCES catalog.category(category_id);
59
+
60
+ -- 정의가 없는 테이블을 가리킨다 -> 스텁으로 남아야 한다
61
+ ALTER TABLE sales."order"
62
+ ADD CONSTRAINT fk_order_coupon FOREIGN KEY (coupon_id) REFERENCES promo.coupon(coupon_id);
@@ -0,0 +1,81 @@
1
+ /* CSV 판독기 — xlsx 와 같은 원칙.
2
+ * CSV 에도 외래키 개념이 없다. 매핑이 없으면 구조만 보고한다.
3
+ */
4
+
5
+ import { emptyIR } from './index.mjs';
6
+ import { applyMap } from './xlsx.mjs';
7
+
8
+ /** RFC4180 — 따옴표 안의 구분자·줄바꿈을 지킨다 */
9
+ export function parseCsv(text, delim) {
10
+ const src = text.replace(/^\uFEFF/, '');
11
+ if (!delim) {
12
+ const head = src.slice(0, 4000);
13
+ const counts = [[',', 0], [';', 0], ['\t', 0], ['|', 0]]
14
+ .map(([d]) => [d, (head.match(new RegExp('\\' + d, 'g')) || []).length]);
15
+ counts.sort((a, b) => b[1] - a[1]);
16
+ delim = counts[0][1] ? counts[0][0] : ',';
17
+ }
18
+ const rows = [];
19
+ let row = [], cur = '', q = false;
20
+ for (let i = 0; i < src.length; i++) {
21
+ const c = src[i];
22
+ if (q) {
23
+ if (c === '"') {
24
+ if (src[i + 1] === '"') { cur += '"'; i++; }
25
+ else q = false;
26
+ } else cur += c;
27
+ continue;
28
+ }
29
+ if (c === '"') { q = true; continue; }
30
+ if (c === delim) { row.push(cur); cur = ''; continue; }
31
+ if (c === '\r') continue;
32
+ if (c === '\n') { row.push(cur); rows.push(row); row = []; cur = ''; continue; }
33
+ cur += c;
34
+ }
35
+ if (cur !== '' || row.length) { row.push(cur); rows.push(row); }
36
+ return { rows: rows.filter(r => r.some(v => String(v).trim() !== '')), delim };
37
+ }
38
+
39
+ /** 0-based 인덱스를 스프레드시트 열 이름으로 (0 -> A, 26 -> AA) */
40
+ export function colName(i) {
41
+ let s = '';
42
+ i += 1;
43
+ while (i > 0) { const r = (i - 1) % 26; s = String.fromCharCode(65 + r) + s; i = Math.floor((i - 1) / 26); }
44
+ return s;
45
+ }
46
+
47
+ export function readCsv(text, source = 'input.csv', opt = {}) {
48
+ const ir = emptyIR(source, 'csv');
49
+ const { rows, delim } = parseCsv(text, opt.delim);
50
+ if (!rows.length) { ir.warnings.push('빈 파일입니다'); return ir; }
51
+
52
+ // xlsx 와 같은 { A: '값' } 형태로 맞춰 매핑 로직을 공유한다
53
+ const asObj = rows.map(r => {
54
+ const o = {};
55
+ r.forEach((v, i) => { o[colName(i)] = String(v).trim(); });
56
+ return o;
57
+ });
58
+ const header = asObj[0];
59
+ const sheet = {
60
+ name: source.replace(/\.[^.]+$/, ''),
61
+ rows: asObj.length,
62
+ dataRows: asObj.length - 1,
63
+ columns: Object.keys(header)
64
+ .sort((a, b) => a.length - b.length || (a < b ? -1 : 1))
65
+ .map(c => ({ col: c, header: header[c] || '' })),
66
+ sample: asObj.slice(1, 4),
67
+ all: asObj,
68
+ delim,
69
+ };
70
+ ir.sheets = [sheet];
71
+
72
+ if (!opt.map) {
73
+ ir.needsMapping = true;
74
+ ir.warnings.push('CSV 에는 외래키 정보가 없습니다. 매핑을 줘야 테이블·관계를 뽑습니다 '
75
+ + '(구분자 "' + (delim === '\t' ? 'TAB' : delim) + '" · 데이터 ' + sheet.dataRows + '행 · 열 '
76
+ + sheet.columns.length + '개)');
77
+ return ir;
78
+ }
79
+ applyMap(ir, [sheet], opt.map);
80
+ return ir;
81
+ }
@@ -0,0 +1,55 @@
1
+ /* 판독기 공통 계약
2
+ *
3
+ * 판독기는 '파싱'만 한다. "이 시트의 어느 열이 관계 근거인가" 같은 판단은
4
+ * 스킬(SKILL.md 절차)이 한다. 판독기가 알아서 다 해주는 것처럼 만들면
5
+ * 근거 없는 관계가 조용히 섞인다.
6
+ *
7
+ * 모든 판독기는 아래 IR 을 돌려준다.
8
+ *
9
+ * {
10
+ * source: 'orders.sql',
11
+ * format: 'sql',
12
+ * tables: [{
13
+ * name, label?, group?,
14
+ * columns: [{ name, type, pk, notNull, note? }],
15
+ * }],
16
+ * refs: [{
17
+ * from, col, to, toCol,
18
+ * evidence: '원본에서 이 관계를 뒷받침한 문자열 그대로',
19
+ * confidence: 'fact' | 'estimate',
20
+ * }],
21
+ * warnings: ['읽지 못한 것'],
22
+ * unparsed: ['통째로 건너뛴 원문 조각'],
23
+ * }
24
+ *
25
+ * evidence 는 비워 두지 않는다. 1:1 대조 검사가 이걸로 돈다.
26
+ * confidence 가 'estimate' 인 관계는 --infer 없이는 Ref 로 나가지 않는다.
27
+ */
28
+
29
+ import { readSql } from './sql.mjs';
30
+ import { readXlsx } from './xlsx.mjs';
31
+ import { readCsv } from './csv.mjs';
32
+ import { readXml } from './xml.mjs';
33
+ import { readPrisma } from './prisma.mjs';
34
+ import { readJsonSchema } from './jsonschema.mjs';
35
+
36
+ export const READERS = [
37
+ { format: 'sql', ext: ['.sql', '.ddl'], read: readSql },
38
+ { format: 'xlsx', ext: ['.xlsx', '.xlsm'], read: readXlsx, binary: true },
39
+ { format: 'csv', ext: ['.csv', '.tsv'], read: readCsv },
40
+ { format: 'xml', ext: ['.xml'], read: readXml },
41
+ { format: 'prisma', ext: ['.prisma'], read: readPrisma },
42
+ { format: 'jsonschema', ext: ['.json', '.yaml', '.yml'], read: readJsonSchema },
43
+ ];
44
+
45
+ export function detect(file) {
46
+ const lower = file.toLowerCase();
47
+ for (const r of READERS) {
48
+ if (r.ext.some(e => lower.endsWith(e))) return r;
49
+ }
50
+ return null;
51
+ }
52
+
53
+ export function emptyIR(source, format) {
54
+ return { source, format, tables: [], refs: [], warnings: [], unparsed: [] };
55
+ }
@@ -0,0 +1,86 @@
1
+ /* JSON Schema · OpenAPI 판독기.
2
+ *
3
+ * 주의: 이 형식은 **API 응답 모양**을 기술한다. 테이블도 외래키도 아니다.
4
+ * `$ref` 로 스키마 간 참조는 뽑을 수 있지만 그게 DB 관계라는 보장이 없다.
5
+ * 그래서 여기서 나오는 관계는 전부 confidence:'estimate' 다.
6
+ * --infer 없이는 Ref 로 나가지 않고 report 의 확인 목록으로만 간다.
7
+ */
8
+
9
+ import { emptyIR } from './index.mjs';
10
+
11
+ const typeOf = s => {
12
+ if (!s || typeof s !== 'object') return 'unknown';
13
+ if (s.$ref) return 'ref';
14
+ if (Array.isArray(s.type)) return s.type.filter(t => t !== 'null')[0] || 'unknown';
15
+ if (s.type) return s.type + (s.format ? '(' + s.format + ')' : '');
16
+ if (s.enum) return 'enum';
17
+ if (s.oneOf || s.anyOf || s.allOf) return 'composed';
18
+ return 'unknown';
19
+ };
20
+
21
+ const refName = r => String(r).split('/').pop();
22
+
23
+ export function readJsonSchema(text, source = 'schema.json') {
24
+ const ir = emptyIR(source, 'jsonschema');
25
+ let doc;
26
+ try { doc = JSON.parse(text); }
27
+ catch (err) { ir.warnings.push('JSON 파싱 실패: ' + err.message); return ir; }
28
+
29
+ // OpenAPI 3 · Swagger 2 · 순수 JSON Schema 의 정의 위치
30
+ const defs = doc.components?.schemas || doc.definitions || doc.$defs ||
31
+ (doc.type === 'object' && doc.properties ? { [doc.title || 'root']: doc } : null);
32
+ if (!defs) {
33
+ ir.warnings.push('스키마 정의를 찾지 못했습니다 (components.schemas · definitions · $defs)');
34
+ return ir;
35
+ }
36
+
37
+ const isObj = s => s && typeof s === 'object' &&
38
+ (s.type === 'object' || s.properties || s.allOf || s.$ref);
39
+
40
+ for (const [name, schema] of Object.entries(defs)) {
41
+ if (!isObj(schema)) continue;
42
+ const t = { name, label: schema.title || name, group: null, columns: [] };
43
+ const required = new Set(schema.required || []);
44
+
45
+ // allOf 를 얕게 펼친다 (상속 표현으로 흔히 쓴다)
46
+ const props = { ...(schema.properties || {}) };
47
+ for (const part of schema.allOf || []) {
48
+ if (part.properties) Object.assign(props, part.properties);
49
+ if (part.$ref) {
50
+ ir.refs.push({
51
+ from: name, col: '(allOf)', to: refName(part.$ref), toCol: null,
52
+ evidence: 'allOf: ' + part.$ref, confidence: 'estimate',
53
+ });
54
+ }
55
+ }
56
+
57
+ for (const [pname, p] of Object.entries(props)) {
58
+ const isArr = p && p.type === 'array';
59
+ const inner = isArr ? (p.items || {}) : (p || {});
60
+ t.columns.push({
61
+ name: pname,
62
+ type: typeOf(inner) + (isArr ? '[]' : ''),
63
+ pk: /^id$/i.test(pname),
64
+ notNull: required.has(pname),
65
+ note: (p && (p.description || p.title)) || pname,
66
+ });
67
+ if (inner && inner.$ref) {
68
+ ir.refs.push({
69
+ from: name, col: pname, to: refName(inner.$ref), toCol: null,
70
+ evidence: pname + '.$ref = ' + inner.$ref,
71
+ confidence: 'estimate',
72
+ });
73
+ }
74
+ }
75
+ ir.tables.push(t);
76
+ }
77
+
78
+ const known = new Set(ir.tables.map(t => t.name));
79
+ for (const r of ir.refs) if (!known.has(r.to)) r.missingTarget = true;
80
+
81
+ if (!ir.tables.length) ir.warnings.push('객체 스키마를 하나도 찾지 못했습니다');
82
+ if (ir.refs.length)
83
+ ir.warnings.push('이 형식의 참조 ' + ir.refs.length + '건은 전부 추정입니다 — '
84
+ + 'API 응답 구조이지 DB 외래키가 아닙니다. --infer 로만 Ref 가 됩니다');
85
+ return ir;
86
+ }
@@ -0,0 +1,102 @@
1
+ /* Prisma schema 판독기.
2
+ *
3
+ * Prisma 는 관계를 선언으로 갖고 있어 근거가 명확하다 (confidence:'fact').
4
+ * author User @relation(fields:[authorId], references:[id])
5
+ * 스칼라 필드가 실제 외래키 컬럼이고, 관계 필드는 그 컬럼을 가리킨다.
6
+ * 관계 필드만 보고 선을 그으면 어느 컬럼이 FK 인지 잃어버린다.
7
+ */
8
+
9
+ import { emptyIR } from './index.mjs';
10
+
11
+ const stripComments = s => s.replace(/\/\/[^\n]*/g, '');
12
+
13
+ const SCALARS = new Set(['String', 'Boolean', 'Int', 'BigInt', 'Float', 'Decimal',
14
+ 'DateTime', 'Json', 'Bytes', 'Unsupported']);
15
+
16
+ export function readPrisma(src, source = 'schema.prisma') {
17
+ const ir = emptyIR(source, 'prisma');
18
+ const text = stripComments(src);
19
+
20
+ const enums = new Set();
21
+ let e;
22
+ const reEnum = /^\s*enum\s+(\w+)\s*\{/gm;
23
+ while ((e = reEnum.exec(text))) enums.add(e[1]);
24
+
25
+ const models = new Map();
26
+ const reModel = /^\s*model\s+(\w+)\s*\{([\s\S]*?)^\s*\}/gm;
27
+ let m;
28
+ while ((m = reModel.exec(text))) {
29
+ const name = m[1], body = m[2];
30
+ const t = { name, label: name, group: null, columns: [] };
31
+ const rels = [];
32
+
33
+ for (const raw of body.split('\n')) {
34
+ const line = raw.trim();
35
+ if (!line || line.startsWith('@@')) {
36
+ // @@map("table_name") — 실제 테이블명
37
+ const mm = line.match(/@@map\("([^"]+)"\)/);
38
+ if (mm) t.mapped = mm[1];
39
+ const idm = line.match(/@@id\(\s*\[([^\]]+)\]/);
40
+ if (idm) t.compositeId = idm[1].split(',').map(s => s.trim());
41
+ continue;
42
+ }
43
+ const fm = line.match(/^(\w+)\s+(\w+)(\[\])?(\?)?\s*(.*)$/);
44
+ if (!fm) continue;
45
+ const [, fname, ftype, isList, optional, attrs] = fm;
46
+
47
+ const relm = attrs.match(/@relation\(([^)]*)\)/);
48
+ const isScalar = SCALARS.has(ftype) || enums.has(ftype);
49
+
50
+ if (isScalar) {
51
+ t.columns.push({
52
+ name: fname,
53
+ type: ftype + (isList ? '[]' : ''),
54
+ pk: /@id\b/.test(attrs),
55
+ notNull: !optional && !isList,
56
+ note: (attrs.match(/@map\("([^"]+)"\)/) || [, ''])[1] || fname,
57
+ });
58
+ continue;
59
+ }
60
+
61
+ // 관계 필드 — 목록(1:N 의 N쪽)은 상대편에 FK 가 있으므로 여기선 그리지 않는다
62
+ if (isList) continue;
63
+ if (!relm) {
64
+ // @relation 없는 단일 관계 — FK 컬럼을 특정할 수 없다
65
+ rels.push({ target: ftype, fields: null, refs: null, raw: line });
66
+ continue;
67
+ }
68
+ const fields = (relm[1].match(/fields\s*:\s*\[([^\]]*)\]/) || [, ''])[1]
69
+ .split(',').map(s => s.trim()).filter(Boolean);
70
+ const refs = (relm[1].match(/references\s*:\s*\[([^\]]*)\]/) || [, ''])[1]
71
+ .split(',').map(s => s.trim()).filter(Boolean);
72
+ rels.push({ target: ftype, fields, refs, raw: line });
73
+ }
74
+
75
+ models.set(name, { t, rels });
76
+ }
77
+
78
+ for (const { t } of models.values()) ir.tables.push(t);
79
+
80
+ for (const { t, rels } of models.values()) {
81
+ for (const r of rels) {
82
+ if (!models.has(r.target)) {
83
+ ir.warnings.push(t.name + ': 모델을 찾지 못한 관계 대상 ' + r.target);
84
+ continue;
85
+ }
86
+ if (!r.fields || !r.fields.length) {
87
+ ir.warnings.push(t.name + ' → ' + r.target +
88
+ ': @relation 에 fields 가 없어 외래키 컬럼을 특정할 수 없습니다 (Ref 로 그리지 않음)');
89
+ continue;
90
+ }
91
+ r.fields.forEach((f, i) => {
92
+ ir.refs.push({
93
+ from: t.name, col: f, to: r.target, toCol: (r.refs && r.refs[i]) || null,
94
+ evidence: r.raw, confidence: 'fact',
95
+ });
96
+ });
97
+ }
98
+ }
99
+
100
+ if (!ir.tables.length) ir.warnings.push('model 블록을 찾지 못했습니다');
101
+ return ir;
102
+ }