@m1kapp/kit 0.0.37 → 0.0.39

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.
@@ -0,0 +1,130 @@
1
+ /**
2
+ * 프로젝트 종류 감지 + 트래커 slug 배선.
3
+ *
4
+ * `m1kkit track --write` 와 `m1kkit new` 가 공유한다. 둘 다 "slug를 발급받는 것"
5
+ * 까지는 쉬운데 "그 slug를 앱 코드에 꽂는 것"이 지금까지 비어 있던 단계라,
6
+ * 그 한 단계를 여기 한 곳에 모아둔다.
7
+ */
8
+ import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from "fs";
9
+ import { join, relative } from "path";
10
+
11
+ const SKIP_DIRS = new Set(["node_modules", "dist", "build", ".next", ".git", "coverage", ".vercel"]);
12
+ const CODE_EXT = /\.(tsx|jsx)$/;
13
+
14
+ /**
15
+ * Vite / Next 중 무엇인지 판별한다. env 변수 이름이 서로 달라서(그리고 서로의
16
+ * 이름을 쓰면 조용히 무시되기만 해서) 안내문과 .env 배선이 갈린다.
17
+ */
18
+ export function detectProject(cwd = process.cwd()) {
19
+ const has = (f) => existsSync(join(cwd, f));
20
+ const pkgPath = join(cwd, "package.json");
21
+ let deps = {};
22
+ if (existsSync(pkgPath)) {
23
+ try {
24
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
25
+ deps = { ...pkg.dependencies, ...pkg.devDependencies };
26
+ } catch {
27
+ /* 깨진 package.json — 파일 존재 여부로만 판별 */
28
+ }
29
+ }
30
+
31
+ const isVite =
32
+ has("vite.config.ts") || has("vite.config.js") || has("vite.config.mjs") || !!deps.vite;
33
+ const isNext = has("next.config.ts") || has("next.config.js") || has("next.config.mjs") || !!deps.next;
34
+
35
+ // 둘 다면 Next 우선 — Next 프로젝트가 vite를 도구로 물고 있는 경우가 더 흔하다.
36
+ if (isNext) return { kind: "next", envVar: "NEXT_PUBLIC_M1K_SLUG" };
37
+ if (isVite) return { kind: "vite", envVar: "VITE_M1K_SLUG" };
38
+ return { kind: "unknown", envVar: null };
39
+ }
40
+
41
+ /** `<Watermark` 를 쓰는 첫 소스 파일을 찾는다. */
42
+ export function findWatermarkFile(cwd = process.cwd()) {
43
+ const walk = (dir) => {
44
+ let entries;
45
+ try {
46
+ entries = readdirSync(dir);
47
+ } catch {
48
+ return null;
49
+ }
50
+ for (const name of entries) {
51
+ if (SKIP_DIRS.has(name) || name.startsWith(".")) continue;
52
+ const full = join(dir, name);
53
+ let st;
54
+ try {
55
+ st = statSync(full);
56
+ } catch {
57
+ continue;
58
+ }
59
+ if (st.isDirectory()) {
60
+ const found = walk(full);
61
+ if (found) return found;
62
+ } else if (CODE_EXT.test(name)) {
63
+ let src;
64
+ try {
65
+ src = readFileSync(full, "utf8");
66
+ } catch {
67
+ continue;
68
+ }
69
+ if (src.includes("<Watermark")) return full;
70
+ }
71
+ }
72
+ return null;
73
+ };
74
+ return walk(cwd);
75
+ }
76
+
77
+ /** `.env` 에 `KEY=value` 를 병합한다(기존 키는 덮어씀). */
78
+ export function upsertEnv(cwd, key, value) {
79
+ const file = join(cwd, ".env");
80
+ const line = `${key}=${value}`;
81
+ if (!existsSync(file)) {
82
+ writeFileSync(file, line + "\n");
83
+ return { file, action: "created" };
84
+ }
85
+ const src = readFileSync(file, "utf8");
86
+ const re = new RegExp(`^${key}=.*$`, "m");
87
+ if (re.test(src)) {
88
+ if (src.match(re)[0] === line) return { file, action: "unchanged" };
89
+ writeFileSync(file, src.replace(re, line));
90
+ return { file, action: "updated" };
91
+ }
92
+ writeFileSync(file, src.endsWith("\n") ? src + line + "\n" : src + "\n" + line + "\n");
93
+ return { file, action: "appended" };
94
+ }
95
+
96
+ /**
97
+ * slug를 앱에 실제로 연결한다. `.env` 기록 + `<Watermark>` 패치를 둘 다 한다.
98
+ *
99
+ * `<Watermark>` 에 이미 `trackSlug` 가 있으면(템플릿이 심어둔
100
+ * `trackSlug={import.meta.env.VITE_M1K_SLUG}` 포함) 건드리지 않고 `.env` 만 쓴다.
101
+ * 없으면 문자열 리터럴을 박는다 — 번들러 종류와 무관하게 항상 동작하는 유일한
102
+ * 형태라서다. (Vite는 `import.meta.env` 를 앱 소스에서만 치환하므로 kit 내부
103
+ * 폴백은 존재할 수 없고, 남의 프로젝트 구조를 가정할 수도 없다.)
104
+ * slug 자체는 공개값이라 하드코딩해도 문제없다.
105
+ */
106
+ export function wireSlug(cwd, slug, { envOnly = false } = {}) {
107
+ const project = detectProject(cwd);
108
+ const report = { kind: project.kind, patched: null, env: null, alreadyWired: false };
109
+
110
+ if (project.envVar) report.env = { ...upsertEnv(cwd, project.envVar, slug), key: project.envVar };
111
+
112
+ if (envOnly) return report;
113
+
114
+ const file = findWatermarkFile(cwd);
115
+ if (!file) return report;
116
+
117
+ const src = readFileSync(file, "utf8");
118
+ if (/<Watermark[^>]*\btrackSlug=/.test(src)) {
119
+ report.alreadyWired = true;
120
+ report.patched = relative(cwd, file);
121
+ return report;
122
+ }
123
+
124
+ const patched = src.replace(/<Watermark(?=[\s>])/, `<Watermark trackSlug="${slug}"`);
125
+ if (patched === src) return report;
126
+
127
+ writeFileSync(file, patched);
128
+ report.patched = relative(cwd, file);
129
+ return report;
130
+ }
package/bin/m1kkit.mjs CHANGED
@@ -15,22 +15,25 @@ if (!command || command === "--help" || command === "-h") {
15
15
  m1kkit — @m1kapp/kit CLI
16
16
 
17
17
  Commands:
18
+ m1kkit new <name> 새 앱 생성 (Vite + React + kit 앱쉘 템플릿)
18
19
  m1kkit favicon [options] 파비콘 자동 생성
19
20
  m1kkit skills [options] Claude Code 스킬 설치
20
21
  m1kkit stats [options] 코드 분석 & kit 사용 현황 생성
21
22
  m1kkit track <url> m1k 방문자 트래커에 사이트 등록(무로그인)
23
+ m1kkit untrack [slug] 등록 취소 — 사이트와 누적 기록 삭제
22
24
  m1kkit claim [--token=x] 등록한 사이트를 내 계정에 귀속
23
25
 
24
26
  Options:
25
27
  --help 도움말 보기
26
28
 
27
29
  Examples:
30
+ m1kkit new moodlog --color=#e2603f
28
31
  m1kkit favicon --text=K --color=#3B82F6
29
32
  m1kkit skills
30
33
  m1kkit skills --list
31
34
  m1kkit skills m1kapp-init
32
35
  m1kkit stats --dir=src --out=public
33
- m1kkit track https://myside.app
36
+ m1kkit track https://myside.app --write
34
37
  m1kkit claim
35
38
  `);
36
39
  process.exit(0);
@@ -43,7 +46,10 @@ const path = await import("path");
43
46
 
44
47
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
45
48
 
46
- if (command === "favicon") {
49
+ if (command === "new") {
50
+ process.argv = [process.argv[0], process.argv[1], ...rest];
51
+ await import(path.join(__dirname, "new.mjs"));
52
+ } else if (command === "favicon") {
47
53
  process.argv = [process.argv[0], process.argv[1], ...rest];
48
54
  await import(path.join(__dirname, "favicon.mjs"));
49
55
  } else if (command === "skills") {
@@ -55,6 +61,9 @@ if (command === "favicon") {
55
61
  } else if (command === "track") {
56
62
  process.argv = [process.argv[0], process.argv[1], ...rest];
57
63
  await import(path.join(__dirname, "track.mjs"));
64
+ } else if (command === "untrack") {
65
+ process.argv = [process.argv[0], process.argv[1], ...rest];
66
+ await import(path.join(__dirname, "untrack.mjs"));
58
67
  } else if (command === "claim") {
59
68
  process.argv = [process.argv[0], process.argv[1], ...rest];
60
69
  await import(path.join(__dirname, "claim.mjs"));
package/bin/new.mjs ADDED
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * m1kkit new <name> [--color=#hex] [--title=..] [--desc=..] [--url=..]
4
+ * [--no-install] [--yes]
5
+ *
6
+ * 검증된 템플릿을 복사해서 새 Vite + @m1kapp/kit 앱을 만든다.
7
+ *
8
+ * 스킬(LLM이 산문을 읽고 매번 코드를 다시 타이핑하는 방식)이 반복해서 깨뜨렸던
9
+ * 것들 — `useState(colors.blue)` 리터럴 협착, `vite-env.d.ts` 누락, TODO 주석만
10
+ * 남은 빈 화면 — 을 파일로 고정해두고 그대로 복사한다.
11
+ */
12
+ import { readdirSync, statSync, mkdirSync, readFileSync, writeFileSync, existsSync } from "fs";
13
+ import { join, dirname, resolve, basename } from "path";
14
+ import { fileURLToPath } from "url";
15
+ import { spawnSync } from "child_process";
16
+ import { createInterface } from "readline";
17
+
18
+ const __dirname = dirname(fileURLToPath(import.meta.url));
19
+ const { wireSlug } = await import(join(__dirname, "lib", "project.mjs"));
20
+
21
+ const args = process.argv.slice(2);
22
+ const flags = Object.fromEntries(
23
+ args
24
+ .filter((a) => a.startsWith("--"))
25
+ .map((a) => {
26
+ const [k, v] = a.replace(/^--/, "").split("=");
27
+ return [k, v ?? true];
28
+ }),
29
+ );
30
+ const positional = args.filter((a) => !a.startsWith("--"));
31
+
32
+ if (flags.help) {
33
+ console.log(`
34
+ m1kkit new — 새 m1kapp 앱 만들기 (Vite + React + @m1kapp/kit)
35
+
36
+ 사용법:
37
+ npx m1kkit new <이름> [옵션]
38
+
39
+ 옵션:
40
+ --title=<이름> 화면/타이틀에 뜰 이름 (기본: <이름>)
41
+ --desc=<한줄> 한 줄 설명 (meta description)
42
+ --color=#3B82F6 테마 컬러
43
+ --url=<배포URL> 있으면 방문자 트래커를 지금 등록하고 코드에 배선
44
+ --slug=<slug> 이미 발급받은 slug — 재등록 없이 배선만
45
+ --no-install npm install 건너뛰기
46
+ --yes 질문 없이 기본값으로 진행
47
+
48
+ 예시:
49
+ npx m1kkit new moodlog
50
+ npx m1kkit new moodlog --color=#e2603f --url=https://moodlog.m1k.app
51
+ `);
52
+ process.exit(0);
53
+ }
54
+
55
+ const TEMPLATE = join(__dirname, "..", "templates", "vite-app");
56
+ if (!existsSync(TEMPLATE)) {
57
+ console.error(`✗ 템플릿을 못 찾음: ${TEMPLATE}`);
58
+ process.exit(1);
59
+ }
60
+
61
+ /* ── 아주 약한 grill: 4문항, 전부 기본값 있음 ─────────────────────────── */
62
+
63
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
64
+ const ask = (q, fallback) =>
65
+ new Promise((res) => {
66
+ if (flags.yes || !process.stdin.isTTY) return res(fallback);
67
+ rl.question(`${q}${fallback ? ` (${fallback})` : ""}: `, (a) => res(a.trim() || fallback));
68
+ });
69
+
70
+ const name = positional[0] || (await ask("앱 이름(폴더명)", "my-m1kapp"));
71
+ const title = flags.title || (await ask("화면에 뜰 이름", name));
72
+ const desc = flags.desc || (await ask("한 줄 설명", `${title} — m1kapp으로 만든 사이드 프로젝트`));
73
+ const color = flags.color || (await ask("테마 컬러(hex)", "#3B82F6"));
74
+ const url = flags.url || (await ask("배포 URL — 지금 알면 방문자 트래커를 바로 붙임(없으면 엔터)", ""));
75
+ rl.close();
76
+
77
+ if (!/^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/.test(color)) {
78
+ console.error(`✗ 테마 컬러가 hex가 아님: ${color}`);
79
+ process.exit(1);
80
+ }
81
+
82
+ const target = resolve(process.cwd(), name);
83
+ if (existsSync(target) && readdirSync(target).length > 0) {
84
+ console.error(`✗ 이미 있고 비어있지 않은 폴더: ${target}`);
85
+ process.exit(1);
86
+ }
87
+
88
+ /* ── 템플릿 복사 + 치환 ──────────────────────────────────────────────── */
89
+
90
+ const SUBST = {
91
+ __APP_NAME__: basename(name),
92
+ __TITLE__: title,
93
+ __DESC__: desc,
94
+ __COLOR__: color,
95
+ };
96
+ const TEXT_EXT = /\.(tsx?|jsx?|css|html|json|md)$/;
97
+ // npm pack 이 `.gitignore` / 중첩 `package.json` 을 특수 취급하므로 템플릿에는
98
+ // 밑줄 접두사로 넣어두고 복사할 때 되돌린다.
99
+ const RENAME = { _gitignore: ".gitignore", _package_json: "package.json", "_package.json": "package.json" };
100
+
101
+ function copyTree(from, to) {
102
+ mkdirSync(to, { recursive: true });
103
+ for (const entry of readdirSync(from)) {
104
+ const src = join(from, entry);
105
+ const dst = join(to, RENAME[entry] ?? entry);
106
+ if (statSync(src).isDirectory()) {
107
+ copyTree(src, dst);
108
+ continue;
109
+ }
110
+ let content = readFileSync(src, TEXT_EXT.test(entry) || RENAME[entry] ? "utf8" : null);
111
+ if (typeof content === "string") {
112
+ for (const [k, v] of Object.entries(SUBST)) content = content.split(k).join(v);
113
+ }
114
+ writeFileSync(dst, content);
115
+ }
116
+ }
117
+
118
+ copyTree(TEMPLATE, target);
119
+ console.log(`\n✓ ${name}/ 생성 완료`);
120
+
121
+ /* ── 설치 ────────────────────────────────────────────────────────────── */
122
+
123
+ const run = (cmd, cmdArgs) =>
124
+ spawnSync(cmd, cmdArgs, { cwd: target, stdio: "inherit", shell: process.platform === "win32" });
125
+
126
+ if (!flags["no-install"]) {
127
+ console.log(`\n› npm install …`);
128
+ const r = run("npm", ["install"]);
129
+ if (r.status !== 0) {
130
+ console.error(`\n✗ npm install 실패 — ${target} 에서 직접 다시 돌려주세요.`);
131
+ process.exit(1);
132
+ }
133
+ }
134
+
135
+ /* ── 방문자 트래커 배선 ──────────────────────────────────────────────── */
136
+
137
+ if (flags.slug) {
138
+ // 이미 발급받은 slug가 있는 경우 — 재등록 없이 배선만.
139
+ const r = wireSlug(target, flags.slug);
140
+ console.log(`\n✓ 트래커 배선: ${r.patched ?? "(Watermark 파일 못 찾음)"} + .env`);
141
+ } else if (url) {
142
+ console.log(`\n› 방문자 트래커 등록 …`);
143
+ const r = run("npx", ["m1kkit", "track", url, "--write"]);
144
+ if (r.status !== 0) {
145
+ console.log(` ! 등록 실패 — 나중에: npx m1kkit track ${url} --write`);
146
+ }
147
+ } else {
148
+ console.log(`
149
+ · 방문자 트래커는 아직 안 붙었습니다(배포 URL 미정).
150
+ 배포하고 나서 프로젝트 폴더에서 딱 한 줄:
151
+ npx m1kkit track <배포URL> --write`);
152
+ }
153
+
154
+ console.log(`
155
+ 다음 단계:
156
+ cd ${name}
157
+ npm run dev
158
+
159
+ npm run build 타입 + 번들 검증
160
+ npx m1kkit favicon 파비콘 생성
161
+ npx m1kkit claim 트래커를 내 계정에 귀속
162
+ `);
@@ -1,86 +1,166 @@
1
- `m1kkit stats`의 청결도 점수(quality)를 근거로 코드를 **동작 변화 없이** 정리해 등급을 올린다. 목표: 복잡 함수(CC 10+)를 줄이고, 200줄+ 파일을 쪼개고, 재실행으로 개선을 수치로 증명한다.
1
+ `npx m1kkit stats`(내부적으로 fixearly)가 낸 점수를 근거로, **동작을 바꾸지 않고** 코드를 정리한다.
2
+ 목적은 등급 숫자가 아니라 **다음에 이 코드를 고칠 때 드는 비용**을 줄이는 것이다.
2
3
 
3
- 점수 구조 v2 (AST 모드, 100점 감점제):
4
- - cognitive>15 함수 비율 ×3 (최대 -25) / cognitive>25 함수 비율 ×5 (최대 -15)
5
- - 최악 함수 cognitive 20 초과분 ×1 (최대 -15)
6
- - **중복 밀도 3% 초과분 ×2.5 (최대 -25)** — 토큰 단위 복붙 검출 (식별자 정규화, 테스트 제외)
7
- - 200줄 초과 파일 비율 ×2 (최대 -10) / 평균 파일 길이 80줄 초과분 ÷5 (최대 -10)
8
- - **루프 안 무캐시 파일읽기 ×4 (최대 -20)** — 호출 1번이 파일을 N번 읽는 구조 (`quality.io`)
9
- - **렌더 인질 ×3 (최대 -10)** — fetch 하나가 무관한 UI까지 막는 자리 (`quality.renderGates`)
4
+ ---
5
+
6
+ ## 먼저 — 고치지 말아야 할 때
7
+
8
+ 작업을 시작하기 전에 이 넷을 확인한다. 하나라도 걸리면 **그 항목은 건너뛴다.**
9
+
10
+ 1. **동결된 저장소** — 리포트 상단에 "이 저장소는 지금 고칠 이유가 없다"(최근 6개월 변경 0건)가 뜨면
11
+ 손대지 않는다. 변경 비용은 변경이 일어날 때만 발생한다.
12
+ 2. **`안정` 태그가 붙은 항목** — 그 파일은 최근에 아무도 안 만졌다. 복잡해도 리팩터 ROI가 낮다.
13
+ 3. **선언적 마크업** — `cog ≤ 5`인데 길기만 한 함수(OG 이미지, 공유 카드, 랜딩 히어로).
14
+ 분기가 없는 JSX 트리는 쪼개도 읽기가 안 나아진다. 스타일 상수와 데이터를 props로 릴레이하느라
15
+ 오히려 결합만 는다. **실측: 긴 함수 목록의 약 절반이 여기 해당한다.**
16
+ 4. **평평한 가드 파이프라인** — 리포트에 `분기 대부분이 빠져나가는 가드 절이다(NN%)` 가
17
+ 붙은 항목. `if (!url) return 400;` 이 여덟 줄 늘어선 요청 핸들러가 전형이다.
18
+ cog 는 이걸 3단 중첩과 같은 점수로 매기지만, 가드는 하나씩 읽고 잊으면 되므로
19
+ 머리에 안 쌓인다. 헬퍼 뒤로 숨기면 요청 생명주기가 오히려 안 보인다.
20
+ 고칠 게 있다면 가드가 아니라 **그 뒤 본문의 중첩**이다.
21
+
22
+ > 지표는 언제나 뭔가를 지적할 수 있다. 도구가 일감을 만들어내게 두지 마라.
23
+
24
+ ---
25
+
26
+ ## 점수 구조 (규칙 v7 · 100점 감점제 · 캡 기준 배점)
27
+
28
+ | 축 | 배점 | 내용 |
29
+ |---|---|---|
30
+ | **함수 길이** | **23** | 40줄 초과(JSX 60줄) 비율 16 · p90 3 · 상위10 평균 4 |
31
+ | **인지 복잡도** | **37** | cog15+ 12 · cog25+ 12 · p90 9 · 상위10 평균 4 |
32
+ | **중복** | **9** | 8% 초과분만. 70개 코퍼스에서 점수와 상관 −0.11 |
33
+ | **파일 크기** | **8** | 평균 줄 5 · 200줄+ 비중 3 |
10
34
 
11
- cognitive = 중첩 깊이 가중 복잡도 (SonarQube 방식): `a && b && c`는 1, 5중 중첩 if는 flat if 5개보다 훨씬 비쌈. `??`는 미카운트. 즉 **평평하게 펴는 것(조기 반환)이 진짜 점수가 됨** — 함수 쪼개기만으론 안 오름.
35
+ **우선순위가 예전과 바뀌었다.** 옛 스킬은 "① 중복 제거"부터 시켰지만, 중복은 배점 9점에
36
+ 대부분의 저장소에서 0점 기여다(롱테일 — 소수만 크게 맞는다). 지금 실제로 점수를 가르는 건
37
+ **함수 길이**와 **복잡도 분포**다.
12
38
 
13
- `io` 축은 나머지와 성격이 다르다. cognitive·중복은 "코드가 얼마나 꼬였나"라 5줄짜리
14
- 흠 없는 리더가 루프에서 120번 불리는 종류는 **원리상 못 잡는다** — 실제로 요청당 파일을
15
- 492번 읽던 코드가 A+ 100점을 통과한 적이 있다. 그래서 파일을 읽는 함수(한 다리 건너
16
- 읽는 얇은 래퍼 포함)를 찾아 루프·`map` 안에서 불리는 자리를 센다. 그 리더가 캐시를
17
- 끼고 있으면(모듈 Map을 get/set) 반복 호출돼도 실제 읽기는 한 번이라 감점하지 않는다.
18
- **고치는 법은 리팩토링이 아니라 캐시를 끼우거나 한 번 읽어 재사용하도록 구조를 바꾸는 것** —
19
- `quality.io.worst`가 위치를 지목한다.
39
+ **분포 항이라는 점이 중요하다.** 최악 함수 하나를 고쳐도 거의 안 움직인다 —
40
+ 상위 10개 평균을 쓰기 때문에 **여러 개를 실제로 줄여야** 내려간다.
20
41
 
21
- `renderGates`도 같은 성격이다. `{data && <Nav a={local} b={data.x} onChange={fn} />}`처럼
22
- 프롭 3개 중 1개만 그 데이터에 의존하는데 전체가 대기하면, 사용자에겐 이미 아는 UI까지
23
- 매번 로딩으로 보인다. 데이터에 의존하는 부분만 늦게 채우고 나머지는 먼저 그리는 게 답이다
24
- (서버가 아는 값이면 아예 서버에서 내려주면 된다).
42
+ **그리고 점수가 안 움직이는 게 정상인 구간이 있다.** 대부분의 축은 코퍼스 중앙값까지
43
+ 유예라, 중앙보다 좋은 저장소에선 이미 감점 0 — 더 좋아져도 뺄 게 없다. 이게 v7 에서
44
+ `p90` 항을 6점에서 3점으로 줄인 이유다. 남은 축이 그것뿐이라 순위통계 한 줄이 밀리는
45
+ 것만으로 점수가 뒤집혔다(실측: cog15+ 함수를 저장소에서 전부 없앴는데 순이익 −1점).
25
46
 
26
- 즉 **효율 순서: ① 중복 블록 제거 (kit-stats.json `quality.duplication.worstBlocks`가 위치 지목) ② 최악 함수 중첩 평탄화 ③ cog15+ 함수들 ④ 긴 파일 분할**.
47
+ cognitive = 중첩 깊이 가중(SonarSource S3776). `a && b && c`는 1, 5중 중첩 if는 flat if 5개보다
48
+ 훨씬 비싸다. 즉 **평평하게 펴는 것**이 점수가 되지, 함수만 쪼갠다고 오르지 않는다.
27
49
 
28
- `--llm` 플래그(선택): cognitive 최악 3개를 Claude(haiku)가 네이밍·응집도·본질적 복잡성 여부 자문 — 점수 미반영, `quality.llm`에 기록. "essential: true"로 나온 함수는 억지로 쪼개지 말 것.
50
+ ### 점수에 반영되지 않는 진단 (등급과 무관, 그래도 진짜 결함)
51
+
52
+ `O(n²) 배열 조회` · `루프 안 파일 IO/N+1` · `await in forEach` · `스프레드 누적` ·
53
+ `루프 안 new RegExp` · `공유 참조 fill` · `빈 catch` · `타입 위생`.
54
+
55
+ 특히 **O(n²)와 N+1은 n이 실제로 큰지 사람이 확인해야** 의미가 있다. 상수 목록·설정값을
56
+ 도는 자리면 고치지 말고 그 근거를 적어라. 그래서 점수에서 뺐다.
29
57
 
30
58
  ---
31
59
 
32
60
  ## Step 0: 진단
33
61
 
34
62
  ```bash
35
- npx m1kkit stats # 또는 프로젝트에 맞는 --dir/--out (package.json scripts.stats 참고)
63
+ npm run stats:report # 또는 npx m1kkit stats --report
36
64
  ```
37
65
 
38
- - 콘솔의 `복잡: <함수명> CC <n> — <파일>:<줄>` 목록 = 작업 대상 우선순위.
39
- - `public/kit-stats.json`의 `quality.cc.worst`(상위 5개), `over10`/`over20`, `longFiles`, `maxFile`도 확인.
40
- - `quality.engine`이 `"regex"`면 typescript가 devDependencies에 없는 것 — 설치하면 AST 정밀 분석으로 전환된다.
41
- - 시작 점수·등급을 기록해 둔다 (개선 전후 비교용).
66
+ 리포트(HTML)에서 이 순서로 읽는다:
67
+
68
+ 1. **동결 배너** — 있으면 여기서 멈춘다.
69
+ 2. **어디쯤인가** — 같은 체급 오픈소스 안에서 내 위치. 이미 체급 중앙 이상이면 급하지 않다.
70
+ 3. **무엇부터 고칠까** — **변경 비용 순**(복잡도 × churn)으로 정렬돼 있다. 점수 순이 아니다.
71
+ 각 줄의 `점수 영향` 칩을 본다: `점수 무관`이면 등급은 안 오르지만 진짜 결함이라는 뜻이다.
72
+ 4. **점수 지도** — `이미 지켜낸 축`(감점 0)은 건드리지 않는다.
73
+
74
+ 각 항목의 **[지시문]** 버튼이 그 결함 하나만 고치는 작업 지시문을 준다. 그대로 쓰면 된다.
75
+
76
+ ---
77
+
78
+ ## Step 1: 고치기 — 우선순위 순
79
+
80
+ ### ① 긴 함수 (배점 최대)
42
81
 
43
- ## Step 1: 리팩토링 — CC 낮추는 표준 기법만
82
+ **단, 위 "고치지 말아야 할 때" 3번을 먼저 통과해야 한다.** `cog ≤ 5`면 마크업이다.
44
83
 
45
- 대상 함수를 열고 아래 순서로 적용. **기능·출력·API 시그니처 불변**이 절대 조건.
84
+ - **로직과 렌더를 분리** — 상태·핸들러 뭉치를 커스텀 훅으로. `useXxxGame()`, `useFilters()`처럼
85
+ 이름이 자연스럽게 붙으면 옳은 분할이다.
86
+ - **상수를 밖으로** — 함수 안에 인라인된 딕셔너리·설정 테이블은 모듈 상수로.
87
+ - **독립 단계 추출** — 파싱/검증/변환/포맷처럼 한 덩어리로 이름 붙는 단계.
46
88
 
47
- 1. **조기 반환(guard clause)**: 중첩 if를 `if (!x) return`으로 평탄화 — CC는 그대로여도 가독성↑, 보통 else 제거로 CC↓
48
- 2. **분기 → 데이터**: if/else if 체인·switch를 lookup 객체/Map/배열로. `조건 ? a : b` 체인도 테이블화. CC 감소 효과 최대
49
- 3. **헬퍼 추출**: 한 함수 안의 독립 단계(파싱/검증/변환/포맷)를 이름 있는 함수로 분리 — 함수당 CC가 나뉜다. 단, 한 곳에서만 쓰는 3줄짜리를 억지로 쪼개지 말 것
50
- 4. **`&&`/`||` 체인 → 이름 있는 boolean**: `const isStale = a && b || c` 처럼 의미 단위로 묶기 (CC 총량은 유지되지만 함수 분리와 병행 시 분산)
51
- 5. **긴 컴포넌트 분할**: 200줄+ 파일은 렌더 섹션별 서브컴포넌트/훅으로. props drilling이 3단계 넘게 생기면 무리한 분할이니 중단
52
- 6. **kit 활용**: 직접 짠 로직이 kit 컴포넌트/유틸(`AsyncList`, `fillDateSeries`, `formatKoreanNumber` 등)로 대체되면 코드 자체가 사라진다 — 청결도와 kit 반영율 동시 상승
89
+ > **잘라낸 함수에 이름을 붙일 수 없으면 자른 게 아니다.** `Part1`·`Section2`가 나오면 되돌려라.
53
90
 
54
- **금지**: 과추상화(1회용 제네릭 헬퍼, 설정 객체 패턴 남발), 동작 바뀌는 "개선", 테스트 없는 공개 API 시그니처 변경, CC 점수만 노린 의미 없는 함수 쪼개기.
91
+ ### ② 복잡도 (cog 15+ / 25+)
55
92
 
56
- ## Step 2: 검증
93
+ 리포트가 각 항목에 `깊이 N` 을 적는다. **깊이가 목표지 cog 숫자가 목표가 아니다.**
94
+ 같은 cog 라도 깊이 2와 깊이 5는 다른 문제다. 위 "고치지 말아야 할 때" 4번(가드 파이프라인)을
95
+ 먼저 통과했는지 확인하라.
96
+
97
+ - **조기 반환(guard clause)** — 중첩 if를 `if (!x) return`으로. 깊이가 줄면 cog가 크게 떨어진다.
98
+ 주의: 조기 반환으로 편 결과는 cog 가 거의 안 줄 수도 있다(가드도 +1씩 센다).
99
+ 그래도 그게 옳은 방향이다 — 깊이가 줄었으면 성공이다.
100
+ - **분기 → 데이터** — if/else 체인·switch를 lookup 객체나 Map으로. 효과 최대.
101
+ - **`&&`/`||` 체인 → 이름 있는 boolean** — 의미 단위로 묶어 읽기를 낮춘다.
102
+
103
+ ### ③ 중복 (8% 넘을 때만)
104
+
105
+ **같은 이유로 함께 바뀔 코드만** 합친다. 우연히 닮은 두 모듈에서 공용 헬퍼를 뽑으면
106
+ 결합도만 오른다. 빌드 타깃별 의도적 사본은 그대로 둔다.
107
+
108
+ ### ④ 파일 크기 (배점 8 — 마지막)
109
+
110
+ **여기서 점수를 벌 생각이면 그만두는 게 낫다.** 같은 코드를 6파일로 쪼개기만 해도 옛 공식은
111
+ +27점을 줬고, 그래서 배점을 8점으로 낮췄다. 파일이 긴 진짜 이유(한 파일에 여러 책임)를
112
+ 먼저 확인하고, 책임이 실제로 여러 개일 때만 나눈다.
113
+
114
+ ### ⑤ kit 활용
115
+
116
+ 직접 짠 로직이 kit 컴포넌트·유틸로 대체되면 코드 자체가 사라진다 — 점수와 kit 반영율이 함께 오른다.
117
+ `--kit` 집계의 `kit 사용: N개 요소`가 낮은데 비슷한 걸 직접 짜고 있으면 후보다.
118
+
119
+ **금지**: 과추상화(1회용 제네릭 헬퍼, 설정 객체 남발), 동작이 바뀌는 "개선",
120
+ 테스트 없는 공개 API 시그니처 변경, 지표만 노린 기계적 분할, 쓰레기 헬퍼로 분모 부풀리기.
121
+
122
+ ---
123
+
124
+ ## Step 2: 검증 — 동작 불변을 증명한다
57
125
 
58
126
  ```bash
59
127
  npx tsc --noEmit && npm run build
60
128
  ```
61
129
 
62
- - 둘 다 통과해야 다음 함수로. 테스트 있으면 테스트도.
63
- - 리팩토링 전후 해당 화면/API 동작을 실제로 확인 (dev 서버·curl).
130
+ - 둘 다 통과해야 다음 항목으로. 테스트가 있으면 테스트도.
131
+ - 화면/API를 실제로 확인한다(dev 서버·curl).
132
+ - **확인 안 했으면 "확인 안 함"이라고 적는다.** 추측으로 "개선했다"고 쓰지 않는다.
133
+
134
+ ---
64
135
 
65
- ## Step 3: 재측정 · 반복
136
+ ## Step 3: 재측정 — 변화량으로 증명
66
137
 
67
138
  ```bash
68
- npx m1kkit stats
139
+ npm run stats:report
69
140
  ```
70
141
 
71
- - 점수·등급 변화를 확인. 오르지 않았으면 감점 구조를 다시 보고 다른 항목 공략.
72
- - 기본 목표: **B(70) 이상**, 여유 되면 A(80). 사용자가 목표 등급을 줬으면 그걸 따른다.
73
- - 한 사이클(함수 1~3개)마다 커밋 — 대량 일괄 리팩토링 금지 (리뷰·롤백 불가능해짐).
142
+ 리포트의 **지난번 대비** 섹션이 전/후를 보여준다(`.fixearly-history.json`에 이력이 쌓인다).
143
+
144
+ **점수보다 원지표를 먼저 본다** — `긴 함수 661개 → 623개(−38)`, `함수 길이 상위10 평균 372 → 336줄`.
145
+ 점수는 절대 위치고, 변화량이 당신이 한 일이다. 자기 이력과의 비교라 지표만 건드려서는 안 움직인다.
146
+
147
+ - 한 사이클(항목 1~3개)마다 커밋. 대량 일괄 리팩터 금지 — 리뷰·롤백이 불가능해진다.
148
+ - 점수가 안 올랐는데 원지표가 좋아졌으면 **성공이다**. 분포 항이라 여러 개를 고쳐야 움직인다.
149
+
150
+ ---
74
151
 
75
152
  ## Step 4: 마무리
76
153
 
77
- - 갱신된 `public/kit-stats.json` 커밋 (배포된 사이트·m1k.app dev 모드에 새 등급 반영).
78
- - 최종 보고: 전/후 점수·등급, 처리한 함수 목록(CC 전→후), 남은 CC10+ 함수와 이유(예: "정당한 복잡도 — 상태 머신").
154
+ - 최종 보고: 전/후 점수·등급, **원지표 변화**, 처리한 항목 목록, 그리고 **안 고친 항목과 그 이유**.
155
+ - 안 고치는 판단도 결과물이다 — "마크업이라 분할 이득 없음", "n이 상수 목록이라 O(n²) 무의미",
156
+ "churn 0이라 ROI 낮음"은 모두 정당한 결론이다.
79
157
 
80
158
  ---
81
159
 
82
160
  ## 주의
83
161
 
84
- - CC가 높다고 전부 나쁜 코드는 아니다. 본질적으로 분기 많은 도메인(뱃지 SVG 분기, cron 집계)은 **데이터 테이블화가 안 되면 그대로 두고 보고에 사유 명시**.
85
- - `(anonymous)` 함수는 콜백/핸들러 — 이름 있는 함수로 빼는 것만으로 추적 가능해진다.
86
- - 점수는 상대 비교용 휴리스틱. 100점 만들기가 목적이 아니라 최악 지점 제거가 목적.
162
+ - 복잡도가 높다고 전부 나쁜 코드가 아니다. 본질적으로 분기 많은 도메인(상태 머신, SVG 분기,
163
+ 집계 크론)은 데이터 테이블화가 안 되면 **그대로 두고 사유를 적는다**.
164
+ - `(anonymous)` 함수는 콜백/핸들러다. 이름만 붙여도 추적이 된다.
165
+ - 점수는 **한 가지 렌즈**다. 설계·테스트·문서·보안·성능·커뮤니티는 재지 않는다.
166
+ 낮은 등급이 나쁜 프로젝트라는 뜻이 아니다.
@@ -0,0 +1,73 @@
1
+ ---
2
+ name: m1kapp-new
3
+ description: 새로운 m1kapp 서비스 프로젝트를 스캐폴딩 — m1kkit new 로 뼈대를 만들고, 컨셉에 맞게 첫 화면을 채운다
4
+ ---
5
+
6
+ `$ARGUMENTS` 위치에 새로운 m1kapp 서비스 프로젝트를 생성해줘.
7
+
8
+ **뼈대는 직접 타이핑하지 마라.** `npx m1kkit new`가 검증된 템플릿을 복사한다.
9
+ 이 스킬이 파일 내용을 산문으로 받아적던 시절엔 매번 같은 자리가 깨졌다 —
10
+ `useState(colors.blue)` 리터럴 협착으로 `tsc` 실패, `vite-env.d.ts` 누락으로
11
+ `import "./index.css"` 실패, 본문이 `{/* TODO */}` 하나뿐인 빈 화면.
12
+ 지금은 그게 전부 `templates/vite-app/` 안의 실제 파일로 고정되어 있고 빌드로 검증된다.
13
+
14
+ 네가 할 일은 **뼈대 위에 컨셉을 얹는 것**이다.
15
+
16
+ ## Step 1 — 아주 약한 grill (4문항)
17
+
18
+ 한 번에 묻는다. 인자로 이미 준 건 기본값으로 채워 제안.
19
+
20
+ 1. 앱 이름 (폴더명) + 화면에 뜰 이름
21
+ 2. 한 줄 컨셉 — 이게 첫 화면 구성을 정한다
22
+ 3. 테마 컬러 (hex, 없으면 `#3B82F6`)
23
+ 4. 배포 URL — 정해졌으면 지금. 그래야 방문자 트래커가 이 자리에서 붙는다. 미정이면 미정이라고만.
24
+
25
+ ## Step 2 — 뼈대 생성
26
+
27
+ ```bash
28
+ npx m1kkit new <이름> --title=<화면이름> --desc=<한줄> --color=<hex> [--url=<배포URL>] --yes
29
+ ```
30
+
31
+ - 생성 경로는 현재 디렉토리 하위. `/Users/minho/IdeaProjects/m1kapp/` 아래 만들 거면 거기서 실행한다.
32
+ - `--url`을 주면 `m1kkit track ... --write`까지 자동으로 돌아 `.env`에 `VITE_M1K_SLUG`가 박힌다.
33
+ - `--url` 없이 만들었으면 배포 후 프로젝트 폴더에서 한 줄:
34
+ `npx m1kkit track <배포URL> --write`
35
+
36
+ ## Step 3 — 컨셉에 맞게 첫 화면 채우기
37
+
38
+ 템플릿의 `src/App.tsx`는 홈/목록 두 탭 + StatChip + ListRow + EmptyState + Fab이
39
+ 더미 데이터로 이미 돌아가는 상태다. **이걸 지우고 새로 짜지 말고, 더미를 진짜로 갈아끼운다.**
40
+
41
+ 지킬 것:
42
+ - `Watermark > AppShell > AppShellHeader/AppShellContent/TabBar` 트리는 유지.
43
+ `Watermark`가 `h-dvh` + 중앙정렬 + 하단 `PoweredByKit` 크레딧을 담당한다 —
44
+ 빼면 콘텐츠 높이만큼 쪼그라들고 크레딧도 안 뜬다.
45
+ - `<Watermark trackSlug={import.meta.env.VITE_M1K_SLUG}>` 줄은 **건드리지 마라.**
46
+ Vite는 `import.meta.env`를 앱 소스에서만 치환한다 — kit 안에서는 못 읽으므로
47
+ 이 줄이 유일한 연결 지점이다.
48
+ - `AppShellContent` 안은 `<Section>` 블록을 `<Divider spacing="sm" />`로 구분해 쌓는다.
49
+ `<div className="p-4 flex flex-col gap-4">` 같은 손수 만든 wrapper 금지.
50
+ - 탭 이름·섹션 내용은 컨셉에 맞게 바꾸되, **비우지 마라.** `{/* TODO */}` 하나만
51
+ 남기고 끝내면 실패다. 데이터가 아직 없으면 `EmptyState`를, 숫자가 아직 없으면
52
+ `StatChip value={0}`을 쓴다.
53
+ - 테마색은 `themeColor` 상태 하나로 흐른다(`AppShell accent` / `Tab activeColor` /
54
+ `Fab color`). 하드코딩된 색을 새로 뿌리지 마라.
55
+
56
+ 자주 쓰는 kit 컴포넌트: 레이아웃 `AppShell/AppShellHeader/AppShellContent`,
57
+ 내비 `TabBar/Tab/Fab`, 표시 `Section/SectionHeader/StatChip/Badge/Avatar/EmptyState/
58
+ Divider/Skeleton/BarList/ProgressRing/ListRow/ActionCard`, 오버레이 `Dialog/InAppSheet/
59
+ ToastProvider·useToast/Tooltip`, 입력 `Button/IconButton/Input/Textarea/Field/Select/
60
+ Switch/SegmentedControl/EmojiButton/InlineEdit/ColorPicker`, 모션 `Typewriter/Countdown/Carousel`.
61
+
62
+ ## Step 4 — 확인하고 보고
63
+
64
+ 1. `npm run build` — 타입·번들 통과 확인 (반드시 프로젝트 루트에서)
65
+ 2. `npm run dev` 띄우고 **첫 화면을 눈으로 본 뒤** 보고한다:
66
+ - 워터마크 배경이 깔렸는가
67
+ - 앱쉘이 화면 중앙에 꽉 찼는가 (모바일 폭에서 가로 스크롤 0)
68
+ - 본문이 비어 있지 않은가
69
+ - 하단에 `powered by @m1kapp/kit` 줄이 뜨는가
70
+ - 트래커를 붙였다면 `www.m1k.app/badge/<slug>.svg` 요청이 나가는가
71
+ 3. `git init` + 첫 커밋 준비 (커밋은 사용자 확인 후)
72
+
73
+ 추가 컨텍스트: $ARGUMENTS
package/bin/skills.mjs CHANGED
@@ -16,9 +16,13 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
16
  const SKILLS_SRC = path.join(__dirname, "skills");
17
17
 
18
18
  const SKILLS = [
19
+ {
20
+ name: "m1kapp-new",
21
+ desc: "/m1kapp-new — 새 앱 만들기 (m1kkit new 템플릿 + 컨셉에 맞춰 첫 화면 채우기)",
22
+ },
19
23
  {
20
24
  name: "m1kapp-init",
21
- desc: "/m1kapp-init — Next.js 프로젝트 초기 설정 인터랙티브 스캐폴딩",
25
+ desc: "/m1kapp-init — 기존 Next.js 프로젝트에 kit 초기 설정 적용",
22
26
  },
23
27
  {
24
28
  name: "m1kapp-seo",