@mafianacho/nacholens 0.0.0-stage → 0.3.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 (69) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +13 -0
  3. package/README.de.md +46 -0
  4. package/README.fr.md +46 -0
  5. package/README.ja.md +46 -0
  6. package/README.ko.md +46 -0
  7. package/README.md +45 -2
  8. package/README.zh.md +46 -0
  9. package/demo/shop/v1/README.md +7 -0
  10. package/demo/shop/v1/api.js +78 -0
  11. package/demo/shop/v1/db.js +38 -0
  12. package/demo/shop/v1/gateway.js +50 -0
  13. package/demo/shop/v1/mock-external.js +17 -0
  14. package/demo/shop/v1/public/app.js +32 -0
  15. package/demo/shop/v1/public/format.js +2 -0
  16. package/demo/shop/v1/public/index.html +13 -0
  17. package/demo/shop/v1/public/styles.css +4 -0
  18. package/demo/shop/v2/README.md +7 -0
  19. package/demo/shop/v2/api.js +88 -0
  20. package/demo/shop/v2/db.js +38 -0
  21. package/demo/shop/v2/gateway.js +50 -0
  22. package/demo/shop/v2/mock-external.js +17 -0
  23. package/demo/shop/v2/public/app.js +33 -0
  24. package/demo/shop/v2/public/format.js +3 -0
  25. package/demo/shop/v2/public/index.html +13 -0
  26. package/demo/shop/v2/public/styles.css +5 -0
  27. package/docs/guide.ko.md +97 -0
  28. package/docs/guide.md +142 -0
  29. package/docs/model-schema.md +137 -0
  30. package/docs/review.md +78 -0
  31. package/examples/demo-shop.artifact.html +1535 -0
  32. package/examples/demo-shop.artifact.json +7 -0
  33. package/examples/demo-shop.html +1535 -0
  34. package/examples/external/README.md +12 -0
  35. package/examples/external/full-stack-fastapi-template/LICENSE +21 -0
  36. package/examples/external/full-stack-fastapi-template/changes.json +56 -0
  37. package/examples/external/full-stack-fastapi-template/full-stack-fastapi-template-backend.html +1535 -0
  38. package/models/demo-shop/changes.auto.json +121 -0
  39. package/models/demo-shop/changes.example.json +16 -0
  40. package/models/demo-shop/intent.example.json +75 -0
  41. package/models/demo-shop/model.json +3151 -0
  42. package/package.json +60 -4
  43. package/skills/nacholens/SKILL.md +235 -0
  44. package/src/cli.js +377 -0
  45. package/src/demo.js +42 -0
  46. package/src/diff.js +254 -0
  47. package/src/gitio.js +67 -0
  48. package/src/install.js +94 -0
  49. package/src/model.js +434 -0
  50. package/src/page.js +87 -0
  51. package/src/player/fonts/FONTLOG.md +30 -0
  52. package/src/player/fonts/OFL-Anton.txt +93 -0
  53. package/src/player/fonts/OFL-IBMPlex.txt +94 -0
  54. package/src/player/fonts/anton-400.woff2 +0 -0
  55. package/src/player/fonts/nacho-mono-500.woff2 +0 -0
  56. package/src/player/fonts/nacho-mono-600.woff2 +0 -0
  57. package/src/player/fonts/nacho-sans-400.woff2 +0 -0
  58. package/src/player/fonts/nacho-sans-500.woff2 +0 -0
  59. package/src/player/fonts/nacho-sans-600.woff2 +0 -0
  60. package/src/player/fonts/nacho-sans-700.woff2 +0 -0
  61. package/src/player/player.css +318 -0
  62. package/src/player/player.js +872 -0
  63. package/src/player/review.css +49 -0
  64. package/src/player/review.js +230 -0
  65. package/src/player/template.html +58 -0
  66. package/src/review.js +321 -0
  67. package/src/scan.js +255 -0
  68. package/src/template.js +36 -0
  69. package/src/verify.js +90 -0
