@pghoya2956/livemap 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.
package/src/derive.mjs ADDED
@@ -0,0 +1,161 @@
1
+ // 파생: 그래프(코드 사실) + 여정 파일(뜻)을 합쳐 화면이 읽는 뷰 모델을 만든다. 화면은 여기서 만든 것만 그린다.
2
+ const ROADMAP_STATUS = ['완료', '진행', '다음', '대기', '이후'];
3
+ const RANK = { live: 0, partial: 1, mixed: 1, mock: 2, planned: 3, next: 4, static: 5 };
4
+
5
+ export function derive(g, sem, cfg, { captureExists }) {
6
+ const screens = g.of('screen'), apis = g.of('api'), fns = g.of('function'), tests = g.of('test'), commits = g.of('commit').sort((a, b) => b.props.date.localeCompare(a.props.date));
7
+ const tasks = g.of('task'), decisions = g.of('decision').filter((d) => d.props.kind === 'wiki'), specDefs = g.of('decision').filter((d) => d.props.kind !== 'wiki');
8
+ const head = g.get('deploy', 'head')?.props || null;
9
+ const homelab = g.get('deploy', 'homelab')?.props || null;
10
+ const report = g.get('testreport', 'last')?.props || null;
11
+ const short = (t) => t.split('.').pop();
12
+ const testsOf = (kind, id) => g.in(kind, id, 'covers').map((t) => t.label);
13
+ const testsPassedFresh = (kind, id) => g.in(kind, id, 'covers').some((t) => t.props.lastRun?.passed && t.props.lastRun?.fresh);
14
+
15
+ // ---- 화면·API·함수 뷰 ----
16
+ const screenView = screens.map((s) => ({
17
+ path: s.id, component: s.props.component, file: s.props.file, guarded: s.props.guarded, source: s.props.source, mockVia: s.props.mockVia, fixedVia: s.props.fixedVia || [], files: s.props.files,
18
+ apis: g.out('screen', s.id, 'calls').map((a) => a.id), tests: testsOf('screen', s.id), last: s.props.last, src: s.src,
19
+ steps: [],
20
+ }));
21
+ const apiView = apis.map((a) => ({ path: a.id, method: a.props.method || '?', calls: a.props.calls || [], tests: testsOf('api', a.id), src: a.src, declared: !!a.src }));
22
+ const fnView = fns.map((f) => ({ name: f.id, usedByApi: g.in('function', f.id, 'invokes').length > 0, tables: g.out('function', f.id, 'touches').map((t) => t.label), tests: testsOf('function', f.id), migration: f.props.migration || null, src: f.src }));
23
+ const migView = g.of('migration').map((m) => ({ file: m.id, tables: m.props.tables.map(short), functions: m.props.functions.map(short), grants: m.props.grants, rls: m.props.rls, last: m.props.last }));
24
+ const testView = tests.map((t) => ({ file: t.id, label: t.label, kind: t.props.kind, count: t.props.count, gated: t.props.gated, lastRun: t.props.lastRun || null }));
25
+
26
+ // ---- 여정 해석 ----
27
+ const byPath = Object.fromEntries(screenView.map((s) => [s.path, s]));
28
+ const apiByPath = Object.fromEntries(apiView.map((a) => [a.path, a]));
29
+ const decisionBySlug = Object.fromEntries(decisions.map((d) => [d.id, d]));
30
+ const defBy = Object.fromEntries(specDefs.map((d) => [d.id, { task: g.in('decision', d.id, 'defines')[0]?.id || null, file: d.props.file, done: d.props.done }]));
31
+ const resolveRef = (ref) => { const m = ref.match(/^((?:DEC|PN|P\d)-\d+)/i); return m ? defBy[m[1].toUpperCase()] || null : null; };
32
+ const journeys = (sem.journeys || []).map((j) => {
33
+ const steps = j.steps.map((st) => {
34
+ const actor = st.actor || j.actor;
35
+ const screenNodes = (st.screens || []).map((p) => byPath[p] ? { path: p, source: byPath[p].source, file: byPath[p].file, tests: byPath[p].tests, last: byPath[p].last, mockVia: byPath[p].mockVia, fixedVia: byPath[p].fixedVia } : { path: p, source: 'missing' });
36
+ const apiSet = new Set(st.apis || []);
37
+ for (const p of st.screens || []) for (const a of byPath[p]?.apis || []) apiSet.add(a);
38
+ const apiNodes = [...apiSet].map((p) => apiByPath[p] ? { path: p, method: apiByPath[p].method, calls: apiByPath[p].calls, tests: apiByPath[p].tests } : { path: p, method: '?', calls: [], tests: [], missing: true });
39
+ const fnNames = [...new Set(apiNodes.flatMap((a) => a.calls.filter((c) => !c.startsWith('auth:'))))];
40
+ const functionNodes = fnNames.map((n) => ({ name: n, tables: fnView.find((f) => f.name === n)?.tables || [], tests: testsOf('function', n) }));
41
+ const testFiles = [...new Set([...screenNodes.flatMap((x) => x.tests || []), ...apiNodes.flatMap((x) => x.tests || []), ...functionNodes.flatMap((x) => x.tests || [])])];
42
+ const refNodes = (st.refs || []).map((r) => ({ ref: r, ...(resolveRef(r) || {}), wiki: decisionBySlug[r] ? { title: decisionBySlug[r].label, file: decisionBySlug[r].props.file, status: decisionBySlug[r].props.status } : null }));
43
+ const taskNames = [...new Set(refNodes.map((r) => r.task).filter(Boolean))];
44
+ const warnings = [];
45
+ for (const n of screenNodes) {
46
+ if (n.source === 'missing') warnings.push(`라우트 없음: ${n.path}`);
47
+ else if (st.status === 'live' && n.source !== 'live') warnings.push(`장면은 동작인데 화면은 ${n.source}: ${n.path}`);
48
+ else if ((st.status === 'planned' || st.status === 'next') && n.source === 'live') warnings.push(`장면은 ${st.status}인데 화면은 동작: ${n.path}`);
49
+ }
50
+ for (const r of refNodes) if (/^(DEC|PN|P\d)-/i.test(r.ref) && !r.task) warnings.push(`참조 미해결: ${r.ref}`);
51
+ // 등급: D 주장 / C 관측 / B 검사 존재 / A 최신 커밋에서 통과
52
+ const observed = screenNodes.length > 0 && screenNodes.every((n) => n.source === 'live');
53
+ const covered = observed && testFiles.length > 0;
54
+ const verified = covered && (screenNodes.some((n) => testsPassedFresh('screen', n.path)) || apiNodes.some((a) => !a.missing && testsPassedFresh('api', a.path)) || functionNodes.some((f) => testsPassedFresh('function', f.name)));
55
+ const grade = st.status !== 'live' ? null : verified ? 'A' : covered ? 'B' : observed ? 'C' : 'D';
56
+ if (grade === 'D') warnings.push('동작 주장에 관측 근거 없음');
57
+ // 신선도: 장면 확인일보다 화면 파일이 나중에 바뀌었으면 확인 필요
58
+ const changedAfter = st.reviewedAt ? screenNodes.filter((n) => n.last && n.last.date > st.reviewedAt).map((n) => n.path) : [];
59
+ if (changedAfter.length) warnings.push(`확인 필요: ${st.reviewedAt} 뒤 화면 변경 ${changedAfter.join(', ')}`);
60
+ const recentCommits = [...new Map(screenNodes.flatMap((n) => g.in('screen', n.path, 'changes')).map((c) => [c.id, c])).values()].sort((a, b) => b.props.date.localeCompare(a.props.date)).slice(0, 5).map((c) => ({ sha: c.id, date: c.props.date, subject: c.label }));
61
+ const captureFile = captureExists(st.capture);
62
+ for (const n of screenNodes) if (byPath[n.path]) byPath[n.path].steps.push({ journey: j.id, step: st.id, label: `${j.title} › ${st.label}` });
63
+ return { ...st, actor, screenNodes, apiNodes, functionNodes, testFiles, refNodes, taskNames, warnings, grade, captureFile, recentCommits };
64
+ });
65
+ const sts = steps.map((s) => s.status);
66
+ const status = sts.every((s) => s === 'live') ? 'live' : sts.some((s) => s === 'live') ? 'partial' : sts.slice().sort((a, b) => RANK[a] - RANK[b])[0];
67
+ const counts = Object.fromEntries(['live', 'mock', 'planned', 'next'].map((k) => [k, sts.filter((s) => s === k).length]));
68
+ return { ...j, steps, status, counts, taskNames: [...new Set(steps.flatMap((s) => s.taskNames))], warnings: steps.reduce((n, s) => n + s.warnings.length, 0) };
69
+ });
70
+
71
+ // ---- 고아·커버리지 ----
72
+ const inJourney = new Set(journeys.flatMap((j) => j.steps.flatMap((s) => s.screens || [])));
73
+ const calledApis = new Set(screenView.flatMap((s) => s.apis).concat(journeys.flatMap((j) => j.steps.flatMap((s) => s.apis || []))));
74
+ const orphans = {
75
+ screens: screenView.filter((s) => !inJourney.has(s.path)).map((s) => s.path),
76
+ apis: apiView.filter((a) => !calledApis.has(a.path)).map((a) => a.path),
77
+ functions: fnView.filter((f) => !f.usedByApi).map((f) => f.name),
78
+ tests: testView.filter((t) => !g.out('test', t.file, 'covers').length).map((t) => t.label),
79
+ };
80
+ const coverage = { screens: { inJourney: screenView.length - orphans.screens.length, total: screenView.length } };
81
+
82
+ // ---- 커밋 영향 ----
83
+ const commitView = commits.map((c) => {
84
+ const routes = g.out('commit', c.id, 'changes').filter((n) => n.kind === 'screen').map((n) => n.id);
85
+ const js = [...new Map(routes.flatMap((p) => byPath[p]?.steps || []).map((x) => [x.journey + '/' + x.step, x])).values()];
86
+ return { sha: c.id, date: c.props.date, author: c.props.author, subject: c.label, files: c.props.files, areas: c.props.areas, runtime: c.props.runtime, routes, journeys: js, touchesApi: g.out('commit', c.id, 'changes').some((n) => n.kind === 'api'), touchesDb: g.out('commit', c.id, 'changes').some((n) => n.kind === 'migration') };
87
+ });
88
+ const areaCounts = {};
89
+ for (const c of commitView) for (const a of c.areas) areaCounts[a] = (areaCounts[a] || 0) + 1;
90
+
91
+ // ---- 작업·장부·결정 ----
92
+ const taskView = tasks.map((t) => ({ name: t.id, title: t.label, ...t.props, journeys: journeys.filter((j) => j.taskNames.includes(t.id)).map((j) => ({ id: j.id, title: j.title, status: j.status, steps: j.steps.filter((s) => s.taskNames.includes(t.id)).map((s) => s.label) })) })).sort((a, b) => b.name.localeCompare(a.name));
93
+ const ledger = { running: g.of('ledger').filter((l) => l.props.state === 'running').map((l) => ({ work: l.label, owner: l.props.owner, done: l.props.done })), waiting: g.of('ledger').filter((l) => l.props.state !== 'running').map((l) => ({ work: l.label, state: l.props.status, resume: l.props.resume, done: l.props.state === 'done' })) };
94
+ const decisionView = decisions.map((d) => ({ slug: d.id, title: d.label, file: d.props.file, status: d.props.status, summary: d.props.summary, refs: journeys.reduce((n, j) => n + j.steps.filter((s) => s.refNodes.some((r) => r.wiki && r.wiki.file === d.props.file)).length, 0) }));
95
+ // ---- 로드맵: 항목 순서대로 장면 상태·작업 단계를 붙인다. 해석 실패는 check가 오류로 막는다 ----
96
+ const stepByRef = Object.fromEntries(journeys.flatMap((j) => j.steps.map((s) => [`${j.id}/${s.id}`, { journey: j.id, step: s.id, label: `${j.title} › ${s.label}`, status: s.status, grade: s.grade, fixed: s.screenNodes.some((n) => (n.fixedVia || []).length) }])));
97
+ const milestones = g.of('milestone').sort((a, b) => a.props.order - b.props.order);
98
+ const milestoneIds = new Set(milestones.map((m) => m.id));
99
+ const roadmap = milestones.map((m) => {
100
+ const p = m.props;
101
+ const scenes = p.scenes.map((r) => stepByRef[r] ? { ref: r, ...stepByRef[r] } : { ref: r, missing: true });
102
+ const trackedTasks = p.tasks.map((name) => { const t = taskView.find((x) => x.name === name); return t ? { name, title: t.title, stage: t.stage, status: t.status, pnDone: t.pnDone, pnOpen: t.pnOpen } : { name, missing: true }; });
103
+ const problems = [...scenes.filter((s) => s.missing).map((s) => `장면 없음: ${s.ref}`), ...trackedTasks.filter((t) => t.missing).map((t) => `작업 폴더 없음: ${t.name}`), ...p.deps.filter((d) => !milestoneIds.has(d)).map((d) => `선행 항목 없음: ${d}`)];
104
+ if (!ROADMAP_STATUS.includes(p.status)) problems.push(`알 수 없는 상태: ${p.status || '(비어 있음)'}`);
105
+ const live = scenes.filter((s) => s.status === 'live').length;
106
+ return { id: m.id, order: p.order, title: m.label, status: p.status, mode: p.mode, goal: p.goal, waitingOn: p.waitingOn, done: p.done, deps: p.deps.map((d) => ({ id: d, title: milestones.find((x) => x.id === d)?.label || d, status: milestones.find((x) => x.id === d)?.props.status || null })), scenes, tasks: trackedTasks, progress: { live, total: scenes.length, fixed: scenes.filter((s) => s.fixed).length }, problems, src: m.src };
107
+ });
108
+
109
+ const plans = taskView.filter((t) => t.pnDone + t.pnOpen > 0).map((t) => ({ task: t.name, title: t.title, done: t.pnDone, open: t.pnOpen, oq: t.oq }));
110
+
111
+ const summary = {
112
+ routes: screenView.length, liveRoutes: screenView.filter((s) => s.source === 'live').length, mockRoutes: screenView.filter((s) => s.source === 'mock' || s.source === 'mixed').length, fixedRoutes: screenView.filter((s) => s.fixedVia.length).length,
113
+ apis: apiView.length, dbFunctions: fnView.length, dbTables: g.of('table').length, tests: testView.reduce((n, t) => n + t.count, 0), e2e: testView.filter((t) => t.kind === 'e2e').reduce((n, t) => n + t.count, 0),
114
+ pnDone: plans.reduce((n, p) => n + p.done, 0), pnOpen: plans.reduce((n, p) => n + p.open, 0), oq: plans.reduce((n, p) => n + p.oq, 0),
115
+ commits: commitView.length, warnings: journeys.reduce((n, j) => n + j.warnings, 0), orphans: Object.values(orphans).reduce((n, a) => n + a.length, 0),
116
+ stepsLive: journeys.reduce((n, j) => n + j.counts.live, 0), stepsTotal: journeys.reduce((n, j) => n + j.steps.length, 0),
117
+ grades: Object.fromEntries(['A', 'B', 'C', 'D'].map((k) => [k, journeys.reduce((n, j) => n + j.steps.filter((s) => s.grade === k).length, 0)])),
118
+ };
119
+
120
+ return {
121
+ schemaVersion: 1, generatedAt: new Date().toISOString(), project: sem.project || cfg.project, head, deploy: homelab, testreport: report,
122
+ adapters: g.toJSON().adapters, semantic: { actors: sem.actors || {}, statusLegend: sem.statusLegend || {}, journeys },
123
+ summary, orphans, coverage, tasks: taskView, roadmap, ledger, decisions: decisionView, plans, commits: commitView, areaCounts,
124
+ screens: screenView, apis: apiView, functions: fnView, migrations: migView, tests: testView,
125
+ };
126
+ }
127
+
128
+ // 첫 화면 전용 조각: 시스템 식별자(경로·파일·sha)를 뺀다. 개요는 이것만 받는다.
129
+ export function overviewSlice(d) {
130
+ const A = d.semantic.actors;
131
+ const lastRun = d.testreport ? { fresh: d.testreport.fresh, failures: d.testreport.failures, total: d.testreport.total, at: d.testreport.at } : null;
132
+ return {
133
+ generatedAt: d.generatedAt, project: d.project, headDate: d.head?.date || null,
134
+ line: summaryLine(d),
135
+ journeys: d.semantic.journeys.map((j) => ({ id: j.id, title: j.title, actor: A[j.actor] || j.actor, lane: j.lane, status: j.status, warnings: j.warnings, steps: j.steps.map((s) => ({ id: s.id, label: s.label, status: s.status, grade: s.grade, warn: s.warnings.length > 0 })) })),
136
+ running: d.ledger.running.map((r) => ({ work: r.work, owner: r.owner })),
137
+ roadmap: d.roadmap.filter((m) => m.status !== '완료').slice(0, 4).map((m) => ({ id: m.id, title: m.title, status: m.status, mode: m.mode, live: m.progress.live, total: m.progress.total, waiting: !!m.waitingOn })),
138
+ roadmapDone: d.roadmap.filter((m) => m.status === '완료').length, roadmapTotal: d.roadmap.length,
139
+ waiting: d.ledger.waiting.filter((w) => !w.done).map((w) => ({ work: w.work, state: (w.state || '').split('(')[0] })),
140
+ tasks: d.tasks.filter((t) => t.status !== '폐기' && t.status !== '기록').slice().sort((a, b) => (a.status === '진행' ? -1 : 1) - (b.status === '진행' ? -1 : 1) || b.recentCommits - a.recentCommits).slice(0, 6).map((t) => ({ id: t.name, title: t.title, stage: t.stage, status: t.status, pnDone: t.pnDone, pnOpen: t.pnOpen, oq: t.oq })),
141
+ signals: {
142
+ deploy: d.deploy ? (d.deploy.behindRuntime === 0 ? 'ok' : d.deploy.behindRuntime === null ? 'unknown' : 'behind') : 'unknown', deployBehind: d.deploy?.behindRuntime ?? null,
143
+ tests: lastRun ? (lastRun.failures ? 'fail' : lastRun.fresh ? 'ok' : 'stale') : 'none', lastRun,
144
+ adapters: d.adapters.some((a) => a.status === 'failed') ? 'fail' : d.adapters.some((a) => a.status === 'partial') ? 'partial' : 'ok', adapterNotes: d.adapters.filter((a) => a.status !== 'ok').map((a) => `${a.name}: ${a.error}`),
145
+ warnings: d.summary.warnings, orphans: d.summary.orphans, gated: d.tests.filter((t) => t.gated).reduce((n, t) => n + t.count, 0),
146
+ },
147
+ counts: { stepsLive: d.summary.stepsLive, stepsTotal: d.summary.stepsTotal, screensLive: d.summary.liveRoutes, screensFixed: d.summary.fixedRoutes, screens: d.summary.routes, apis: d.summary.apis, functions: d.summary.dbFunctions, tests: d.summary.tests, pnDone: d.summary.pnDone, pnTotal: d.summary.pnDone + d.summary.pnOpen, oq: d.summary.oq, decisions: d.decisions.filter((x) => x.status === 'current').length, proposed: d.decisions.filter((x) => x.status === 'proposed').length, grades: d.summary.grades },
148
+ areas: Object.entries(d.areaCounts).sort((a, b) => b[1] - a[1]).slice(0, 6),
149
+ recent: d.commits.filter((c) => c.journeys.length).slice(0, 6).map((c) => ({ date: c.date, subject: c.subject, scenes: c.journeys.map((x) => x.label) })),
150
+ openQuestions: d.plans.filter((p) => p.oq).map((p) => ({ title: p.title, oq: p.oq })),
151
+ };
152
+ }
153
+
154
+ export function summaryLine(d) {
155
+ const s = d.summary;
156
+ const next = (d.roadmap || []).find((m) => m.status === '진행')?.title || (d.roadmap || []).find((m) => m.status === '다음')?.title || d.ledger.running[0]?.work || d.ledger.waiting.find((w) => !w.done && /대기/.test(w.state || ''))?.work || '';
157
+ const parts = [`장면 ${s.stepsLive}/${s.stepsTotal} 동작`, `화면 ${s.liveRoutes}/${s.routes} 실데이터`, ...(s.fixedRoutes ? [`하드코딩 표시 ${s.fixedRoutes}`] : []), `14일 커밋 ${s.commits}`];
158
+ if (d.deploy && d.deploy.behindRuntime > 0) parts.push(`미배포 ${d.deploy.behindRuntime}`);
159
+ if (s.warnings) parts.push(`경고 ${s.warnings}`);
160
+ return `${parts.join(' · ')}${next ? ` · 다음: ${next.replace(/\*\*/g, '').replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').slice(0, 40)}` : ''}`;
161
+ }
package/src/init.mjs ADDED
@@ -0,0 +1,70 @@
1
+ // livemap init: 없는 파일만 템플릿으로 만들고 .gitignore 줄과 npm 스크립트를 넣는다. 다시 실행하면 아무것도 바꾸지 않는다.
2
+ import { existsSync, mkdirSync, copyFileSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+
5
+ export const INIT_FILES = [
6
+ ['templates/config.json', 'map/config.json'],
7
+ ['templates/journeys.json', 'map/semantic/journeys.json'],
8
+ ['templates/README.md', 'map/README.md'],
9
+ ['templates/captures-README.md', 'map/captures/README.md'],
10
+ ];
11
+ export const IGNORE_LINE = 'map/.out/';
12
+ export const BUDGET_SCRIPT = 'playwright test --config node_modules/@pghoya2956/livemap/budget/playwright.config.mjs';
13
+ export const SCRIPTS = {
14
+ map: 'livemap build',
15
+ 'map:check': 'livemap check',
16
+ 'map:serve': 'livemap serve',
17
+ 'map:export': 'livemap export map/.out/site',
18
+ 'test:report': 'livemap test-report',
19
+ };
20
+
21
+ const hasPlaywright = (root, pkg) => existsSync(join(root, 'node_modules/@playwright/test/package.json'))
22
+ || Boolean(pkg?.devDependencies?.['@playwright/test'] || pkg?.dependencies?.['@playwright/test']);
23
+
24
+ export function init({ root, pkgRoot }) {
25
+ const created = [];
26
+ const kept = [];
27
+ for (const [from, to] of INIT_FILES) {
28
+ const dst = join(root, to);
29
+ if (existsSync(dst)) continue;
30
+ mkdirSync(dirname(dst), { recursive: true });
31
+ copyFileSync(join(pkgRoot, from), dst);
32
+ created.push(to);
33
+ }
34
+
35
+ const ignore = join(root, '.gitignore');
36
+ const ignoreText = existsSync(ignore) ? readFileSync(ignore, 'utf8') : '';
37
+ if (!ignoreText.split(/\r?\n/).some((l) => l.trim() === IGNORE_LINE || l.trim() === `/${IGNORE_LINE}`)) {
38
+ writeFileSync(ignore, `${ignoreText}${ignoreText && !ignoreText.endsWith('\n') ? '\n' : ''}${IGNORE_LINE}\n`);
39
+ created.push(`.gitignore: ${IGNORE_LINE}`);
40
+ }
41
+
42
+ const pkgFile = join(root, 'package.json');
43
+ const notes = [];
44
+ if (!existsSync(pkgFile)) {
45
+ notes.push('package.json 없음: npm init 뒤 다시 실행하면 npm 스크립트를 넣는다');
46
+ } else {
47
+ const raw = readFileSync(pkgFile, 'utf8');
48
+ const pkg = JSON.parse(raw);
49
+ const want = { ...SCRIPTS };
50
+ if (hasPlaywright(root, pkg)) want['map:budget'] = BUDGET_SCRIPT;
51
+ else notes.push('@playwright/test 없음: 화면 예산 검사를 쓰려면 npm i -D -E @playwright/test@1.63.0 뒤 다시 livemap init');
52
+ const scripts = { ...(pkg.scripts || {}) };
53
+ let changed = false;
54
+ for (const [name, value] of Object.entries(want)) {
55
+ if (!(name in scripts)) { scripts[name] = value; created.push(`npm 스크립트 ${name}`); changed = true; }
56
+ else if (scripts[name] !== value) kept.push(`npm 스크립트 ${name}: 기존 값 유지("${scripts[name]}", 권장 "${value}")`);
57
+ }
58
+ if (changed) {
59
+ pkg.scripts = scripts;
60
+ const indent = raw.match(/^[ \t]+(?=")/m)?.[0] ?? ' ';
61
+ writeFileSync(pkgFile, JSON.stringify(pkg, null, indent) + (raw.endsWith('\n') ? '\n' : ''));
62
+ }
63
+ }
64
+
65
+ for (const c of created) console.log(`+ ${c}`);
66
+ for (const k of kept) console.log(`= ${k}`);
67
+ for (const n of notes) console.log(`! ${n}`);
68
+ if (!created.length) console.log('livemap init: 변경 없음');
69
+ return 0;
70
+ }
@@ -0,0 +1,48 @@
1
+ // 그래프 모델. 어댑터는 노드·엣지만 넣고, 화면은 파생 뷰만 읽는다.
2
+ // 노드: { kind, id, label, props, src: { file, line, rule } } 엣지: { from, to, kind }
3
+ export const NODE_KINDS = ['journey', 'step', 'screen', 'api', 'function', 'table', 'migration', 'test', 'commit', 'decision', 'task', 'ledger', 'deploy', 'testreport', 'milestone'];
4
+ export const EDGE_KINDS = ['has_step', 'shows', 'uses', 'calls', 'invokes', 'touches', 'covers', 'changes', 'refs', 'defines', 'contains', 'tracks'];
5
+
6
+ export class Graph {
7
+ constructor() { this.nodes = new Map(); this.edges = []; this.adapters = []; }
8
+ key(kind, id) { return `${kind}:${id}`; }
9
+ add(kind, id, label, props = {}, src = null) {
10
+ if (!NODE_KINDS.includes(kind)) throw new Error(`unknown node kind ${kind}`);
11
+ const k = this.key(kind, id);
12
+ const existing = this.nodes.get(k);
13
+ if (existing) { Object.assign(existing.props, props); if (src && !existing.src) existing.src = src; return existing; }
14
+ const node = { kind, id, label: label ?? id, props, src };
15
+ this.nodes.set(k, node);
16
+ return node;
17
+ }
18
+ link(fromKind, fromId, kind, toKind, toId) {
19
+ if (!EDGE_KINDS.includes(kind)) throw new Error(`unknown edge kind ${kind}`);
20
+ const from = this.key(fromKind, fromId), to = this.key(toKind, toId);
21
+ if (!this.edges.some((e) => e.from === from && e.to === to && e.kind === kind)) this.edges.push({ from, to, kind });
22
+ }
23
+ get(kind, id) { return this.nodes.get(this.key(kind, id)) || null; }
24
+ of(kind) { return [...this.nodes.values()].filter((n) => n.kind === kind); }
25
+ out(kind, id, edgeKind = null) {
26
+ const from = this.key(kind, id);
27
+ return this.edges.filter((e) => e.from === from && (!edgeKind || e.kind === edgeKind)).map((e) => this.nodes.get(e.to)).filter(Boolean);
28
+ }
29
+ in(kind, id, edgeKind = null) {
30
+ const to = this.key(kind, id);
31
+ return this.edges.filter((e) => e.to === to && (!edgeKind || e.kind === edgeKind)).map((e) => this.nodes.get(e.from)).filter(Boolean);
32
+ }
33
+ report(name, status, count, error = null) { this.adapters.push({ name, status, count, error }); }
34
+ toJSON() {
35
+ return { schemaVersion: 1, adapters: this.adapters, nodes: [...this.nodes.values()], edges: this.edges };
36
+ }
37
+ }
38
+
39
+ // 어댑터 실행 래퍼: 실패해도 그래프 생성이 멈추지 않고 failed로 기록된다(빈 표를 "없음"으로 오해하지 않게).
40
+ export function runAdapter(graph, name, fn) {
41
+ const before = graph.nodes.size;
42
+ try {
43
+ const partial = fn(graph);
44
+ graph.report(name, partial ? 'partial' : 'ok', graph.nodes.size - before, partial || null);
45
+ } catch (e) {
46
+ graph.report(name, 'failed', graph.nodes.size - before, String(e.message || e));
47
+ }
48
+ }
@@ -0,0 +1,36 @@
1
+ // 어댑터 공용 유틸. 파일 읽기·순회·git 호출을 한 곳에 둔다(테스트에서 ROOT를 바꿔 끼운다).
2
+ import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { join } from 'node:path';
5
+
6
+ export function makeFs(ROOT) {
7
+ const abs = (p) => join(ROOT, p);
8
+ const has = (p) => existsSync(abs(p));
9
+ const isDir = (p) => has(p) && statSync(abs(p)).isDirectory();
10
+ const read = (p) => readFileSync(abs(p), 'utf8');
11
+ const walk = (dir, pred, acc = []) => {
12
+ if (!isDir(dir)) return acc;
13
+ for (const name of readdirSync(abs(dir))) {
14
+ const rel = join(dir, name);
15
+ if (statSync(abs(rel)).isDirectory()) { if (name !== 'node_modules' && name !== '.git') walk(rel, pred, acc); }
16
+ else if (pred(rel)) acc.push(rel);
17
+ }
18
+ return acc;
19
+ };
20
+ const ls = (dir) => (isDir(dir) ? readdirSync(abs(dir)) : []);
21
+ let gitOk = null;
22
+ const git = (...a) => {
23
+ try { const out = execFileSync('git', ['-C', ROOT, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); gitOk = true; return out; }
24
+ catch { if (gitOk === null) gitOk = false; return ''; }
25
+ };
26
+ const lastCommit = (path) => {
27
+ const line = git('log', '-1', '--format=%h|%ad|%s', '--date=short', '--', path);
28
+ if (!line) return null;
29
+ const [sha, date, ...s] = line.split('|');
30
+ return { sha, date, subject: s.join('|') };
31
+ };
32
+ // main → origin/main → HEAD 순으로 존재하는 참조를 고른다(CI 체크아웃에는 로컬 main이 없을 수 있다).
33
+ const resolveRef = (name) => { for (const c of [name, `origin/${name}`, 'HEAD']) if (git('rev-parse', '--verify', '--quiet', c)) return c; return null; };
34
+ const lineOf = (text, needle) => { const i = text.indexOf(needle); return i < 0 ? null : text.slice(0, i).split('\n').length; };
35
+ return { ROOT, abs, has, isDir, read, walk, ls, git, lastCommit, lineOf, resolveRef, hasGit: () => { if (gitOk === null) git('rev-parse', 'HEAD'); return gitOk; } };
36
+ }
package/src/serve.mjs ADDED
@@ -0,0 +1,106 @@
1
+ // 로컬 뷰와 export. 127.0.0.1 전용.
2
+ // 브라우저 주소 → 파일 배치는 locate() 하나가 정하고, serve(재빌드)·serve --static·export가 같은 배치를 쓴다.
3
+ // /map/ /map/map.css /map/map.js /map/fonts/* → 화면 폴더(패키지 site/)
4
+ // /map/captures/<id>.jpg → 캡처 폴더(프로젝트 config.captures.site)
5
+ // /map/data/*.json → 생성물 폴더(--out)
6
+ import { createServer } from 'node:http';
7
+ import { readFile, stat } from 'node:fs/promises';
8
+ import { existsSync, mkdirSync, readdirSync, rmSync, statSync, copyFileSync, writeFileSync } from 'node:fs';
9
+ import { join, extname, resolve, dirname, sep } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ const types = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.jpg': 'image/jpeg', '.png': 'image/png', '.svg': 'image/svg+xml', '.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8' };
13
+ export const SITE = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'site');
14
+ export const DATA_FILES = ['data.json', 'overview.json', 'graph.json'];
15
+ export const EXPORT_MARK = '.livemap-export';
16
+ export const CSP = "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'";
17
+
18
+ // 주소(/map/ 아래 경로)를 { kind, rel }로 푼다. 숨김 파일·경로 탈출은 null.
19
+ export function locate(pathname) {
20
+ if (!pathname.startsWith('/map/')) return null;
21
+ let rel = pathname.slice('/map/'.length);
22
+ if (rel === '') rel = 'index.html';
23
+ let parts;
24
+ try { parts = rel.split('/').map((p) => decodeURIComponent(p)); } catch { return null; }
25
+ if (parts.some((p) => p === '' || p.startsWith('.') || /[\\/\0]/.test(p))) return null;
26
+ if (parts[0] === 'data') return parts.length === 2 ? { kind: 'data', rel: parts[1] } : null;
27
+ if (parts[0] === 'captures') return parts.length === 2 ? { kind: 'captures', rel: parts[1] } : null;
28
+ return { kind: 'site', rel: parts.join('/') };
29
+ }
30
+
31
+ const inside = (base, file) => file === base || file.startsWith(base.endsWith(sep) ? base : base + sep);
32
+
33
+ // 배치 종류별 원본 폴더. 재빌드 서빙은 패키지·프로젝트·생성물 세 곳, static은 export 폴더 한 곳.
34
+ export function sourcesFor({ static: dir, out, captures }) {
35
+ if (dir) return { site: dir, captures: join(dir, 'captures'), data: join(dir, 'data') };
36
+ return { site: SITE, captures, data: out };
37
+ }
38
+
39
+ export function serve({ root, out, port, build, captures, static: staticDir }) {
40
+ const src = sourcesFor({ static: staticDir, out, captures });
41
+ let last = 0;
42
+ const server = createServer(async (req, res) => {
43
+ const url = new URL(req.url, 'http://localhost');
44
+ const path = url.pathname;
45
+ if (path === '/' || path === '/map') { res.writeHead(302, { location: '/map/' }); return res.end(); }
46
+ // 배포 서빙과 같은 CSP를 걸어 인라인 스타일·스크립트 의존을 로컬에서 먼저 잡는다.
47
+ res.setHeader('content-security-policy', CSP);
48
+ const loc = locate(path);
49
+ if (!loc) { res.writeHead(path.startsWith('/map/') ? 403 : 404); return res.end('not found'); }
50
+ try {
51
+ if (loc.kind === 'data' && !staticDir && Date.now() - last > 5000) { await build(); last = Date.now(); }
52
+ const base = src[loc.kind];
53
+ const file = resolve(base, loc.rel);
54
+ if (!inside(base, file)) { res.writeHead(403); return res.end(); }
55
+ if (!(await stat(file)).isFile()) throw new Error('not a file');
56
+ const cache = loc.kind === 'data' ? 'no-store' : 'no-cache';
57
+ res.writeHead(200, { 'content-type': types[extname(file)] || 'application/octet-stream', 'cache-control': cache });
58
+ res.end(await readFile(file));
59
+ } catch {
60
+ res.writeHead(404); res.end('not found');
61
+ }
62
+ });
63
+ const label = staticDir ? `static ${staticDir}` : `root ${root}`;
64
+ server.listen(port, '127.0.0.1', () => console.log(`map serve → http://127.0.0.1:${port}/map/ (${label})`));
65
+ return server;
66
+ }
67
+
68
+ // 폴더 안 파일을 숨김 파일 빼고 상대 경로로 나열한다.
69
+ function listFiles(dir, prefix = '') {
70
+ if (!existsSync(dir)) return [];
71
+ const acc = [];
72
+ for (const name of readdirSync(dir).sort()) {
73
+ if (name.startsWith('.')) continue;
74
+ const abs = join(dir, name);
75
+ const rel = prefix ? `${prefix}/${name}` : name;
76
+ if (statSync(abs).isDirectory()) acc.push(...listFiles(abs, rel));
77
+ else acc.push(rel);
78
+ }
79
+ return acc;
80
+ }
81
+
82
+ // export 폴더를 serve --static이 읽는 배치(locate의 역)로 만든다. 종료 코드를 돌려준다.
83
+ export function exportSite({ out, captures, target }) {
84
+ const missing = DATA_FILES.filter((f) => !existsSync(join(out, f)));
85
+ if (missing.length) { console.error(`생성물 없음(${missing.join(', ')}): 먼저 livemap build --out ${out}`); return 2; }
86
+ if (existsSync(target)) {
87
+ if (!statSync(target).isDirectory()) { console.error(`export 대상이 폴더가 아님: ${target}`); return 2; }
88
+ const entries = readdirSync(target);
89
+ if (entries.length && !entries.includes(EXPORT_MARK)) {
90
+ console.error(`export 대상이 비어 있지 않고 이전 export 폴더도 아님(지우지 않음): ${target}`);
91
+ return 2;
92
+ }
93
+ rmSync(target, { recursive: true, force: true });
94
+ }
95
+ const dst = sourcesFor({ static: target });
96
+ const copies = [
97
+ ...listFiles(SITE).map((rel) => [join(SITE, rel), join(dst.site, rel)]),
98
+ ...listFiles(captures).filter((f) => !f.includes('/') && f.endsWith('.jpg')).map((f) => [join(captures, f), join(dst.captures, f)]),
99
+ ...DATA_FILES.map((f) => [join(out, f), join(dst.data, f)]),
100
+ ];
101
+ for (const [from, to] of copies) { mkdirSync(dirname(to), { recursive: true }); copyFileSync(from, to); }
102
+ writeFileSync(join(target, EXPORT_MARK), 'livemap export\n');
103
+ const caps = copies.filter(([, to]) => dirname(to) === dst.captures).length;
104
+ console.log(`map export → ${target}: 파일 ${copies.length} (캡처 ${caps})`);
105
+ return 0;
106
+ }
@@ -0,0 +1,22 @@
1
+ // 단위 검사를 JUnit 리포트로 남기고 기준 커밋·시각을 옆에 적는다. 상황판이 이 둘을 읽어 "최신 커밋에서 통과"(등급 A)를 판정한다.
2
+ // 검사 폴더는 config.tests.dir, 리포트는 config.tests.report(메타는 같은 이름의 .json). 로컬 스택이 필요한 검사면 개발 머신에서 돌린다.
3
+ import { spawnSync, execFileSync } from 'node:child_process';
4
+ import { mkdirSync, writeFileSync, readdirSync, existsSync } from 'node:fs';
5
+ import { dirname, join, resolve } from 'node:path';
6
+
7
+ export function testReport({ root, cfg }) {
8
+ const dir = cfg?.tests?.dir || 'tests';
9
+ const report = cfg?.tests?.report || 'map/.out/junit.xml';
10
+ const abs = (p) => resolve(root, p);
11
+ if (!existsSync(abs(dir))) { console.error(`검사 폴더 없음: ${dir} (config.tests.dir)`); return 2; }
12
+ mkdirSync(dirname(abs(report)), { recursive: true });
13
+ // Node 22.22는 디렉토리 인자를 검사 하나의 실패로 취급하므로 파일 목록을 넘긴다.
14
+ const files = readdirSync(abs(dir)).filter((f) => f.endsWith('.test.mjs')).map((f) => join(dir, f));
15
+ const r = spawnSync('node', ['--test', '--test-concurrency=1', '--test-reporter=junit', `--test-reporter-destination=${report}`, ...files], { stdio: 'inherit', cwd: root });
16
+ let sha = null;
17
+ try { sha = execFileSync('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); } catch { /* git 저장소가 아니면 기준 커밋 없음 */ }
18
+ const meta = report.replace(/\.xml$/, '.json');
19
+ writeFileSync(abs(meta), JSON.stringify({ sha, at: new Date().toISOString(), exit: r.status }, null, 2));
20
+ console.log(`test report → ${report} (sha ${sha ? sha.slice(0, 7) : '-'}, exit ${r.status})`);
21
+ return r.status ?? 1;
22
+ }
@@ -0,0 +1,29 @@
1
+ # 프로젝트 상황판
2
+
3
+ 제품 오너가 코드를 읽지 않고 "어디까지 실제로 동작하나, 지금 무엇을 하나, 무엇이 바뀌었나"를 한 화면에서 보고, 여정 → 장면 → 화면·API·DB·검사·결정·작업으로 파고드는 뷰다. 엔진은 npm 패키지 `@pghoya2956/livemap`이고, 이 폴더에는 프로젝트 소유 파일만 둔다. 배포본 주소: `<배포 주소>/map/`. 실시간은 로컬 `npm run map:serve`.
4
+
5
+ ## 명령
6
+
7
+ | 명령 | 하는 일 |
8
+ |---|---|
9
+ | `npm run map` | 저장소 스캔 → `map/.out/{graph,data,overview}.json` (git 제외) |
10
+ | `npm run map:check` | 정합 검사. 오류면 exit 1(CI 게이트): 어댑터 실패·바닥값 미달·장면이 가리키는 라우트 없음·상태 모순·참조 미해결 |
11
+ | `npm run map:serve` | `http://127.0.0.1:4180/map/` 로컬 뷰. 요청마다 재빌드(5초 캐시) |
12
+ | `npm run map:budget` | 화면 예산 Playwright 검사 + 개요 스크린샷 `map/.out/overview-1440.png` |
13
+ | `npm run map:export` | 배포용 한 폴더 `map/.out/site`(먼저 `npm run map`) |
14
+ | `npm run test:report` | 단위 검사를 JUnit으로 남겨 장면 등급 A(최신 커밋에서 통과) 판정에 쓴다 |
15
+
16
+ ## 이 폴더
17
+
18
+ | 파일 | 내용 |
19
+ |---|---|
20
+ | `config.json` | 어댑터 입력 경로·바닥값·화면 예산. `engine`은 엔진 major |
21
+ | `semantic/journeys.json` | 손으로 유지하는 유일한 층: 배우·목표·장면. 장면 상태가 바뀌는 병합은 같은 커밋에서 이 파일을 고친다 |
22
+ | `captures/*.jpg` | 장면 캡처 |
23
+ | `adapters/<이름>.mjs` | 이 프로젝트만의 어댑터(선택). 참조 어댑터와 이름이 같으면 참조 어댑터를 가린다 |
24
+
25
+ 작성 안내·어댑터 계약·서빙 방법은 `node_modules/@pghoya2956/livemap/docs/`에 있다.
26
+
27
+ ## 업그레이드
28
+
29
+ `npm i -D -E @pghoya2956/livemap@<버전>`을 커밋한다. major가 바뀌면 `docs/migrate.md`를 따른다.
@@ -0,0 +1 @@
1
+ 이 폴더에 장면 캡처(JPEG, 파일명 = `map/semantic/journeys.json`의 `capture` 값 + `.jpg`)를 둔다. 없으면 장면 카드가 빈 칸으로 그려진다. 위치는 `map/config.json`의 `captures.site`가 정한다.
@@ -0,0 +1,18 @@
1
+ {
2
+ "engine": 1,
3
+ "project": { "name": "프로젝트 이름", "host": "https://example.invalid" },
4
+ "adapters": ["router", "bff", "migrations", "tests", "wiki", "tasks", "roadmap", "git", "deploy", "testreport"],
5
+ "router": { "app": "web/src/App.tsx", "pagesDir": "web/src/pages", "localDirs": ["web/src/pages", "web/src/components"], "mockPattern": "/mock'", "fixedPattern": "/content/", "livePattern": "lib/queries", "hookApi": { "Session": "/api/session" } },
6
+ "bff": { "server": "app/server.mjs" },
7
+ "migrations": { "dir": "supabase/migrations" },
8
+ "tests": { "dir": "tests", "gatePattern": "ALLOW_DESTRUCTIVE", "report": "map/.out/junit.xml" },
9
+ "tasks": { "dir": "tasks", "index": "tasks/index.md" },
10
+ "roadmap": { "file": "tasks/roadmap.md" },
11
+ "wiki": { "index": ".agent/wiki/index.md", "sources": ".agent/wiki/sources.yaml" },
12
+ "git": { "branch": "main", "sinceDays": 14, "areas": [["web/", "화면"], ["app/", "서버"], ["supabase/", "DB"], ["tests/", "검사"], ["deploy/", "배포"], [".github/", "배포"], ["map/", "상황판"], ["tasks/", "문서"], [".md", "문서"]], "runtimePaths": ["web", "app", "supabase", "Dockerfile", "package.json"] },
13
+ "deploy": { "manifest": "deploy/k8s/app.yaml", "imagePattern": "image: ghcr\\.io/[^:]+:([0-9a-f]{40})" },
14
+ "semantic": "map/semantic/journeys.json",
15
+ "captures": { "site": "map/captures" },
16
+ "floors": { "screen": 1, "api": 1 },
17
+ "budget": { "viewport": [1440, 900], "maxPanels": 8, "maxRowsPerPanel": 6, "navItems": 5 }
18
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "project": { "name": "프로젝트 이름", "tagline": "한 줄 설명", "host": "https://example.invalid" },
3
+ "actors": { "user": "사용자", "staff": "운영자" },
4
+ "statusLegend": { "live": "실데이터로 동작", "mock": "화면만(목업 데이터)", "planned": "미착수(스펙 있음)", "next": "다음 스펙" },
5
+ "journeys": [
6
+ {
7
+ "id": "first", "title": "첫 여정(예시, 바꿔 쓴다)", "actor": "user", "lane": "사용자 여정",
8
+ "goal": "배우가 이루려는 목표 한 문장",
9
+ "steps": [
10
+ { "id": "start", "label": "첫 장면", "intent": "사용자가 원해서 하는 일", "status": "mock", "screens": ["/"], "capture": null, "refs": [], "reviewedAt": "2026-01-01" }
11
+ ]
12
+ }
13
+ ],
14
+ "captures": { "dir": "map/captures", "note": "장면 캡처(JPEG 720px). 화면이 바뀌면 같은 이름으로 다시 만든다." }
15
+ }