@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/CHANGELOG.md +15 -0
- package/LICENSE +24 -0
- package/README.md +70 -0
- package/bin/livemap.mjs +6 -0
- package/budget/playwright.config.mjs +24 -0
- package/budget/view-budget.spec.mjs +70 -0
- package/docs/adapter-contract.md +96 -0
- package/docs/hosting-and-csp.md +98 -0
- package/docs/migrate.md +17 -0
- package/docs/semantic-authoring.md +77 -0
- package/docs/semantic-schema.md +44 -0
- package/docs/view-budget.md +40 -0
- package/package.json +19 -0
- package/site/fonts/LICENSE.txt +104 -0
- package/site/fonts/PretendardVariable.woff2 +0 -0
- package/site/index.html +25 -0
- package/site/map.css +167 -0
- package/site/map.js +204 -0
- package/src/adapters/bff.mjs +39 -0
- package/src/adapters/deploy.mjs +24 -0
- package/src/adapters/git.mjs +30 -0
- package/src/adapters/migrations.mjs +32 -0
- package/src/adapters/roadmap.mjs +33 -0
- package/src/adapters/router.mjs +57 -0
- package/src/adapters/tasks.mjs +51 -0
- package/src/adapters/testreport.mjs +24 -0
- package/src/adapters/tests.mjs +38 -0
- package/src/adapters/wiki.mjs +13 -0
- package/src/check.mjs +38 -0
- package/src/cli.mjs +156 -0
- package/src/derive.mjs +161 -0
- package/src/init.mjs +70 -0
- package/src/lib/graph.mjs +48 -0
- package/src/lib/util.mjs +36 -0
- package/src/serve.mjs +106 -0
- package/src/test-report.mjs +22 -0
- package/templates/README.md +29 -0
- package/templates/captures-README.md +1 -0
- package/templates/config.json +18 -0
- package/templates/journeys.json +15 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// 배포 어댑터: 배포 매니페스트의 이미지 태그(커밋 sha)를 읽어 main 대비 미배포 커밋 수(전체·런타임)를 센다.
|
|
2
|
+
export default function deploy(g, fs, cfg) {
|
|
3
|
+
const c = cfg.deploy;
|
|
4
|
+
if (!fs.has(c.manifest)) return `매니페스트 없음: ${c.manifest}`;
|
|
5
|
+
const text = fs.read(c.manifest);
|
|
6
|
+
const sha = (text.match(new RegExp(c.imagePattern)) || [])[1] || null;
|
|
7
|
+
if (!sha) return '이미지 태그를 못 읽음';
|
|
8
|
+
let behind = null, behindRuntime = null;
|
|
9
|
+
const ref = fs.resolveRef(cfg.git?.branch || 'main');
|
|
10
|
+
// 이미지 빌드 중(CI)에는 지금 커밋이 곧 배포본이다. 매니페스트 태그는 배포 뒤에야 바뀌므로 CI가 알려준 sha를 우선한다.
|
|
11
|
+
const assumed = process.env.MAP_ASSUME_DEPLOYED_SHA || null;
|
|
12
|
+
if (assumed && ref && fs.git('rev-parse', ref) === assumed) {
|
|
13
|
+
g.add('deploy', 'homelab', cfg.project.host, { host: cfg.project.host, sha: assumed.slice(0, 7), full: assumed, behind: 0, behindRuntime: 0, assumed: true }, { file: c.manifest, line: null, rule: 'deploy:MAP_ASSUME_DEPLOYED_SHA' });
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
if (fs.hasGit() && ref) {
|
|
17
|
+
const b = fs.git('rev-list', '--count', `${sha}..${ref}`);
|
|
18
|
+
behind = b === '' ? null : Number(b);
|
|
19
|
+
const r = fs.git('rev-list', '--count', `${sha}..${ref}`, '--', ...(cfg.git?.runtimePaths || []));
|
|
20
|
+
behindRuntime = r === '' ? null : Number(r);
|
|
21
|
+
}
|
|
22
|
+
g.add('deploy', 'homelab', cfg.project.host, { host: cfg.project.host, sha: sha.slice(0, 7), full: sha, behind, behindRuntime }, { file: c.manifest, line: fs.lineOf(text, sha), rule: 'deploy:image tag' });
|
|
23
|
+
return behind === null ? '배포 sha가 main 이력에 없음(뒤처짐 계산 불가)' : null;
|
|
24
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// 변경 어댑터: 최근 N일 main 커밋을 commit 노드로 만들고, 건드린 파일을 screen(페이지·닫힘 파일)·api·migration 노드에 changes 엣지로 잇는다.
|
|
2
|
+
export default function gitAdapter(g, fs, cfg) {
|
|
3
|
+
const c = cfg.git;
|
|
4
|
+
if (!fs.hasGit()) return 'git 없음';
|
|
5
|
+
const ref = fs.resolveRef(c.branch);
|
|
6
|
+
if (!ref) return `브랜치 없음: ${c.branch}`;
|
|
7
|
+
const head = fs.git('rev-parse', ref);
|
|
8
|
+
const log = fs.git('log', ref, `--since=${c.sinceDays} days ago`, '--format=%x1e%h|%ad|%an|%s', '--date=iso-strict', '--name-only');
|
|
9
|
+
const areaOf = (p) => { for (const [prefix, area] of c.areas) if (prefix.startsWith('.') && !prefix.includes('/') ? p.endsWith(prefix) : p.startsWith(prefix)) return area; return '기타'; };
|
|
10
|
+
const fileToScreens = {};
|
|
11
|
+
for (const s of g.of('screen')) for (const f of s.props.files || []) (fileToScreens[f] ||= []).push(s.id);
|
|
12
|
+
const serverFile = cfg.bff?.server || null;
|
|
13
|
+
let n = 0;
|
|
14
|
+
for (const chunk of log.split('\x1e').filter(Boolean)) {
|
|
15
|
+
const [headLine, ...rest] = chunk.trim().split('\n');
|
|
16
|
+
const [sha, date, author, ...s] = headLine.split('|');
|
|
17
|
+
const files = rest.filter(Boolean);
|
|
18
|
+
const areas = [...new Set(files.map(areaOf))];
|
|
19
|
+
const runtime = files.some((f) => c.runtimePaths.some((p) => f === p || f.startsWith(p + '/')));
|
|
20
|
+
g.add('commit', sha, s.join('|'), { date, author, files: files.length, areas, runtime }, { file: null, line: null, rule: `git:log ${ref}` });
|
|
21
|
+
for (const f of files) {
|
|
22
|
+
for (const sid of fileToScreens[f] || []) g.link('commit', sha, 'changes', 'screen', sid);
|
|
23
|
+
if (f === serverFile) for (const a of g.of('api')) g.link('commit', sha, 'changes', 'api', a.id);
|
|
24
|
+
if (cfg.migrations?.dir && f.startsWith(cfg.migrations.dir + '/')) { const id = f.split('/').pop(); if (g.get('migration', id)) g.link('commit', sha, 'changes', 'migration', id); }
|
|
25
|
+
}
|
|
26
|
+
n += 1;
|
|
27
|
+
}
|
|
28
|
+
g.add('deploy', 'head', 'main', { sha: head.slice(0, 7), full: head, subject: fs.git('log', '-1', '--format=%s', ref), date: fs.git('log', '-1', '--format=%ad', '--date=iso-strict', ref) }, { file: null, line: null, rule: 'git:rev-parse' });
|
|
29
|
+
return n === 0 ? `${c.sinceDays}일 커밋 0건` : null;
|
|
30
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// DB 어댑터: migration SQL에서 migration·table·function 노드를 만들고, 함수 본문이 건드리는 테이블을 잇는다.
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export default function migrations(g, fs, cfg) {
|
|
5
|
+
const dir = cfg.migrations.dir;
|
|
6
|
+
const files = fs.walk(dir, (p) => p.endsWith('.sql')).sort();
|
|
7
|
+
if (!files.length) return `migration 없음: ${dir}`;
|
|
8
|
+
const allTables = [];
|
|
9
|
+
const parsed = files.map((f) => {
|
|
10
|
+
const sql = fs.read(f).toLowerCase();
|
|
11
|
+
const tables = [...sql.matchAll(/create table (?:if not exists )?([a-z_.]+)/g)].map((m) => m[1]);
|
|
12
|
+
const functions = [...new Set([...sql.matchAll(/create (?:or replace )?function ([a-z_.]+)/g)].map((m) => m[1]))];
|
|
13
|
+
allTables.push(...tables);
|
|
14
|
+
return { f, sql, tables, functions, grants: (sql.match(/^\s*grant /gm) || []).length, rls: (sql.match(/enable row level security/g) || []).length };
|
|
15
|
+
});
|
|
16
|
+
for (const p of parsed) {
|
|
17
|
+
const id = basename(p.f);
|
|
18
|
+
g.add('migration', id, id.replace(/^\d+_/, ''), { file: p.f, tables: p.tables, functions: p.functions, grants: p.grants, rls: p.rls, last: fs.lastCommit(p.f) }, { file: p.f, line: 1, rule: 'migrations:create table|function' });
|
|
19
|
+
for (const t of p.tables) { g.add('table', t, t.split('.').pop(), { schema: t.split('.')[0] }, { file: p.f, line: fs.lineOf(p.sql, t), rule: 'migrations:create table' }); g.link('migration', id, 'contains', 'table', t); }
|
|
20
|
+
const parts = p.sql.split(/create (?:or replace )?function /);
|
|
21
|
+
for (const part of parts.slice(1)) {
|
|
22
|
+
const name = (part.match(/^([a-z_.]+)/) || [])[1];
|
|
23
|
+
if (!name) continue;
|
|
24
|
+
const short = name.split('.').pop();
|
|
25
|
+
const body = part.split(/\$\$/).slice(1, 2).join('') || part;
|
|
26
|
+
g.add('function', short, short, { qualified: name, migration: id }, { file: p.f, line: fs.lineOf(p.sql, `function ${name}`), rule: 'migrations:create function' });
|
|
27
|
+
g.link('migration', id, 'contains', 'function', short);
|
|
28
|
+
for (const t of allTables) if (body.includes(t) || body.includes(t.split('.').pop() + ' ')) g.link('function', short, 'touches', 'table', t);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// 로드맵 어댑터: tasks/roadmap.md 의 `## 제목` 절마다 milestone 노드를 만든다. 절의 첫 문단은 목표, `- 키: 값` 목록은 속성이다.
|
|
2
|
+
// 순서는 파일 안 위치다. 장면·작업·선행은 문자열로만 남기고 해석(존재 확인)은 derive·check가 한다.
|
|
3
|
+
const KEYS = { id: 'id', 상태: 'status', '진행 방식': 'mode', 작업: 'tasks', 장면: 'scenes', 선행: 'deps', '결정 대기': 'waitingOn', '완료 기준': 'done' };
|
|
4
|
+
const LISTS = new Set(['tasks', 'scenes', 'deps']);
|
|
5
|
+
|
|
6
|
+
export default function roadmap(g, fs, cfg) {
|
|
7
|
+
const file = cfg.roadmap?.file;
|
|
8
|
+
if (!file) return null;
|
|
9
|
+
if (!fs.has(file)) return `로드맵 파일 없음: ${file}`;
|
|
10
|
+
const text = fs.read(file);
|
|
11
|
+
let n = 0;
|
|
12
|
+
for (const block of text.split(/^## /m).slice(1)) {
|
|
13
|
+
const [head, ...lines] = block.split('\n');
|
|
14
|
+
const title = head.trim();
|
|
15
|
+
const props = { order: n + 1, goal: '', status: '', mode: '', tasks: [], scenes: [], deps: [], waitingOn: '', done: '' };
|
|
16
|
+
const goal = [];
|
|
17
|
+
for (const line of lines) {
|
|
18
|
+
const m = line.match(/^- ([^:]+):\s*(.*)$/);
|
|
19
|
+
if (m && KEYS[m[1].trim()]) {
|
|
20
|
+
const key = KEYS[m[1].trim()];
|
|
21
|
+
const value = m[2].replace(/`/g, '').trim();
|
|
22
|
+
props[key] = LISTS.has(key) ? value.split(/[,,]\s*/).map((x) => x.trim()).filter((x) => x && x !== '—') : value;
|
|
23
|
+
} else if (!line.startsWith('- ') && line.trim()) goal.push(line.trim());
|
|
24
|
+
}
|
|
25
|
+
props.goal = goal.join(' ');
|
|
26
|
+
const id = props.id || `m${n + 1}`;
|
|
27
|
+
delete props.id;
|
|
28
|
+
g.add('milestone', id, title, props, { file, line: fs.lineOf(text, `## ${head}`), rule: 'roadmap:## 제목 + - 키: 값' });
|
|
29
|
+
for (const t of props.tasks) g.link('milestone', id, 'tracks', 'task', t);
|
|
30
|
+
n += 1;
|
|
31
|
+
}
|
|
32
|
+
return n === 0 ? '로드맵 항목 0건' : null;
|
|
33
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// 화면 어댑터: React Router의 <Route> 선언에서 screen 노드를 만든다. 페이지와 그 로컬 import 닫힘에서 데이터 출처(live/mock/mixed)와 호출 API를 읽는다.
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export default function router(g, fs, cfg) {
|
|
5
|
+
const c = cfg.router;
|
|
6
|
+
if (!fs.has(c.app)) return `라우터 파일 없음: ${c.app}`;
|
|
7
|
+
const pageFiles = fs.walk(c.pagesDir, (p) => /\.tsx?$/.test(p));
|
|
8
|
+
const owner = new Map();
|
|
9
|
+
for (const f of pageFiles) for (const m of fs.read(f).matchAll(/export\s+(?:function|const)\s+([A-Z]\w+)/g)) owner.set(m[1], f);
|
|
10
|
+
|
|
11
|
+
const resolveLocal = (from, spec) => {
|
|
12
|
+
const base = join(from, '..', spec);
|
|
13
|
+
for (const cand of [base + '.tsx', base + '.ts', join(base, 'index.tsx'), join(base, 'index.ts')]) if (fs.has(cand)) return cand;
|
|
14
|
+
return null;
|
|
15
|
+
};
|
|
16
|
+
const closure = (file, depth = 3, seen = new Set()) => {
|
|
17
|
+
if (seen.has(file) || depth < 0) return seen;
|
|
18
|
+
seen.add(file);
|
|
19
|
+
for (const m of fs.read(file).matchAll(/from\s+'(\.[^']+)'/g)) {
|
|
20
|
+
const t = resolveLocal(file, m[1]);
|
|
21
|
+
if (t && c.localDirs.some((d) => t.startsWith(d + '/'))) closure(t, depth - 1, seen);
|
|
22
|
+
}
|
|
23
|
+
return seen;
|
|
24
|
+
};
|
|
25
|
+
const classify = (file) => {
|
|
26
|
+
const files = [...closure(file)];
|
|
27
|
+
const all = files.map(fs.read).join('\n');
|
|
28
|
+
const mockVia = files.filter((f) => fs.read(f).includes(c.mockPattern));
|
|
29
|
+
// 코드에 고정된 표시값(이름·사진·문구·가격 표시)을 읽는 파일. 출처 분류는 바꾸지 않고 따로 센다.
|
|
30
|
+
const fixedVia = c.fixedPattern ? files.filter((f) => fs.read(f).includes(c.fixedPattern)) : [];
|
|
31
|
+
const live = all.includes(c.livePattern);
|
|
32
|
+
const hookNames = Object.keys(c.hookApi || {});
|
|
33
|
+
const hooks = hookNames.length ? [...new Set([...all.matchAll(new RegExp(`use(${hookNames.join('|')})\\b`, 'g'))].map((m) => m[1]))] : [];
|
|
34
|
+
const source = mockVia.length && live ? 'mixed' : mockVia.length ? 'mock' : live ? 'live' : 'static';
|
|
35
|
+
return { files, mockVia: mockVia.map((f) => relative('web/src', f)), fixedVia: fixedVia.map((f) => relative('web/src', f)), source, apis: hooks.map((h) => c.hookApi[h]).filter(Boolean) };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const app = fs.read(c.app);
|
|
39
|
+
let parent = null, n = 0;
|
|
40
|
+
app.split('\n').forEach((line, i) => {
|
|
41
|
+
const m = line.match(/<Route\s+(?:path="([^"]+)"|(index))[^>]*element=\{(?:\w*[gG]uard\()?<(\w+)/);
|
|
42
|
+
if (m) {
|
|
43
|
+
const own = m[2] ? '' : m[1];
|
|
44
|
+
const path = parent ? (own ? `${parent}/${own}` : parent) : own;
|
|
45
|
+
const isLayout = !/\/>\s*$/.test(line.trim()) && !/<\/Route>/.test(line);
|
|
46
|
+
if (isLayout) { parent = m[1]; return; }
|
|
47
|
+
if (path === '*' || path.endsWith('/*')) return;
|
|
48
|
+
const file = owner.get(m[3]) || null;
|
|
49
|
+
const cls = file ? classify(file) : { files: [], mockVia: [], fixedVia: [], source: 'static', apis: [] };
|
|
50
|
+
g.add('screen', path, path, { component: m[3], file, guarded: /[gG]uard\(/.test(line) || parent !== null, source: cls.source, mockVia: cls.mockVia, fixedVia: cls.fixedVia, files: cls.files, last: file ? fs.lastCommit(file) : null }, { file: c.app, line: i + 1, rule: 'router:<Route path>' });
|
|
51
|
+
for (const a of cls.apis) { g.add('api', a, a); g.link('screen', path, 'calls', 'api', a); }
|
|
52
|
+
n += 1;
|
|
53
|
+
}
|
|
54
|
+
if (/<\/Route>/.test(line)) parent = null;
|
|
55
|
+
});
|
|
56
|
+
return n === 0 ? '라우트 0건' : null;
|
|
57
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// 작업 어댑터: tasks/<date>-<slug>/ 폴더에서 task 노드를 만든다. 단계는 폴더 안 문서로, 상태는 폴더 문서를 먼저 보고 장부(tasks/index.md)를 보조로 판정한다.
|
|
2
|
+
// DEC-nn·PN-nn 정의 위치를 defines 엣지로 남겨 여정 장면의 refs가 스펙 문서로 이어지게 한다.
|
|
3
|
+
export default function tasks(g, fs, cfg) {
|
|
4
|
+
const c = cfg.tasks;
|
|
5
|
+
const dirs = fs.ls(c.dir).filter((n) => /^\d{8}-/.test(n) && fs.isDir(`${c.dir}/${n}`)).sort();
|
|
6
|
+
if (!dirs.length) return `작업 폴더 없음: ${c.dir}`;
|
|
7
|
+
const index = fs.has(c.index) ? fs.read(c.index) : '';
|
|
8
|
+
const sections = Object.fromEntries(index.split(/^## /m).slice(1).map((s) => [s.split('\n')[0].trim(), s]));
|
|
9
|
+
const mdIn = (rel) => fs.ls(rel).filter((f) => f.endsWith('.md'));
|
|
10
|
+
const heading = (rel) => { if (!rel || !fs.has(rel) || fs.isDir(rel)) return null; const m = fs.read(rel).match(/^#\s+(.+)$/m); return m ? m[1].trim() : null; };
|
|
11
|
+
// 장부 행 파싱: 현재 실행 장부·실행 대기·완료 작업
|
|
12
|
+
const rows = (s) => (s || '').split('\n').filter((l) => l.startsWith('|') && !/^\|[-\s|]+\|$/.test(l)).slice(1).map((l) => l.split('|').slice(1, -1).map((x) => x.trim()));
|
|
13
|
+
for (const [i, r] of rows(sections['현재 실행 장부']).entries()) g.add('ledger', `running-${i}`, r[0], { state: 'running', owner: r[1], scope: r[2], done: r[3] }, { file: c.index, line: null, rule: 'tasks:index 현재 실행 장부' });
|
|
14
|
+
for (const [i, r] of rows(sections['실행 대기·중단']).entries()) g.add('ledger', `waiting-${i}`, r[0], { state: /^완료/.test(r[1]) ? 'done' : 'waiting', status: r[1], resume: r[2] }, { file: c.index, line: null, rule: 'tasks:index 실행 대기·중단' });
|
|
15
|
+
|
|
16
|
+
for (const name of dirs) {
|
|
17
|
+
const base = `${c.dir}/${name}`;
|
|
18
|
+
const spec = { initial: fs.has(`${base}/spec/initial.md`), review: fs.has(`${base}/spec/review-log.md`), final: fs.has(`${base}/spec/final.md`) };
|
|
19
|
+
const plan = fs.has(`${base}/task_plan.md`) ? `${base}/task_plan.md` : fs.has(`${base}/plan.md`) ? `${base}/plan.md` : null;
|
|
20
|
+
const phases = mdIn(`${base}/phase`).length;
|
|
21
|
+
const execution = mdIn(`${base}/execution`).length;
|
|
22
|
+
const verification = ['verification.md', 'phase-verification.md'].filter((f) => fs.has(`${base}/${f}`)).map((f) => `${base}/${f}`);
|
|
23
|
+
const stage = verification.length ? '검증' : execution ? '실행' : plan && phases ? 'phase 계획' : plan ? '계획' : spec.final ? '스펙 확정' : spec.initial ? '스펙 초안' : '조사·기록';
|
|
24
|
+
const doneRow = (sections['완료 작업'] || '').split('\n').find((l) => l.includes(`(${name}/`)) || '';
|
|
25
|
+
const mention = (sec) => (sections[sec] || '').includes(`(${name}/`);
|
|
26
|
+
const planText = plan ? fs.read(plan) : '';
|
|
27
|
+
// 폴더 문서 우선: 계획 문서에 폐기·완료 표기가 있으면 그것, 없으면 장부
|
|
28
|
+
const status = /\*\*폐기\*\*/.test(planText.slice(0, 2000)) || /\*\*폐기\*\*|폐기\*\*/.test(doneRow) ? '폐기'
|
|
29
|
+
: mention('현재 실행 장부') ? '진행'
|
|
30
|
+
: doneRow ? '완료'
|
|
31
|
+
: mention('실행 대기·중단') ? '대기'
|
|
32
|
+
: mention('현재 작업') ? '진행' : '기록';
|
|
33
|
+
const title = heading(plan) || heading(`${base}/spec/final.md`) || heading(`${base}/spec/initial.md`) || heading(`${base}/README.md`) || heading(`${base}/${mdIn(base)[0] || ''}`) || name.slice(9);
|
|
34
|
+
let dec = 0, pnDone = 0, pnOpen = 0, oq = 0;
|
|
35
|
+
for (const f of [`${base}/spec/final.md`, plan].filter((f) => f && fs.has(f))) {
|
|
36
|
+
const t = fs.read(f);
|
|
37
|
+
for (const m of t.matchAll(/^- (DEC-\d+)/gm)) { dec += 1; if (!g.get('decision', m[1])) { g.add('decision', m[1], m[1], { kind: 'spec-dec', file: f }, { file: f, line: fs.lineOf(t, m[0]), rule: 'tasks:- DEC-nn' }); g.link('task', name, 'defines', 'decision', m[1]); } }
|
|
38
|
+
for (const m of t.matchAll(/^- \[(x| )\] ((?:PN|P\d)-\d+)/gim)) {
|
|
39
|
+
const done = m[1].toLowerCase() === 'x'; if (done) pnDone += 1; else pnOpen += 1;
|
|
40
|
+
const pid = m[2].toUpperCase();
|
|
41
|
+
if (!g.get('decision', pid)) { g.add('decision', pid, pid, { kind: 'plan-item', done, file: f }, { file: f, line: fs.lineOf(t, m[0]), rule: 'tasks:- [ ] PN-nn' }); g.link('task', name, 'defines', 'decision', pid); }
|
|
42
|
+
}
|
|
43
|
+
oq += (t.match(/^\| OQ-\d+ \|/gm) || []).length;
|
|
44
|
+
}
|
|
45
|
+
const ref = fs.hasGit() ? fs.resolveRef(cfg.git?.branch || 'main') : null;
|
|
46
|
+
const recent = ref ? Number(fs.git('rev-list', '--count', ref, `--since=${cfg.git?.sinceDays || 14} days ago`, '--', base) || 0) : 0;
|
|
47
|
+
const wikiSources = fs.has(cfg.wiki?.sources) ? (fs.read(cfg.wiki?.sources).match(new RegExp(name, 'g')) || []).length : 0;
|
|
48
|
+
g.add('task', name, title, { date: `${name.slice(0, 4)}-${name.slice(4, 6)}-${name.slice(6, 8)}`, slug: name.slice(9), stage, status, spec, plan, phases, execution, verification, dec, pnDone, pnOpen, oq, recentCommits: recent, wikiSources, files: fs.walk(base, (p) => p.endsWith('.md')).length, last: fs.lastCommit(base) }, { file: plan || base, line: 1, rule: 'tasks:folder' });
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// 검사 결과 어댑터: 마지막 검사 실행의 JUnit XML(map/.out/junit.xml)을 읽는다. 기준 커밋이 main HEAD와 같을 때만 "최신 검증"으로 친다.
|
|
2
|
+
// 리포트는 `npm run test:report`가 남긴다. 없으면 등급 A를 줄 수 없을 뿐 생성은 계속된다.
|
|
3
|
+
export default function testreport(g, fs, cfg) {
|
|
4
|
+
const rel = cfg.tests.report;
|
|
5
|
+
if (!fs.has(rel)) return '검사 리포트 없음(npm run test:report 미실행)';
|
|
6
|
+
const xml = fs.read(rel);
|
|
7
|
+
const suites = [...xml.matchAll(/<testsuite\b([^>]*)>/g)].map((m) => Object.fromEntries([...m[1].matchAll(/(\w+)="([^"]*)"/g)].map((a) => [a[1], a[2]])));
|
|
8
|
+
const total = suites.reduce((n, s) => n + Number(s.tests || 0), 0);
|
|
9
|
+
const failures = suites.reduce((n, s) => n + Number(s.failures || 0) + Number(s.errors || 0), 0);
|
|
10
|
+
const skipped = suites.reduce((n, s) => n + Number(s.skipped || 0), 0);
|
|
11
|
+
const metaFile = rel.replace(/\.xml$/, '.json');
|
|
12
|
+
const meta = fs.has(metaFile) ? JSON.parse(fs.read(metaFile)) : {};
|
|
13
|
+
const head = g.get('deploy', 'head')?.props.full || null;
|
|
14
|
+
// suite가 하나도 없는 리포트(러너가 파일을 못 찾은 경우 등)는 통과 근거가 아니다.
|
|
15
|
+
const fresh = total > 0 && !!(meta.sha && head && meta.sha === head);
|
|
16
|
+
g.add('testreport', 'last', '마지막 검사', { total, failures, skipped, sha: meta.sha || null, at: meta.at || null, fresh, files: suites.map((s) => s.name) }, { file: rel, line: 1, rule: 'testreport:junit' });
|
|
17
|
+
// 검사 파일 노드에 통과 여부를 붙인다(파일명 기준 매칭)
|
|
18
|
+
for (const s of suites) {
|
|
19
|
+
const t = g.of('test').find((x) => s.name && (s.name.includes(x.label) || s.name.includes(x.id)));
|
|
20
|
+
if (t) { t.props.lastRun = { passed: Number(s.failures || 0) + Number(s.errors || 0) === 0, tests: Number(s.tests || 0), fresh }; }
|
|
21
|
+
}
|
|
22
|
+
if (total === 0) return '리포트에 검사가 없음(러너 인자·경로 확인)';
|
|
23
|
+
return failures ? `실패 ${failures}건` : null;
|
|
24
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
// 검사 어댑터: tests/ 파일에서 test 노드를 만들고, 파일 안의 goto('/…')·'/api/…' 문자열로 screen·api를 덮는(covers) 엣지를 잇는다.
|
|
3
|
+
export default function tests(g, fs, cfg) {
|
|
4
|
+
const c = cfg.tests;
|
|
5
|
+
const files = fs.walk(c.dir, (p) => /\.(test|spec)\.mjs$/.test(p));
|
|
6
|
+
if (!files.length) return `검사 파일 없음: ${c.dir}`;
|
|
7
|
+
const gate = new RegExp(c.gatePattern);
|
|
8
|
+
// 검사 파일이 helpers를 거쳐 API를 부르는 경우가 많아, 같은 폴더 안 로컬 import를 닫힘으로 합쳐 본다.
|
|
9
|
+
const closure = (file, depth = 2, seen = new Set()) => {
|
|
10
|
+
if (seen.has(file) || depth < 0) return seen;
|
|
11
|
+
seen.add(file);
|
|
12
|
+
for (const m of fs.read(file).matchAll(/from\s+'(\.[^']+)'/g)) {
|
|
13
|
+
const t = join(file, '..', m[1]);
|
|
14
|
+
if (t.startsWith(c.dir + '/') && fs.has(t)) closure(t, depth - 1, seen);
|
|
15
|
+
}
|
|
16
|
+
return seen;
|
|
17
|
+
};
|
|
18
|
+
for (const f of files) {
|
|
19
|
+
const own = fs.read(f);
|
|
20
|
+
const t = [...closure(f)].map(fs.read).join('\n');
|
|
21
|
+
const count = (own.match(/^\s*(?:test|it)\(/gm) || []).length;
|
|
22
|
+
const id = f;
|
|
23
|
+
g.add('test', id, f.replace(`${c.dir}/`, '').replace(/\.(test|spec)\.mjs$/, ''), { kind: f.endsWith('.spec.mjs') ? 'e2e' : 'unit', count, gated: gate.test(own) }, { file: f, line: 1, rule: 'tests:test(|it(' });
|
|
24
|
+
for (const s of g.of('screen')) {
|
|
25
|
+
const goto = s.id.replace(/:\w+/g, '');
|
|
26
|
+
if ((goto.length > 1 && t.includes(`goto('${goto}`)) || (s.id === '/' && t.includes("goto('/')"))) g.link('test', id, 'covers', 'screen', s.id);
|
|
27
|
+
}
|
|
28
|
+
for (const a of g.of('api')) {
|
|
29
|
+
const key = a.id.replace(':id', '');
|
|
30
|
+
if (t.includes(`'${a.id}'`) || (a.id.includes(':id') && new RegExp(`'${key}[^']`).test(t))) g.link('test', id, 'covers', 'api', a.id);
|
|
31
|
+
}
|
|
32
|
+
// DB 함수 직접 호출(rpc('name') 또는 /rest/v1/rpc/name)
|
|
33
|
+
for (const f of g.of('function')) {
|
|
34
|
+
if (new RegExp(`rpc\\(\\s*['\"\`]${f.id}['\"\`]|/rpc/${f.id}\\b`).test(t)) g.link('test', id, 'covers', 'function', f.id);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// 위키 어댑터: .agent/wiki/index.md의 결정 목록에서 decision 노드를 만든다(상태 current/proposed/superseded).
|
|
2
|
+
export default function wiki(g, fs, cfg) {
|
|
3
|
+
const c = cfg.wiki;
|
|
4
|
+
if (!fs.has(c.index)) return `위키 인덱스 없음: ${c.index}`;
|
|
5
|
+
const text = fs.read(c.index);
|
|
6
|
+
let n = 0;
|
|
7
|
+
for (const m of text.matchAll(/^- \[([^\]]+)\]\(<(decisions\/[^>]+)>\) — (\w+) — (.+)$/gm)) {
|
|
8
|
+
const slug = m[2].replace(/^decisions\//, '').replace(/\.md$/, '');
|
|
9
|
+
g.add('decision', slug, m[1], { kind: 'wiki', file: `.agent/wiki/${m[2]}`, status: m[3], summary: m[4] }, { file: c.index, line: fs.lineOf(text, m[0]), rule: 'wiki:index decisions' });
|
|
10
|
+
n += 1;
|
|
11
|
+
}
|
|
12
|
+
return n === 0 ? '결정 0건' : null;
|
|
13
|
+
}
|
package/src/check.mjs
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// 검증: 생성물 바이트 비교가 아니라 뜻(여정)과 사실(코드)의 정합을 본다.
|
|
2
|
+
// error → CI 실패. warn → 화면에 뜨는 경고와 같은 것들.
|
|
3
|
+
export function check(d, cfg) {
|
|
4
|
+
const out = [];
|
|
5
|
+
const err = (msg) => out.push({ level: 'error', msg });
|
|
6
|
+
const warn = (msg) => out.push({ level: 'warn', msg });
|
|
7
|
+
|
|
8
|
+
for (const a of d.adapters) if (a.status === 'failed') err(`어댑터 실패 ${a.name}: ${a.error}`);
|
|
9
|
+
const counts = { screen: d.summary.routes, api: d.summary.apis, function: d.summary.dbFunctions, test: d.tests.length, task: d.tasks.length, decision: d.decisions.length };
|
|
10
|
+
for (const [k, floor] of Object.entries(cfg.floors || {})) if ((counts[k] ?? 0) < floor) err(`바닥값 미달 ${k}: ${counts[k] ?? 0} < ${floor} (스캐너가 깨졌을 가능성)`);
|
|
11
|
+
|
|
12
|
+
const ids = new Set();
|
|
13
|
+
for (const j of d.semantic.journeys) {
|
|
14
|
+
if (ids.has(j.id)) err(`여정 id 중복: ${j.id}`); ids.add(j.id);
|
|
15
|
+
if (!j.steps?.length) err(`여정에 장면 없음: ${j.id}`);
|
|
16
|
+
const stepIds = new Set();
|
|
17
|
+
for (const s of j.steps) {
|
|
18
|
+
if (stepIds.has(s.id)) err(`${j.title}: 장면 id 중복 ${s.id}`); stepIds.add(s.id);
|
|
19
|
+
if (!s.intent && s.status !== 'next') warn(`${j.title} › ${s.label}: intent 비어 있음`);
|
|
20
|
+
for (const w of s.warnings) (/라우트 없음|참조 미해결|장면은 동작인데|관측 근거 없음/.test(w) ? err : warn)(`${j.title} › ${s.label}: ${w}`);
|
|
21
|
+
if (!['live', 'mock', 'planned', 'next'].includes(s.status)) err(`${j.title} › ${s.label}: 알 수 없는 상태 ${s.status}`);
|
|
22
|
+
if (s.status === 'planned' && (s.screens || []).length) warn(`${j.title} › ${s.label}: planned 인데 화면이 있음(mock 이 맞는지 확인)`);
|
|
23
|
+
if (s.capture && !s.captureFile) warn(`${j.title} › ${s.label}: 캡처 파일 없음 ${s.capture}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
// 로드맵: 가리키는 장면·작업·선행 항목이 없거나 상태 어휘가 틀리면 오류. 진행 중인데 작업 폴더가 없으면 경고.
|
|
27
|
+
const mids = new Set();
|
|
28
|
+
for (const m of d.roadmap || []) {
|
|
29
|
+
if (mids.has(m.id)) err(`로드맵 id 중복: ${m.id}`); mids.add(m.id);
|
|
30
|
+
for (const p of m.problems) err(`로드맵 ${m.title}: ${p}`);
|
|
31
|
+
if (m.status === '진행' && !m.tasks.length) warn(`로드맵 ${m.title}: 진행인데 작업 폴더가 없음`);
|
|
32
|
+
}
|
|
33
|
+
if (d.orphans.screens.length) warn(`여정에 없는 화면 ${d.orphans.screens.length}: ${d.orphans.screens.join(', ')}`);
|
|
34
|
+
if (d.orphans.apis.length) warn(`어느 화면도 부르지 않는 API ${d.orphans.apis.length}: ${d.orphans.apis.join(', ')}`);
|
|
35
|
+
if (d.orphans.tests.length) warn(`라우트·API에 붙지 않는 검사 ${d.orphans.tests.length}: ${d.orphans.tests.join(', ')}`);
|
|
36
|
+
if (d.deploy && d.deploy.behind === null) warn('배포 sha가 main 이력에 없어 뒤처짐을 계산하지 못함');
|
|
37
|
+
return out;
|
|
38
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// 프로젝트 상황판 CLI. 의존성 없음(Node 22). 명령 진입은 bin/livemap.mjs가 main(argv)를 부른다.
|
|
2
|
+
// livemap build [--root .] [--out map/.out] 저장소 스캔 → graph.json·data.json·overview.json
|
|
3
|
+
// livemap check [--root .] 검증(바닥값·라우트 존재·상태 모순·참조 미해결·어댑터 실패) → exit 1이면 실패
|
|
4
|
+
// livemap serve [--port 4180] [--static <dir>] loopback 서빙. 기본은 요청마다 재빌드(5초 캐시), --static은 export 폴더를 그대로 준다
|
|
5
|
+
// livemap export <dir> [--out map/.out] 화면·서체·캡처·생성물을 /map/ 주소 배치 그대로 한 폴더에 모은다
|
|
6
|
+
// livemap init 없는 파일만 템플릿으로 만들고 .gitignore·npm 스크립트를 넣는다
|
|
7
|
+
// livemap test-report 단위 검사를 JUnit으로 남긴다(config.tests.dir → config.tests.report)
|
|
8
|
+
// livemap --version
|
|
9
|
+
import { mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
|
|
10
|
+
import { resolve, dirname, join } from 'node:path';
|
|
11
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
12
|
+
import { Graph, runAdapter } from './lib/graph.mjs';
|
|
13
|
+
import { makeFs } from './lib/util.mjs';
|
|
14
|
+
import { derive, overviewSlice } from './derive.mjs';
|
|
15
|
+
import { check } from './check.mjs';
|
|
16
|
+
|
|
17
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
export const PKG_ROOT = resolve(here, '..');
|
|
19
|
+
export const VERSION = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8')).version;
|
|
20
|
+
export const ENGINE_MAJOR = Number(VERSION.split('.')[0]);
|
|
21
|
+
export const CONFIG = 'map/config.json';
|
|
22
|
+
export const MIGRATE_DOC = 'node_modules/@pghoya2956/livemap/docs/migrate.md';
|
|
23
|
+
|
|
24
|
+
// 기본 어댑터 순서. 순서가 의미 있다: tests·git은 screen·api 노드가 있어야 엣지를 잇고, testreport는 git이 만든 head를 본다.
|
|
25
|
+
// config.json의 "adapters" 배열로 바꾼다(프로젝트마다 어댑터 파일을 map/adapters/<name>.mjs 에 둔다).
|
|
26
|
+
const DEFAULT_ADAPTERS = ['router', 'bff', 'migrations', 'tests', 'wiki', 'tasks', 'git', 'deploy', 'testreport'];
|
|
27
|
+
const adapterCache = new Map();
|
|
28
|
+
// 어댑터는 프로젝트(map/adapters/<name>.mjs)가 우선이고, 없으면 엔진에 딸린 참조 어댑터(src/adapters/)를 쓴다.
|
|
29
|
+
async function loadAdapter(root, name) {
|
|
30
|
+
const key = `${root}:${name}`;
|
|
31
|
+
if (!adapterCache.has(key)) {
|
|
32
|
+
const project = resolve(root, 'map/adapters', name + '.mjs');
|
|
33
|
+
const reference = resolve(here, 'adapters', name + '.mjs');
|
|
34
|
+
const file = [project, reference].find((f) => existsSync(f));
|
|
35
|
+
if (!file) throw new Error(`어댑터 파일 없음: ${project} | ${reference}`);
|
|
36
|
+
const shadowed = file === project && project !== reference && existsSync(reference);
|
|
37
|
+
adapterCache.set(key, import(pathToFileURL(file).href).then((m) => ({ fn: m.default, shadowed })));
|
|
38
|
+
}
|
|
39
|
+
return adapterCache.get(key);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function buildGraph(root = process.cwd()) {
|
|
43
|
+
const fs = makeFs(root);
|
|
44
|
+
const cfg = JSON.parse(fs.read(CONFIG));
|
|
45
|
+
const g = new Graph();
|
|
46
|
+
const shadowed = [];
|
|
47
|
+
for (const name of cfg.adapters || DEFAULT_ADAPTERS) {
|
|
48
|
+
let loaded;
|
|
49
|
+
try { loaded = await loadAdapter(root, name); } catch (e) { g.report(name, 'failed', 0, String(e.message)); continue; }
|
|
50
|
+
if (loaded.shadowed) shadowed.push(name);
|
|
51
|
+
runAdapter(g, name, (g) => loaded.fn(g, fs, cfg));
|
|
52
|
+
}
|
|
53
|
+
const sem = fs.has(cfg.semantic) ? JSON.parse(fs.read(cfg.semantic)) : { journeys: [] };
|
|
54
|
+
const captureExists = (id) => (id && fs.has(`${capturesDir(cfg)}/${id}.jpg`) ? `${id}.jpg` : null);
|
|
55
|
+
const data = derive(g, sem, cfg, { captureExists });
|
|
56
|
+
return { g, cfg, sem, data, fs, shadowed };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function build(root = process.cwd(), out = resolve(root, 'map/.out')) {
|
|
60
|
+
const r = await buildGraph(root);
|
|
61
|
+
mkdirSync(out, { recursive: true });
|
|
62
|
+
writeFileSync(join(out, 'graph.json'), JSON.stringify(r.g.toJSON()));
|
|
63
|
+
writeFileSync(join(out, 'data.json'), JSON.stringify(r.data));
|
|
64
|
+
writeFileSync(join(out, 'overview.json'), JSON.stringify(overviewSlice(r.data)));
|
|
65
|
+
return r;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const capturesDir = (cfg) => cfg?.captures?.site || 'map/captures';
|
|
69
|
+
|
|
70
|
+
// config.json의 engine(major)이 이 엔진과 다르면 멈춘다. 키가 없으면 1로 본다.
|
|
71
|
+
export function engineMismatch(cfg) {
|
|
72
|
+
const want = cfg?.engine ?? 1;
|
|
73
|
+
if (Number(want) === ENGINE_MAJOR) return null;
|
|
74
|
+
return `${CONFIG}의 engine ${want}이 이 엔진(${VERSION}, major ${ENGINE_MAJOR})과 다릅니다. 이행 방법: ${MIGRATE_DOC}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const USAGE = `usage: livemap <command>
|
|
78
|
+
build [--root .] [--out map/.out] 저장소 스캔 → graph.json · data.json · overview.json
|
|
79
|
+
check [--root .] 정합 검사, 오류가 있으면 exit 1
|
|
80
|
+
serve [--port 4180] [--static <dir>] 로컬 뷰 http://127.0.0.1:<port>/map/
|
|
81
|
+
export <dir> [--out map/.out] 화면·서체·캡처·생성물을 한 폴더에(먼저 build)
|
|
82
|
+
init map/ 초안 파일·.gitignore·npm 스크립트
|
|
83
|
+
test-report 단위 검사를 JUnit 리포트로
|
|
84
|
+
--version 엔진 버전`;
|
|
85
|
+
|
|
86
|
+
function readConfig(root) {
|
|
87
|
+
const file = resolve(root, CONFIG);
|
|
88
|
+
if (!existsSync(file)) return { error: `${CONFIG} 없음: 프로젝트 루트에서 실행하거나 먼저 livemap init` };
|
|
89
|
+
try { return { cfg: JSON.parse(readFileSync(file, 'utf8')) }; }
|
|
90
|
+
catch (e) { return { error: `${CONFIG} 읽기 실패: ${e.message}` }; }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 명령을 실행하고 종료 코드를 돌려준다. serve는 서버를 띄우고 undefined를 돌려준다(프로세스가 계속 산다).
|
|
94
|
+
export async function main(argv = []) {
|
|
95
|
+
const cmd = argv[0] || 'build';
|
|
96
|
+
const opt = (k, d) => { const i = argv.indexOf(`--${k}`); return i >= 0 ? argv[i + 1] : d; };
|
|
97
|
+
if (cmd === '--version' || cmd === '-v' || cmd === 'version') { console.log(VERSION); return 0; }
|
|
98
|
+
if (cmd === 'help' || cmd === '--help' || cmd === '-h') { console.log(USAGE); return 0; }
|
|
99
|
+
if (cmd === 'init') { const { init } = await import('./init.mjs'); return init({ root: process.cwd(), pkgRoot: PKG_ROOT }); }
|
|
100
|
+
|
|
101
|
+
const root = resolve(opt('root', process.cwd()));
|
|
102
|
+
const out = resolve(root, opt('out', 'map/.out'));
|
|
103
|
+
if (!['build', 'check', 'serve', 'export', 'test-report'].includes(cmd)) { console.error(USAGE); return 2; }
|
|
104
|
+
|
|
105
|
+
const staticDir = cmd === 'serve' ? opt('static') : undefined;
|
|
106
|
+
let cfg = null;
|
|
107
|
+
if (!staticDir) {
|
|
108
|
+
const r = readConfig(root);
|
|
109
|
+
if (r.error) { console.error(r.error); return 2; }
|
|
110
|
+
cfg = r.cfg;
|
|
111
|
+
const mismatch = engineMismatch(cfg);
|
|
112
|
+
if (mismatch) { console.error(mismatch); return 2; }
|
|
113
|
+
}
|
|
114
|
+
const notifyShadow = (names) => { for (const n of names) console.log(`프로젝트 어댑터가 참조 어댑터를 가림: ${n}`); };
|
|
115
|
+
|
|
116
|
+
if (cmd === 'build') {
|
|
117
|
+
const { data: d, shadowed } = await build(root, out);
|
|
118
|
+
notifyShadow(shadowed);
|
|
119
|
+
const bad = d.adapters.filter((a) => a.status !== 'ok');
|
|
120
|
+
console.log(`map build → ${out}: 화면 ${d.summary.routes} · API ${d.summary.apis} · 함수 ${d.summary.dbFunctions} · 작업 ${d.tasks.length} · 커밋 ${d.summary.commits} · 경고 ${d.summary.warnings} · 고아 ${d.summary.orphans}`);
|
|
121
|
+
for (const a of bad) console.log(` ${a.status === 'failed' ? '✗' : '△'} ${a.name}: ${a.error}`);
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
124
|
+
if (cmd === 'check') {
|
|
125
|
+
const { data, cfg: c, shadowed } = await buildGraph(root);
|
|
126
|
+
notifyShadow(shadowed);
|
|
127
|
+
const problems = check(data, c);
|
|
128
|
+
for (const p of problems) console.log(`${p.level === 'error' ? '✗' : '△'} ${p.msg}`);
|
|
129
|
+
const errors = problems.filter((p) => p.level === 'error').length;
|
|
130
|
+
console.log(errors ? `map check: 오류 ${errors}` : `map check: 통과 (경고 ${problems.length})`);
|
|
131
|
+
return errors ? 1 : 0;
|
|
132
|
+
}
|
|
133
|
+
if (cmd === 'serve') {
|
|
134
|
+
const { serve } = await import('./serve.mjs');
|
|
135
|
+
const port = Number(opt('port', 4180));
|
|
136
|
+
if (staticDir) {
|
|
137
|
+
const dir = resolve(process.cwd(), staticDir);
|
|
138
|
+
if (!existsSync(join(dir, 'index.html'))) { console.error(`export 폴더가 아님(index.html 없음): ${dir}`); return 2; }
|
|
139
|
+
serve({ port, static: dir });
|
|
140
|
+
} else {
|
|
141
|
+
serve({ root, out, port, captures: resolve(root, capturesDir(cfg)), build: () => build(root, out) });
|
|
142
|
+
}
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
if (cmd === 'export') {
|
|
146
|
+
const target = argv[1] && !argv[1].startsWith('--') ? argv[1] : null;
|
|
147
|
+
if (!target) { console.error('usage: livemap export <dir> [--out map/.out]'); return 2; }
|
|
148
|
+
const { exportSite } = await import('./serve.mjs');
|
|
149
|
+
return exportSite({ root, out, captures: resolve(root, capturesDir(cfg)), target: resolve(process.cwd(), target) });
|
|
150
|
+
}
|
|
151
|
+
if (cmd === 'test-report') {
|
|
152
|
+
const { testReport } = await import('./test-report.mjs');
|
|
153
|
+
return testReport({ root, cfg });
|
|
154
|
+
}
|
|
155
|
+
return 2;
|
|
156
|
+
}
|