package/src/cli.js ADDED
@@ -0,0 +1,377 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, mkdtempSync, rmSync, realpathSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join, resolve, dirname, basename } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { validateModel, validateChanges } from "./model.js";
7
+ import { verifyEvidence, verifyEvidenceWith, collectEvidence } from "./verify.js";
8
+ import { buildPage, artifactManifest, formatForAgent, LANGS } from "./page.js";
9
+ import { openRepo } from "./gitio.js";
10
+ import { overlayFromRepo } from "./diff.js";
11
+ import { makeTemplate } from "./template.js";
12
+ import { scanRepo, formatScan } from "./scan.js";
13
+ import { makeDemoRepo } from "./demo.js";
14
+ import { install } from "./install.js";
15
+ import { buildReview, validateIntent, checkIntent, buildImpact, discoverTests, isTestFile } from "./review.js";
16
+
17
+ export const EXIT = { OK: 0, MODEL: 1, USAGE: 2, STRICT: 3, INTENT: 4 };
18
+
19
+ const USAGE = `nacholens — AI 가 바꾼 백엔드 변경을 유즈케이스 흐름 애니메이션으로 보여 줍니다
20
+
21
+ 한 줄 실행 (대상 저장소 + 비교 구간):
22
+ nacholens build --repo <경로> --base <ref> [--head <ref>] [--out <dir>] [--agent claude|...]
23
+
24
+ 바로 보기 (대상 저장소 없이, 번들된 데모 앱):
25
+ nacholens demo [--out <dir>] [--agent claude|...] [--lang en|ko]
26
+
27
+ 에이전트에 설치 (스킬 + 단독 실행 CLI 사본, 의존성 없음):
28
+ nacholens install --claude [--into <프로젝트>] [--global] [--force] .claude/skills/nacholens
29
+ nacholens install --agents [--into <프로젝트>] [--force] .nacholens/ + AGENTS.md 블록
30
+ nacholens install --to <스킬 폴더> [--force] <폴더>/nacholens
31
+
32
+ 단계별:
33
+ nacholens init --repo <경로> [--model <파일>] [--name <제품명>] 빈 모델 템플릿 생성
34
+ nacholens scan --repo <경로> [--json] [--limit N] 모델 초안용 후보(스택·라우트·게이트웨이·DB·외부 주소·HTTP 호출)
35
+ nacholens validate [<model.json>] [--repo <경로>] [--changes <파일>]
36
+ nacholens verify [<model.json>] (--repo <경로> [--ref <ref>] | --source <디렉터리>)
37
+ nacholens diff --repo <경로> --base <ref> [--head <ref>] [--model <파일>] [--out <changes.json>] [--strict]
38
+ nacholens build <model.json> [--changes <파일>] ... (저장소 없이 만들기)
39
+ nacholens intent --model <파일> --intent <intent.json> [--repo <경로> --base <ref>] [--strict-intent] 기획 의도 대조
40
+ nacholens impact --repo <경로> --base <ref> [--head <ref>] [--model <파일>] 회귀 지도 (다시 확인할 유즈케이스)
41
+
42
+ 검토 패널 (build): --before-model <파일> 변경 전 모델 (있으면 흐름 자체를 전/후 비교)
43
+ --intent <intent.json> 기획 의도 (기대사항을 모델과 대조)
44
+ 회귀 지도는 --repo --base 가 있으면 자동 (테스트는 경로 문자열로 찾는 추정)
45
+
46
+ 공통 옵션: --model <파일> 모델 위치 (기본 <repo>/.nacholens/model.json)
47
+ --head <ref> 기본 HEAD
48
+ --out <dir> 기본 ./nacholens-out (저장소 없이 만들 때는 ./dist)
49
+ --format html|artifact|both|auto auto(기본): --agent/NACHOLENS_AGENT 가 claude* 이면 artifact, 아니면 html
50
+ --lang en|ko 화면 글자 언어 (기본 en; 모델 글자는 쓴 그대로)
51
+ --strict 모델에 없는 변경이 있으면 종료 코드 3
52
+
53
+ 종료 코드: 0 정상 · 1 모델/근거 문제(고쳐야 함) · 2 사용법/입출력/git 오류 · 3 --strict 에서 모델에 없는 변경 발견
54
+ 4 --strict-intent 에서 기획 의도가 미충족
55
+ `;
56
+
57
+ const BOOL = new Set(["strict", "strict-intent", "json", "help", "claude", "agents", "global", "force"]);
58
+ function parseArgs(argv) {
59
+ const pos = [], opt = {};
60
+ for (let i = 0; i < argv.length; i++) {
61
+ const a = argv[i];
62
+ if (a.startsWith("--")) {
63
+ const k = a.slice(2);
64
+ if (BOOL.has(k)) { opt[k] = true; continue; }
65
+ const v = argv[i + 1];
66
+ if (v === undefined || v.startsWith("--")) throw new Error(`${a} 에 값이 필요합니다`);
67
+ opt[k] = v; i++;
68
+ } else pos.push(a);
69
+ }
70
+ return { pos, opt };
71
+ }
72
+ const readJson = (p, what) => {
73
+ try { return JSON.parse(readFileSync(resolve(p), "utf8")); }
74
+ catch (e) { throw new Error(`${what ?? "파일"} 을 읽을 수 없습니다 (${p}): ${e.message}`); }
75
+ };
76
+ const defaultModelPath = (repo) => join(resolve(repo), ".nacholens", "model.json");
77
+
78
+ function printErrors(errors, log) {
79
+ const todo = errors.filter((e) => e.includes("채워 주세요"));
80
+ const rest = errors.filter((e) => !e.includes("채워 주세요"));
81
+ rest.forEach((e) => log("✗ " + e));
82
+ if (todo.length) {
83
+ log(`✗ 모델 템플릿에 채워야 할 곳이 ${todo.length}개 있습니다 (JSON 경로 → 무엇을 쓸지):`);
84
+ todo.forEach((e) => log(" - " + e.replace(/^model\./, "").replace(": 채워 주세요 →", " →")));
85
+ log(" 각 TODO 를 실제 코드에서 확인한 내용과 근거(파일:줄)로 바꾼 뒤 다시 실행하세요. 가이드: skills/nacholens/SKILL.md");
86
+ }
87
+ }
88
+
89
+ // evidence without `contains` still checks the file and range, but cannot notice that the lines drifted onto other code
90
+ function warnNoContains(model, log) {
91
+ const n = collectEvidence(model).filter(({ e }) => e.contains === undefined).length;
92
+ if (n) log(`⚠ contains 가 없는 근거 ${n}건 — 줄이 다른 코드로 밀려도 검증이 알아채지 못합니다. 각 근거에 줄 안의 고유한 문자열을 contains 로 적으세요.`);
93
+ }
94
+
95
+ const codeUnmapped = (o) => (o.unmapped ?? []).filter((u) => u.category !== "style" && u.category !== "config");
96
+ function summarize(o, log) {
97
+ const style = o.unmapped.length - codeUnmapped(o).length;
98
+ log(`변경 단계 ${o.steps.length} · 변경 컴포넌트 ${o.components.length} · 모델에 없는 변경 코드 ${codeUnmapped(o).length} · 스타일·설정 ${style} · 무시한 파일 ${o.ignored.length}`);
99
+ o.steps.forEach((s) => log(` 단계 ${s.useCase}/${s.step} [${s.change}]`));
100
+ o.components.forEach((c) => log(` 컴포넌트 ${c.id} [${c.change}]`));
101
+ o.unmapped.forEach((u) => log(` ${u.category === "style" || u.category === "config" ? "·" : "⚠"} 모델에 없는 변경${u.category === "style" ? "(스타일)" : u.category === "config" ? "(설정·의존성)" : ""} ${u.file} (${u.status}, 줄 ${u.lines.length > 60 ? u.lines.slice(0, 57) + "…" : u.lines})`));
102
+ if (o.ignored.length) log(` (무시: ${o.ignored.map((i) => `${i.file}[${i.reason}]`).join(", ")})`);
103
+ }
104
+
105
+ function initTemplate(repo, modelPath, name) {
106
+ if (existsSync(modelPath)) throw new Error(`이미 모델이 있습니다: ${modelPath}`);
107
+ const t = makeTemplate({ slug: repo.remoteSlug(), commit: repo.resolve("HEAD"), name: name ?? basename(resolve(repo.path)) });
108
+ mkdirSync(dirname(modelPath), { recursive: true });
109
+ writeFileSync(modelPath, JSON.stringify(t, null, 2) + "\n");
110
+ return t;
111
+ }
112
+
113
+ /** test files at head that mention routes: read-only, capped so a huge repo stays fast */
114
+ function loadTests(repo, headSha, changes) {
115
+ const names = repo.files(headSha).filter(isTestFile).slice(0, 600);
116
+ const files = [];
117
+ for (const path of names) { const text = repo.show(headSha, path); if (text !== null && text.length <= 400_000) files.push({ path, text }); }
118
+ const changed = (changes?.ignored ?? []).filter((i) => i.reason === "test").map((i) => i.file);
119
+ return { files, changed };
120
+ }
121
+
122
+ /** compare / intent / impact for the review panel. Returns { review, intentReport, code } (code != 0 -> stop) */
123
+ function makeReview(model, changes, opt, repo, log) {
124
+ let beforeModel = null, intent = null, tests = null;
125
+ if (opt["before-model"]) {
126
+ beforeModel = readJson(opt["before-model"], "변경 전 모델");
127
+ const errs = validateModel(beforeModel);
128
+ if (errs.length) { log("변경 전 모델이 올바르지 않습니다:"); printErrors(errs, log); return { code: EXIT.MODEL }; }
129
+ }
130
+ if (opt.intent) {
131
+ intent = readJson(opt.intent, "기획 의도");
132
+ const errs = validateIntent(intent, model);
133
+ if (errs.length) { errs.forEach((e) => log("✗ " + e)); return { code: EXIT.MODEL }; }
134
+ }
135
+ if (repo && changes) {
136
+ const head = repo.resolve(opt.head ?? "HEAD");
137
+ const t = loadTests(repo, head, changes);
138
+ tests = discoverTests(model, t.files, t.changed);
139
+ }
140
+ return { review: buildReview(model, changes, { beforeModel, intent, tests }), code: EXIT.OK };
141
+ }
142
+
143
+ function logIntent(rep, log) {
144
+ const s = rep.summary;
145
+ log(`기획 의도 「${rep.title}」: 충족 ${s.met} · 미충족 ${s.unmet} · 확인 불가 ${s.unknown} (전체 ${s.total})` + (s.changedSteps ? ` · 기획에 연결되지 않은 변경 ${s.uncovered}/${s.changedSteps}` : ""));
146
+ rep.results.forEach((r) => log(` ${{ met: "✓ 충족 ", unmet: "✗ 미충족", unknown: "? 불명 " }[r.status]} ${r.id} ${r.text}${r.touched ? " [이번 변경이 건드림]" : ""}\n ${r.reason}`));
147
+ rep.uncovered.forEach((u) => log(` ⚠ 기획에 연결되지 않은 변경 ${u.useCase}/${u.step} [${u.change}] ${u.label}`));
148
+ }
149
+ function logImpact(im, log) {
150
+ const s = im.summary;
151
+ log(`회귀 지도: 다시 확인할 유즈케이스 ${s.direct + s.indirect}개 (직접 ${s.direct} · 간접 ${s.indirect})` + (s.testsKnown ? ` · 테스트를 찾지 못한 곳 ${s.withoutTests}` : " · 테스트 확인 안 함"));
152
+ im.useCases.forEach((u) => {
153
+ log(` ${u.relation === "direct" ? "직접" : "간접"} ${u.id} (${u.trigger}) 테스트: ${{ "covered-changed": "있음·이번에 바뀜", covered: "있음·안 바뀜", "none-found": "찾지 못함", unknown: "확인 안 함" }[u.tests.state]}`);
154
+ u.direct.forEach((d) => log(` 바뀐 단계 ${d.step} [${d.change}] ${d.label}`));
155
+ u.reasons.forEach((r) => log(` ${r.text} (단계 ${r.step})`));
156
+ });
157
+ }
158
+
159
+ export function run(argv, env = process.env, log = console.log) {
160
+ const [cmd, ...rest] = argv;
161
+ if (!cmd || cmd === "help" || cmd === "--help") { log(USAGE); return EXIT.OK; }
162
+ const { pos, opt } = parseArgs(rest);
163
+ if (opt.help) { log(USAGE); return EXIT.OK; }
164
+
165
+ if (cmd === "init") {
166
+ if (!opt.repo) throw new Error("--repo 가 필요합니다\n" + USAGE);
167
+ const repo = openRepo(opt.repo);
168
+ const modelPath = resolve(opt.model ?? defaultModelPath(opt.repo));
169
+ initTemplate(repo, modelPath, opt.name);
170
+ log(`생성: ${modelPath}`);
171
+ log("다음: ① nacholens scan --repo … 로 후보를 보고 ② 코드를 읽으며 TODO 를 채운 뒤 ③ nacholens validate --repo … 로 남은 곳을 확인하세요.");
172
+ return EXIT.OK;
173
+ }
174
+
175
+ if (cmd === "scan") {
176
+ if (!opt.repo) throw new Error("--repo 가 필요합니다\n" + USAGE);
177
+ openRepo(opt.repo);
178
+ const r = scanRepo(resolve(opt.repo), opt.limit ? { limit: Math.max(1, Number(opt.limit) || 60) } : {});
179
+ log(opt.json ? JSON.stringify(r, null, 2) : formatScan(r));
180
+ return EXIT.OK;
181
+ }
182
+
183
+ if (cmd === "install") return install(opt, env, log).code;
184
+
185
+ if (cmd === "demo") {
186
+ const bundled = join(dirname(fileURLToPath(import.meta.url)), "..", "models", "demo-shop", "model.json");
187
+ if (!existsSync(bundled) || !existsSync(join(dirname(bundled), "..", "..", "demo"))) throw new Error("번들된 데모가 없습니다 (nacholens 저장소 전체가 필요합니다)");
188
+ const dir = mkdtempSync(join(tmpdir(), "nacholens-demo-"));
189
+ rmSync(dir, { recursive: true });
190
+ const { base } = makeDemoRepo(dir);
191
+ log(`데모 저장소: ${dir} (커밋 2개: v1 → v2)`);
192
+ const args = ["build", "--repo", dir, "--model", bundled, "--base", base, "--out", opt.out ?? "nacholens-demo"];
193
+ if (opt.format) args.push("--format", opt.format);
194
+ if (opt.agent) args.push("--agent", opt.agent);
195
+ if (opt.lang) args.push("--lang", opt.lang);
196
+ return run(args, env, log);
197
+ }
198
+
199
+ const modelArg = pos[0] ?? opt.model ?? (opt.repo ? defaultModelPath(opt.repo) : null);
200
+ if (!modelArg) throw new Error("model.json 경로(또는 --repo)가 필요합니다\n" + USAGE);
201
+ const needModel = () => {
202
+ if (!existsSync(resolve(modelArg))) throw new Error(`모델이 없습니다: ${modelArg}. 새로 만들려면: nacholens init --repo <경로>`);
203
+ return readJson(modelArg, "모델");
204
+ };
205
+
206
+ if (cmd === "intent" || cmd === "impact") {
207
+ const model = needModel();
208
+ const errors = validateModel(model);
209
+ if (errors.length) { printErrors(errors, log); return EXIT.MODEL; }
210
+ let changes = null, repo = null;
211
+ if (opt.changes) changes = readJson(opt.changes, "변경 오버레이");
212
+ else if (opt.repo && opt.base) {
213
+ repo = openRepo(opt.repo);
214
+ const r = overlayFromRepo(repo, model, opt.base, opt.head ?? "HEAD");
215
+ changes = r.overlay; r.warnings.forEach((w) => log("⚠ " + w));
216
+ } else if (cmd === "impact") throw new Error("impact 는 --repo 와 --base (또는 --changes) 가 필요합니다\n" + USAGE);
217
+ if (changes) { const bad = validateChanges(changes, model); if (bad.length) { printErrors(bad, log); return EXIT.MODEL; } }
218
+ if (cmd === "impact") {
219
+ let tests = null;
220
+ if (repo) { const t = loadTests(repo, repo.resolve(opt.head ?? "HEAD"), changes); tests = discoverTests(model, t.files, t.changed); }
221
+ const im = buildImpact(model, changes, tests);
222
+ logImpact(im, log);
223
+ if (opt.out) { mkdirSync(dirname(resolve(opt.out)), { recursive: true }); writeFileSync(resolve(opt.out), JSON.stringify(im, null, 2) + "\n"); log("생성: " + resolve(opt.out)); }
224
+ return EXIT.OK;
225
+ }
226
+ if (!opt.intent) throw new Error("--intent <intent.json> 이 필요합니다\n" + USAGE);
227
+ const intent = readJson(opt.intent, "기획 의도");
228
+ const ierr = validateIntent(intent, model);
229
+ if (ierr.length) { ierr.forEach((e) => log("✗ " + e)); return EXIT.MODEL; }
230
+ const rep = checkIntent(model, intent, changes);
231
+ logIntent(rep, log);
232
+ if (opt.out) { mkdirSync(dirname(resolve(opt.out)), { recursive: true }); writeFileSync(resolve(opt.out), JSON.stringify(rep, null, 2) + "\n"); log("생성: " + resolve(opt.out)); }
233
+ return opt["strict-intent"] && rep.summary.unmet ? EXIT.INTENT : EXIT.OK;
234
+ }
235
+
236
+ if (cmd === "validate") {
237
+ const model = needModel();
238
+ const changes = opt.changes ? readJson(opt.changes, "변경 오버레이") : null;
239
+ const errors = [...validateModel(model), ...validateChanges(changes, model)];
240
+ if (errors.length) { printErrors(errors, log); return EXIT.MODEL; }
241
+ const steps = model.useCases.reduce((n, u) => n + u.steps.length, 0);
242
+ log(`✓ 유효: 컴포넌트 ${model.components.length}, 연결 ${model.links.length}, 유즈케이스 ${model.useCases.length}, 단계 ${steps}` + (changes ? `, 변경 오버레이 ${changes.components.length}개 컴포넌트` : ""));
243
+ return EXIT.OK;
244
+ }
245
+
246
+ if (cmd === "verify") {
247
+ const model = needModel();
248
+ const errors = validateModel(model);
249
+ if (errors.length) { printErrors(errors, log); return EXIT.MODEL; }
250
+ let result, where;
251
+ if (opt.repo) {
252
+ const repo = openRepo(opt.repo);
253
+ const sha = repo.resolve(opt.ref ?? model.source.commit);
254
+ result = verifyEvidenceWith(model, (f) => repo.show(sha, f));
255
+ where = `${model.source.repo} @ ${sha.slice(0, 7)}`;
256
+ } else if (opt.source) {
257
+ result = verifyEvidence(model, opt.source);
258
+ where = `${model.source.repo} @ ${model.source.commit.slice(0, 7)}`;
259
+ } else throw new Error("--repo 또는 --source 가 필요합니다");
260
+ if (result.failures.length) { result.failures.forEach((f) => log("✗ " + f)); log(`근거 ${result.checked}건 중 ${result.failures.length}건 실패`); return EXIT.MODEL; }
261
+ log(`✓ 근거 ${result.checked}건 모두 소스와 일치 (${where})`);
262
+ warnNoContains(model, log);
263
+ return EXIT.OK;
264
+ }
265
+
266
+ if (cmd === "diff") {
267
+ if (!opt.repo || !opt.base) throw new Error("--repo 와 --base 가 필요합니다\n" + USAGE);
268
+ const repo = openRepo(opt.repo);
269
+ const model = needModel();
270
+ const errors = validateModel(model);
271
+ if (errors.length) { printErrors(errors, log); return EXIT.MODEL; }
272
+ const { overlay, warnings } = overlayFromRepo(repo, model, opt.base, opt.head ?? "HEAD");
273
+ const bad = validateChanges(overlay, model);
274
+ if (bad.length) throw new Error("내부 오류: 생성된 오버레이가 스키마에 맞지 않습니다\n" + bad.join("\n"));
275
+ const outFile = resolve(opt.out ?? join("nacholens-out", "changes.json"));
276
+ mkdirSync(dirname(outFile), { recursive: true });
277
+ writeFileSync(outFile, JSON.stringify(overlay, null, 2) + "\n");
278
+ warnings.forEach((w) => log("⚠ " + w));
279
+ summarize(overlay, log);
280
+ log("생성: " + outFile);
281
+ return opt.strict && codeUnmapped(overlay).length ? EXIT.STRICT : EXIT.OK;
282
+ }
283
+
284
+ if (cmd === "build") {
285
+ const out = resolve(opt.out ?? (opt.repo ? "nacholens-out" : "dist"));
286
+ let format = opt.format ?? "auto";
287
+ if (format === "auto") format = formatForAgent(opt.agent ?? env.NACHOLENS_AGENT);
288
+ if (!["html", "artifact", "both"].includes(format)) throw new Error("--format 은 html|artifact|both|auto");
289
+ const lang = opt.lang ?? "en";
290
+ if (!LANGS.includes(lang)) throw new Error(`--lang 은 ${LANGS.join("|")}`);
291
+
292
+ let changes = null, repo = null, warnings = [];
293
+ const modelPath = resolve(modelArg);
294
+ if (opt.repo) {
295
+ repo = openRepo(opt.repo);
296
+ if (!existsSync(modelPath)) {
297
+ initTemplate(repo, modelPath, opt.name);
298
+ log(`이 저장소에는 아직 모델이 없어 빈 템플릿을 만들었습니다: ${modelPath}`);
299
+ printErrors(validateModel(readJson(modelPath)), log);
300
+ return EXIT.MODEL;
301
+ }
302
+ }
303
+ const model = needModel();
304
+ const errors = validateModel(model);
305
+ if (errors.length) { printErrors(errors, log); return EXIT.MODEL; }
306
+
307
+ if (repo) {
308
+ const sha = repo.tryResolve(model.source.commit);
309
+ if (!sha) { log(`✗ 모델 기준 커밋 ${model.source.commit} 를 저장소에서 찾을 수 없습니다 (git fetch 또는 model.source.commit 확인)`); return EXIT.MODEL; }
310
+ const v = verifyEvidenceWith(model, (f) => repo.show(sha, f));
311
+ if (v.failures.length) { v.failures.forEach((f) => log("✗ " + f)); log(`근거 ${v.checked}건 중 ${v.failures.length}건이 ${sha.slice(0, 7)} 의 소스와 다릅니다. 모델의 근거를 고치세요.`); return EXIT.MODEL; }
312
+ log(`✓ 근거 ${v.checked}건 확인 (${sha.slice(0, 7)})`);
313
+ warnNoContains(model, log);
314
+ if (opt.base && !opt.changes) {
315
+ const r = overlayFromRepo(repo, model, opt.base, opt.head ?? "HEAD");
316
+ changes = r.overlay; warnings = r.warnings;
317
+ const bad = validateChanges(changes, model);
318
+ if (bad.length) throw new Error("내부 오류: 생성된 오버레이가 스키마에 맞지 않습니다\n" + bad.join("\n"));
319
+ warnings.forEach((w) => log("⚠ " + w));
320
+ summarize(changes, log);
321
+ } else if (!opt.base && !opt.changes) log("--base 가 없어 변경 강조 없이 모델만 그립니다.");
322
+ }
323
+ if (opt.changes) {
324
+ changes = readJson(opt.changes, "변경 오버레이");
325
+ const bad = validateChanges(changes, model);
326
+ if (bad.length) { printErrors(bad, log); return EXIT.MODEL; }
327
+ }
328
+
329
+ const mr = makeReview(model, changes, opt, repo, log);
330
+ if (mr.code) return mr.code;
331
+ const review = mr.review;
332
+ if (review?.intent) logIntent(review.intent, log);
333
+ if (review?.impact) logImpact(review.impact, log);
334
+
335
+ mkdirSync(out, { recursive: true });
336
+ const id = model.product.id, written = [];
337
+ if (format === "html" || format === "both") {
338
+ const f = join(out, `${id}.html`);
339
+ writeFileSync(f, buildPage(model, changes, { target: "html", review, lang })); written.push(f);
340
+ }
341
+ if (format === "artifact" || format === "both") {
342
+ const f = join(out, `${id}.artifact.html`), m = join(out, `${id}.artifact.json`);
343
+ writeFileSync(f, buildPage(model, changes, { target: "artifact", review, lang }));
344
+ writeFileSync(m, JSON.stringify(artifactManifest(model, `${id}.artifact.html`), null, 2) + "\n");
345
+ written.push(f, m);
346
+ }
347
+ if (repo) {
348
+ if (review) { const rv = join(out, "review.json"); writeFileSync(rv, JSON.stringify(review, null, 2) + "\n"); written.push(rv); }
349
+ if (changes) { const c = join(out, "changes.json"); writeFileSync(c, JSON.stringify(changes, null, 2) + "\n"); written.push(c); }
350
+ const r = join(out, "report.json");
351
+ writeFileSync(r, JSON.stringify({
352
+ ok: true, product: id, modelPath, modelCommit: model.source.commit, format,
353
+ base: changes?.generated?.base ?? null, head: changes?.generated?.head ?? null,
354
+ changedSteps: (changes?.steps ?? []).map((s) => `${s.useCase}/${s.step}`),
355
+ changedComponents: (changes?.components ?? []).map((c) => c.id),
356
+ unmapped: changes?.unmapped ?? [], ignoredCount: (changes?.ignored ?? []).length, warnings,
357
+ review: review ? { compare: review.compare?.summary ?? null, intent: review.intent?.summary ?? null, impact: review.impact?.summary ?? null } : null,
358
+ outputs: written.map((w) => w.slice(out.length + 1)).concat("report.json"),
359
+ }, null, 2) + "\n");
360
+ written.push(r);
361
+ }
362
+ written.forEach((w) => log("생성: " + w));
363
+ if (opt.strict && changes && codeUnmapped(changes).length) return EXIT.STRICT;
364
+ return opt["strict-intent"] && review?.intent?.summary.unmet ? EXIT.INTENT : EXIT.OK;
365
+ }
366
+ throw new Error(`알 수 없는 명령: ${cmd}\n` + USAGE);
367
+ }
368
+
369
+ // npm starts bin entries through a symlink: compare real paths, or the CLI would silently do nothing
370
+ const isMain = (() => {
371
+ try { return !!process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); }
372
+ catch { return false; }
373
+ })();
374
+ if (isMain) {
375
+ try { process.exitCode = run(process.argv.slice(2)); }
376
+ catch (e) { console.error("오류: " + e.message); process.exitCode = EXIT.USAGE; }
377
+ }
package/src/demo.js ADDED
@@ -0,0 +1,42 @@
1
+ // Builds the public demo repository: two commits (v1, then v2 = "show shipping status on the order list")
2
+ // from demo/shop/v1 and demo/shop/v2. Identity and dates are fixed, so the same files always give
3
+ // the same commit ids and the bundled model can name them.
4
+ import { execFileSync } from "node:child_process";
5
+ import { cpSync, mkdirSync, readdirSync, rmSync, existsSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const here = dirname(fileURLToPath(import.meta.url));
10
+ export const DEMO_DIR = join(here, "..", "demo");
11
+
12
+ const ENV = {
13
+ GIT_AUTHOR_NAME: "nacholens demo", GIT_AUTHOR_EMAIL: "demo@example.com",
14
+ GIT_COMMITTER_NAME: "nacholens demo", GIT_COMMITTER_EMAIL: "demo@example.com",
15
+ GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null", GIT_CONFIG_NOSYSTEM: "1",
16
+ };
17
+
18
+ function git(cwd, args, date) {
19
+ const env = { ...process.env, ...ENV, ...(date ? { GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date } : {}) };
20
+ return execFileSync("git", args, { cwd, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
21
+ }
22
+
23
+ function replaceTree(from, to) {
24
+ for (const name of readdirSync(to)) if (name !== ".git") rmSync(join(to, name), { recursive: true, force: true });
25
+ cpSync(from, to, { recursive: true });
26
+ }
27
+
28
+ /** Creates `dir` (must not exist or be empty) as a git repo with the two demo commits. Returns { dir, base, head }. */
29
+ export function makeDemoRepo(dir) {
30
+ if (existsSync(dir) && readdirSync(dir).length) throw new Error(`${dir} is not empty`);
31
+ mkdirSync(dir, { recursive: true });
32
+ git(dir, ["init", "-q", "-b", "main"]);
33
+ const commit = (version, message, date) => {
34
+ replaceTree(join(DEMO_DIR, "shop", version), dir);
35
+ git(dir, ["add", "-A"]);
36
+ git(dir, ["commit", "-q", "-m", message], date);
37
+ return git(dir, ["rev-parse", "HEAD"]);
38
+ };
39
+ const base = commit("v1", "demo shop: sign-in, orders, sales report", "2026-01-01T00:00:00+00:00");
40
+ const head = commit("v2", "show shipping status on the order list", "2026-01-02T00:00:00+00:00");
41
+ return { dir, base, head };
42
+ }