@pghoya2956/livemap 1.0.1 → 1.1.1
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 +55 -0
- package/budget/view-budget.spec.mjs +75 -23
- package/docs/adapter-contract.md +23 -4
- package/docs/hosting-and-csp.md +26 -2
- package/docs/migrate.md +6 -1
- package/docs/semantic-authoring.md +115 -2
- package/docs/semantic-schema.md +21 -5
- package/docs/view-budget.md +38 -16
- package/package.json +8 -3
- package/site/index.html +3 -15
- package/site/map.css +439 -155
- package/site/map.js +61 -199
- package/src/adapters/roadmap.mjs +89 -19
- package/src/adapters/tasks.mjs +11 -4
- package/src/check.mjs +18 -0
- package/src/cli.mjs +1 -1
- package/src/derive.mjs +163 -4
- package/src/lib/graph.mjs +13 -3
- package/templates/README.md +2 -2
package/src/adapters/roadmap.mjs
CHANGED
|
@@ -1,33 +1,103 @@
|
|
|
1
|
-
// 로드맵 어댑터: tasks/roadmap.md 의 `## 제목` 절마다 milestone
|
|
2
|
-
//
|
|
3
|
-
|
|
1
|
+
// 로드맵 어댑터: tasks/roadmap.md 의 `## 제목` 절마다 milestone 노드(로드맵 항목)를 만든다. 절의 첫 문단은 목표, `- 키: 값` 목록은 속성이다.
|
|
2
|
+
// `## 마일스톤: 제목` 절은 release 노드(마일스톤, 1.x 임시 이름)이고, 항목의 `마일스톤` 키가 release → milestone contains 엣지가 된다.
|
|
3
|
+
// 순서는 파일 안 위치다. 항목 order·자동 id는 항목 절만 센다. 장면·작업·선행은 문자열로만 남기고 해석(존재 확인)은 derive·check가 한다.
|
|
4
|
+
// 완료일(completedAt)·결정 대기 시작일(waitingSince)은 로드맵 파일의 git 이력에서 계산한다.
|
|
5
|
+
const KEYS = { id: 'id', 상태: 'status', '진행 방식': 'mode', 작업: 'tasks', 장면: 'scenes', 선행: 'deps', '결정 대기': 'waitingOn', '완료 기준': 'done', 마일스톤: 'milestone' };
|
|
6
|
+
const RELEASE_KEYS = { id: 'id', 상태: 'status', 완료일: 'completedOn', 목표일: 'targetOn', '결정 대기': 'waitingOn' };
|
|
4
7
|
const LISTS = new Set(['tasks', 'scenes', 'deps']);
|
|
8
|
+
const RELEASE_HEAD = /^마일스톤\s*[::]\s*(.*)$/;
|
|
9
|
+
const SHALLOW = '얕은 클론: 완료일·결정 대기 시작일 생략';
|
|
5
10
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
if (!fs.has(file)) return `로드맵 파일 없음: ${file}`;
|
|
10
|
-
const text = fs.read(file);
|
|
11
|
-
let n = 0;
|
|
11
|
+
// 로드맵 본문 → { items, releases }. 이력의 옛 판도 같은 규칙으로 읽는다.
|
|
12
|
+
function parse(text) {
|
|
13
|
+
const items = [], releases = [];
|
|
12
14
|
for (const block of text.split(/^## /m).slice(1)) {
|
|
13
15
|
const [head, ...lines] = block.split('\n');
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
+
const rel = head.trim().match(RELEASE_HEAD);
|
|
17
|
+
const keys = rel ? RELEASE_KEYS : KEYS;
|
|
18
|
+
const props = rel
|
|
19
|
+
? { order: releases.length + 1, goal: '', status: '', completedOn: '', targetOn: '', waitingOn: '' }
|
|
20
|
+
: { order: items.length + 1, goal: '', status: '', mode: '', tasks: [], scenes: [], deps: [], waitingOn: '', done: '', milestone: null };
|
|
16
21
|
const goal = [];
|
|
17
22
|
for (const line of lines) {
|
|
18
23
|
const m = line.match(/^- ([^:]+):\s*(.*)$/);
|
|
19
|
-
if (m &&
|
|
20
|
-
const key =
|
|
24
|
+
if (m && keys[m[1].trim()]) {
|
|
25
|
+
const key = keys[m[1].trim()];
|
|
21
26
|
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;
|
|
27
|
+
props[key] = LISTS.has(key) ? value.split(/[,,]\s*/).map((x) => x.trim()).filter((x) => x && x !== '—') : key === 'milestone' ? value || null : value;
|
|
23
28
|
} else if (!line.startsWith('- ') && line.trim()) goal.push(line.trim());
|
|
24
29
|
}
|
|
25
30
|
props.goal = goal.join(' ');
|
|
26
|
-
const
|
|
31
|
+
const declared = props.id;
|
|
27
32
|
delete props.id;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
33
|
+
if (rel) releases.push({ id: declared || `r${props.order}`, idMissing: !declared, title: rel[1].trim(), head, props });
|
|
34
|
+
else items.push({ id: declared || `m${props.order}`, title: head.trim(), head, props });
|
|
35
|
+
}
|
|
36
|
+
return { items, releases };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 이력 날짜: 마지막 완료 전이 커밋, 결정 대기 연속 구간의 시작 커밋(작성 시각, UTC ISO). 키는 'item:<id>'·'release:<id>'.
|
|
40
|
+
function historyDates(fs, cfg, file) {
|
|
41
|
+
if (!fs.hasGit()) return { dates: null };
|
|
42
|
+
if (fs.git('rev-parse', '--is-shallow-repository') === 'true') return { dates: null, note: SHALLOW };
|
|
43
|
+
const ref = fs.resolveRef(cfg.git?.branch || 'main');
|
|
44
|
+
if (!ref) return { dates: null };
|
|
45
|
+
// --reverse와 --follow를 함께 주면 git이 이름 바꾸기를 따라가지 않는다(git 2.53 실측). 최신순으로 받아 뒤집는다.
|
|
46
|
+
const log = fs.git('log', '--follow', '--name-only', '--format=%H|%ad', '--date=iso-strict', ref, '--', file);
|
|
47
|
+
const completedAt = new Map(), waitingSince = new Map();
|
|
48
|
+
let prev = new Map();
|
|
49
|
+
let commit = null;
|
|
50
|
+
const apply = (sha, date, path) => {
|
|
51
|
+
const { items, releases } = parse(fs.git('show', `${sha}:${path}`));
|
|
52
|
+
const iso = new Date(date).toISOString();
|
|
53
|
+
const now = new Map([...items.map((x) => [`item:${x.id}`, x.props]), ...releases.map((x) => [`release:${x.id}`, x.props])]);
|
|
54
|
+
for (const [k, p] of now) {
|
|
55
|
+
const before = prev.get(k);
|
|
56
|
+
if (k.startsWith('item:')) {
|
|
57
|
+
if (p.status !== '완료') completedAt.delete(k);
|
|
58
|
+
else if (!before || before.status !== '완료') completedAt.set(k, iso);
|
|
59
|
+
}
|
|
60
|
+
if (!p.waitingOn) waitingSince.delete(k);
|
|
61
|
+
else if (!before || !before.waitingOn) waitingSince.set(k, iso);
|
|
62
|
+
}
|
|
63
|
+
for (const k of [...completedAt.keys()]) if (!now.has(k)) completedAt.delete(k);
|
|
64
|
+
for (const k of [...waitingSince.keys()]) if (!now.has(k)) waitingSince.delete(k);
|
|
65
|
+
prev = now;
|
|
66
|
+
};
|
|
67
|
+
const commits = [];
|
|
68
|
+
for (const line of log.split('\n')) {
|
|
69
|
+
const h = line.match(/^([0-9a-f]{40})\|(.+)$/);
|
|
70
|
+
if (h) { commit = { sha: h[1], date: h[2], path: null }; commits.push(commit); continue; }
|
|
71
|
+
if (commit && !commit.path && line.trim()) commit.path = line.trim();
|
|
72
|
+
}
|
|
73
|
+
for (const c of commits.reverse()) if (c.path) apply(c.sha, c.date, c.path);
|
|
74
|
+
return { dates: { completedAt, waitingSince } };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export default function roadmap(g, fs, cfg) {
|
|
78
|
+
const file = cfg.roadmap?.file;
|
|
79
|
+
if (!file) return null;
|
|
80
|
+
if (!fs.has(file)) return `로드맵 파일 없음: ${file}`;
|
|
81
|
+
const text = fs.read(file);
|
|
82
|
+
const { items, releases } = parse(text);
|
|
83
|
+
const hist = items.length || releases.length ? historyDates(fs, cfg, file) : { dates: null };
|
|
84
|
+
const d = hist.dates;
|
|
85
|
+
const rule = 'roadmap:## 제목 + - 키: 값';
|
|
86
|
+
const seen = new Map();
|
|
87
|
+
for (const r of releases) {
|
|
88
|
+
if (seen.has(r.id)) { seen.get(r.id).props.duplicates += 1; continue; }
|
|
89
|
+
const waitingSince = r.props.waitingOn && d?.waitingSince.get(`release:${r.id}`) || null;
|
|
90
|
+
const node = g.add('release', r.id, r.title, { ...r.props, idMissing: r.idMissing, duplicates: 0, waitingSince }, { file, line: fs.lineOf(text, `## ${r.head}`), rule: 'roadmap:## 마일스톤: 제목 + - 키: 값' });
|
|
91
|
+
seen.set(r.id, node);
|
|
92
|
+
}
|
|
93
|
+
for (const it of items) {
|
|
94
|
+
const p = it.props;
|
|
95
|
+
p.completedAt = p.status === '완료' && d?.completedAt.get(`item:${it.id}`) || null;
|
|
96
|
+
p.waitingSince = p.waitingOn && d?.waitingSince.get(`item:${it.id}`) || null;
|
|
97
|
+
g.add('milestone', it.id, it.title, p, { file, line: fs.lineOf(text, `## ${it.head}`), rule });
|
|
98
|
+
for (const t of p.tasks) g.link('milestone', it.id, 'tracks', 'task', t);
|
|
99
|
+
if (p.milestone && seen.has(p.milestone)) g.link('release', p.milestone, 'contains', 'milestone', it.id);
|
|
31
100
|
}
|
|
32
|
-
|
|
101
|
+
if (items.length === 0) return '로드맵 항목 0건';
|
|
102
|
+
return hist.note || null;
|
|
33
103
|
}
|
package/src/adapters/tasks.mjs
CHANGED
|
@@ -31,16 +31,23 @@ export default function tasks(g, fs, cfg) {
|
|
|
31
31
|
: mention('실행 대기·중단') ? '대기'
|
|
32
32
|
: mention('현재 작업') ? '진행' : '기록';
|
|
33
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
|
+
// 수는 파일 하나에서만 센다: 계획 항목은 체크하는 계획 문서, 결정·열린 질문은 spec/final.md.
|
|
35
|
+
// 스펙에 적은 계획 초안 체크박스는 실행 중 체크되지 않아 더하면 분모만 부푼다. 노드는 두 문서 모두에서 만들되 세는 문서를 먼저 읽는다.
|
|
36
|
+
const finalSpec = `${base}/spec/final.md`;
|
|
34
37
|
let dec = 0, pnDone = 0, pnOpen = 0, oq = 0;
|
|
35
|
-
for (const f of [
|
|
38
|
+
for (const f of [finalSpec, plan].filter((f) => f && fs.has(f))) {
|
|
39
|
+
const t = fs.read(f);
|
|
40
|
+
for (const m of t.matchAll(/^- (DEC-\d+)/gm)) { if (f === finalSpec) 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]); } }
|
|
41
|
+
if (f === finalSpec) oq += (t.match(/^\| OQ-\d+ \|/gm) || []).length;
|
|
42
|
+
}
|
|
43
|
+
for (const f of [plan, finalSpec].filter((f) => f && fs.has(f))) {
|
|
36
44
|
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
45
|
for (const m of t.matchAll(/^- \[(x| )\] ((?:PN|P\d)-\d+)/gim)) {
|
|
39
|
-
const done = m[1].toLowerCase() === 'x';
|
|
46
|
+
const done = m[1].toLowerCase() === 'x';
|
|
47
|
+
if (f === plan) { if (done) pnDone += 1; else pnOpen += 1; }
|
|
40
48
|
const pid = m[2].toUpperCase();
|
|
41
49
|
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
50
|
}
|
|
43
|
-
oq += (t.match(/^\| OQ-\d+ \|/gm) || []).length;
|
|
44
51
|
}
|
|
45
52
|
const ref = fs.hasGit() ? fs.resolveRef(cfg.git?.branch || 'main') : null;
|
|
46
53
|
const recent = ref ? Number(fs.git('rev-list', '--count', ref, `--since=${cfg.git?.sinceDays || 14} days ago`, '--', base) || 0) : 0;
|
package/src/check.mjs
CHANGED
|
@@ -30,9 +30,27 @@ export function check(d, cfg) {
|
|
|
30
30
|
for (const p of m.problems) err(`로드맵 ${m.title}: ${p}`);
|
|
31
31
|
if (m.status === '진행' && !m.tasks.length) warn(`로드맵 ${m.title}: 진행인데 작업 폴더가 없음`);
|
|
32
32
|
}
|
|
33
|
+
// 마일스톤: 문장은 derive가 만든다. 마일스톤 절이 없으면 줄이 없다
|
|
34
|
+
for (const m of d.milestones || []) { for (const p of m.problems) err(p); for (const w of m.warnings) warn(w); }
|
|
35
|
+
const running = (d.milestones || []).filter((m) => m.status === '진행');
|
|
36
|
+
if (running.length > 1) warn(`진행 마일스톤 ${running.length}개: ${running.map((m) => m.title).join(', ')}`);
|
|
37
|
+
for (const m of d.roadmap || []) {
|
|
38
|
+
if (m.status !== '진행') continue;
|
|
39
|
+
if ((d.milestones || []).length && !m.milestone) warn(`로드맵 ${m.title}: 진행인데 마일스톤 없음`);
|
|
40
|
+
const open = (m.deps || []).filter((x) => x.status !== '완료');
|
|
41
|
+
if (open.length) warn(`로드맵 ${m.title}: 진행인데 선행 미완 ${open.map((x) => x.title).join(', ')}`);
|
|
42
|
+
}
|
|
43
|
+
// 배우 사전: 여정 배우, 여정과 다른 단계 배우가 사전 키에 없으면 경고(사전이 없는 프로젝트는 건너뜀)
|
|
44
|
+
const actors = d.semantic.actors || {};
|
|
45
|
+
if (Object.keys(actors).length) for (const j of d.semantic.journeys) {
|
|
46
|
+
if (j.actor && !(j.actor in actors)) warn(`${j.title}: 배우 사전에 없는 값 ${j.actor}`);
|
|
47
|
+
for (const s of j.steps) if (s.actor && s.actor !== j.actor && !(s.actor in actors)) warn(`${j.title} › ${s.label}: 배우 사전에 없는 값 ${s.actor}`);
|
|
48
|
+
}
|
|
33
49
|
if (d.orphans.screens.length) warn(`여정에 없는 화면 ${d.orphans.screens.length}: ${d.orphans.screens.join(', ')}`);
|
|
34
50
|
if (d.orphans.apis.length) warn(`어느 화면도 부르지 않는 API ${d.orphans.apis.length}: ${d.orphans.apis.join(', ')}`);
|
|
35
51
|
if (d.orphans.tests.length) warn(`라우트·API에 붙지 않는 검사 ${d.orphans.tests.length}: ${d.orphans.tests.join(', ')}`);
|
|
36
52
|
if (d.deploy && d.deploy.behind === null) warn('배포 sha가 main 이력에 없어 뒤처짐을 계산하지 못함');
|
|
53
|
+
// 어댑터가 g.issue로 낸 문제: 기존 검사 뒤에 그대로 싣는다. error는 종료 코드 1에 센다.
|
|
54
|
+
for (const i of d.issues || []) (i.level === 'error' ? err : warn)(`${i.label}: ${i.message}`);
|
|
37
55
|
return out;
|
|
38
56
|
}
|
package/src/cli.mjs
CHANGED
|
@@ -61,7 +61,7 @@ export async function build(root = process.cwd(), out = resolve(root, 'map/.out'
|
|
|
61
61
|
mkdirSync(out, { recursive: true });
|
|
62
62
|
writeFileSync(join(out, 'graph.json'), JSON.stringify(r.g.toJSON()));
|
|
63
63
|
writeFileSync(join(out, 'data.json'), JSON.stringify(r.data));
|
|
64
|
-
writeFileSync(join(out, 'overview.json'), JSON.stringify(overviewSlice(r.data)));
|
|
64
|
+
writeFileSync(join(out, 'overview.json'), JSON.stringify(overviewSlice(r.data, { sinceDays: r.cfg.git?.sinceDays })));
|
|
65
65
|
return r;
|
|
66
66
|
}
|
|
67
67
|
|
package/src/derive.mjs
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
// 파생: 그래프(코드 사실) + 여정 파일(뜻)을 합쳐 화면이 읽는 뷰 모델을 만든다. 화면은 여기서 만든 것만 그린다.
|
|
2
2
|
const ROADMAP_STATUS = ['완료', '진행', '다음', '대기', '이후'];
|
|
3
3
|
const RANK = { live: 0, partial: 1, mixed: 1, mock: 2, planned: 3, next: 4, static: 5 };
|
|
4
|
+
const DAY = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
5
|
+
// YYYY-MM-DD 이고 달력에 있는 날짜인가
|
|
6
|
+
export const isDay = (s) => { const m = String(s ?? '').match(DAY); if (!m) return false; const t = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3])); return t.getUTCFullYear() === +m[1] && t.getUTCMonth() === +m[2] - 1 && t.getUTCDate() === +m[3]; };
|
|
7
|
+
|
|
8
|
+
// 문구 정리: 링크 글자만, 강조·백틱 제거, 공백 정리, n자(코드 포인트) 넘으면 말줄임
|
|
9
|
+
export function plain(text, n) {
|
|
10
|
+
const s = String(text ?? '').replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/\*\*|`/g, '').replace(/\s+/g, ' ').trim();
|
|
11
|
+
const cp = [...s];
|
|
12
|
+
return n && cp.length > n ? cp.slice(0, n - 1).join('') + '…' : s;
|
|
13
|
+
}
|
|
14
|
+
// 변경 종류: 커밋 제목의 관례 접두어(`feat(범위)!:`)만 본다. 프로젝트의 영역 이름을 가정하지 않는다
|
|
15
|
+
const KINDS = { feat: '기능', fix: '수정', docs: '문서', test: '검사', refactor: '정리', perf: '정리', style: '정리', build: '운영', ci: '운영', chore: '운영', deploy: '운영', release: '운영' };
|
|
16
|
+
const PREFIX = /^([a-z]+)(\([^)]*\))?!?:\s*/i;
|
|
17
|
+
export function changeKind(subject) {
|
|
18
|
+
const m = String(subject ?? '').match(PREFIX);
|
|
19
|
+
return (m && KINDS[m[1].toLowerCase()]) || '변경';
|
|
20
|
+
}
|
|
21
|
+
// 커밋 제목 정리: 접두어·[skip ci]·내부 ID·식별자 낱말을 지우고 괄호·끝 기호를 정리한다. 2자 미만이면 "{종류} 변경"
|
|
22
|
+
export function subjectPlain(subject, n) {
|
|
23
|
+
const kind = changeKind(subject);
|
|
24
|
+
let s = plain(String(subject ?? '').replace(PREFIX, '').replace(/\[(skip ci|ci skip)\]/gi, ''));
|
|
25
|
+
s = s.replace(/\b(PN|DEC|OQ|AC|P\d)-\d+(?:\s*[·,~]\s*\d+)*/g, '').replace(/\b(PN|DEC|OQ|AC)\b/g, '').replace(/\bQ\d+\b/g, '');
|
|
26
|
+
s = s.split(' ').filter((w) => !/^[0-9a-f]{7,40}$/i.test(w) && !w.includes('/api/') && !/\.(tsx|mjs|sql)$/.test(w) && !w.startsWith('web/src')).join(' ');
|
|
27
|
+
s = s.replace(/\(\s*[,·\s]*/g, '(').replace(/[,·\s]*\)/g, ')').replace(/\(\)/g, '').replace(/\s+/g, ' ').replace(/[\s\-–—:·,]+$/, '').trim();
|
|
28
|
+
if ([...s].length < 2) s = `${kind} 변경`;
|
|
29
|
+
return plain(s, n);
|
|
30
|
+
}
|
|
31
|
+
// Asia/Seoul 날짜(YYYY-MM-DD). 커밋 시각의 Z·+09:00 혼용을 Date로 통일한다
|
|
32
|
+
const seoulDay = (t) => new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(t));
|
|
33
|
+
const STEP_STATUS = ['live', 'mock', 'planned', 'next'];
|
|
34
|
+
// 결정 대기 "<주체>: <질문>" 나누기. 주체는 콜론 없는 1~20자, 콜론 뒤 공백 필수(URL·시각은 주체가 아니다)
|
|
35
|
+
export function splitWaiting(text) {
|
|
36
|
+
const t = plain(text);
|
|
37
|
+
const m = t.match(/^([^::\s][^::]{0,19}?)\s*[::]\s+(.+)$/);
|
|
38
|
+
return m ? { who: m[1].trim(), what: m[2] } : { who: null, what: t };
|
|
39
|
+
}
|
|
4
40
|
|
|
5
41
|
export function derive(g, sem, cfg, { captureExists }) {
|
|
6
42
|
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));
|
|
@@ -96,14 +132,55 @@ export function derive(g, sem, cfg, { captureExists }) {
|
|
|
96
132
|
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
133
|
const milestones = g.of('milestone').sort((a, b) => a.props.order - b.props.order);
|
|
98
134
|
const milestoneIds = new Set(milestones.map((m) => m.id));
|
|
135
|
+
const releases = g.of('release').sort((a, b) => a.props.order - b.props.order);
|
|
136
|
+
const releaseIds = new Set(releases.map((r) => r.id));
|
|
99
137
|
const roadmap = milestones.map((m) => {
|
|
100
138
|
const p = m.props;
|
|
101
139
|
const scenes = p.scenes.map((r) => stepByRef[r] ? { ref: r, ...stepByRef[r] } : { ref: r, missing: true });
|
|
102
140
|
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
141
|
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
142
|
if (!ROADMAP_STATUS.includes(p.status)) problems.push(`알 수 없는 상태: ${p.status || '(비어 있음)'}`);
|
|
143
|
+
if (p.milestone && !releaseIds.has(p.milestone)) problems.push(`마일스톤 없음 ${p.milestone}`);
|
|
105
144
|
const live = scenes.filter((s) => s.status === 'live').length;
|
|
106
|
-
|
|
145
|
+
const 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 }));
|
|
146
|
+
const waiting = splitWaiting(p.waitingOn);
|
|
147
|
+
// 막힘: 결정 대기(비완료), 선행 미완(진행·다음), 추적 작업 대기
|
|
148
|
+
const blockedBy = [];
|
|
149
|
+
if (p.status !== '완료' && p.waitingOn) blockedBy.push('waiting');
|
|
150
|
+
if (['진행', '다음'].includes(p.status) && deps.some((d) => d.status !== '완료')) blockedBy.push('deps');
|
|
151
|
+
if (trackedTasks.some((t) => t.status === '대기')) blockedBy.push('task');
|
|
152
|
+
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, scenes, tasks: trackedTasks, progress: { live, total: scenes.length, fixed: scenes.filter((s) => s.fixed).length }, problems, src: m.src,
|
|
153
|
+
milestone: p.milestone ?? null, completedAt: p.completedAt ?? null, waitingSince: p.waitingSince ?? null, waitingWho: waiting.who, waitingWhat: waiting.what, blockedBy };
|
|
154
|
+
});
|
|
155
|
+
for (const t of taskView) t.roadmapItems = roadmap.filter((m) => m.tasks.some((x) => x.name === t.name)).map((m) => m.id);
|
|
156
|
+
|
|
157
|
+
// ---- 마일스톤(release 노드): 소속 항목·진척·막힘. problems·warnings는 check가 그대로 싣는 완성 문장이다 ----
|
|
158
|
+
const itemIds = new Set(roadmap.map((m) => m.id));
|
|
159
|
+
const milestoneView = releases.map((r) => {
|
|
160
|
+
const p = r.props;
|
|
161
|
+
const items = roadmap.filter((m) => m.milestone === r.id);
|
|
162
|
+
const sceneRefs = new Map(items.flatMap((m) => m.scenes.filter((s) => !s.missing)).map((s) => [s.ref, s]));
|
|
163
|
+
const planTasks = new Map(items.flatMap((m) => m.tasks.filter((t) => !t.missing)).map((t) => [t.name, t]));
|
|
164
|
+
const waiting = splitWaiting(p.waitingOn);
|
|
165
|
+
const at = `마일스톤 ${r.label}: `;
|
|
166
|
+
const problems = [], warnings = [];
|
|
167
|
+
if (p.idMissing) problems.push(`${at}id 없음`);
|
|
168
|
+
if (p.duplicates || itemIds.has(r.id)) problems.push(`마일스톤 id 중복: ${r.id}`);
|
|
169
|
+
if (!ROADMAP_STATUS.includes(p.status)) problems.push(`${at}알 수 없는 상태 ${p.status || '(비어 있음)'}`);
|
|
170
|
+
for (const [key, v] of [['완료일', p.completedOn], ['목표일', p.targetOn]]) if (v && !isDay(v)) problems.push(`${at}날짜 형식 ${key} ${v}`);
|
|
171
|
+
const open = items.filter((m) => m.status !== '완료');
|
|
172
|
+
if (p.status === '완료' && open.length) warnings.push(`${at}완료인데 미완료 항목 ${open.length}`);
|
|
173
|
+
if (p.status !== '완료' && items.length && !open.length) warnings.push(`${at}항목이 모두 완료인데 상태 ${p.status}`);
|
|
174
|
+
if (['다음', '대기', '이후'].includes(p.status) && items.some((m) => m.status === '진행')) warnings.push(`${at}진행 항목이 있는데 상태 ${p.status}`);
|
|
175
|
+
if (!items.length) warnings.push(`${at}묶인 항목 없음`);
|
|
176
|
+
if ((p.status === '완료') !== !!p.completedOn) warnings.push(`${at}완료일과 상태가 맞지 않음`);
|
|
177
|
+
return {
|
|
178
|
+
id: r.id, order: p.order, title: r.label, status: p.status, goal: p.goal, completedOn: p.completedOn || null, targetOn: p.targetOn || null,
|
|
179
|
+
waitingOn: p.waitingOn, waitingWho: waiting.who, waitingWhat: waiting.what, waitingSince: p.waitingSince ?? null,
|
|
180
|
+
items: items.map((m) => m.id), progress: { live: [...sceneRefs.values()].filter((s) => s.status === 'live').length, total: sceneRefs.size },
|
|
181
|
+
plans: { done: [...planTasks.values()].reduce((n, t) => n + (t.pnDone || 0), 0), total: [...planTasks.values()].reduce((n, t) => n + (t.pnDone || 0) + (t.pnOpen || 0), 0) },
|
|
182
|
+
blocked: !!p.waitingOn || open.some((m) => m.blockedBy.length > 0), problems, warnings, src: r.src,
|
|
183
|
+
};
|
|
107
184
|
});
|
|
108
185
|
|
|
109
186
|
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 }));
|
|
@@ -122,17 +199,94 @@ export function derive(g, sem, cfg, { captureExists }) {
|
|
|
122
199
|
adapters: g.toJSON().adapters, semantic: { actors: sem.actors || {}, statusLegend: sem.statusLegend || {}, journeys },
|
|
123
200
|
summary, orphans, coverage, tasks: taskView, roadmap, ledger, decisions: decisionView, plans, commits: commitView, areaCounts,
|
|
124
201
|
screens: screenView, apis: apiView, functions: fnView, migrations: migView, tests: testView,
|
|
202
|
+
milestones: milestoneView, issues: g.issues.map((i) => ({ ...i })), sources: { semantic: cfg.semantic, roadmap: cfg.roadmap?.file ?? null },
|
|
125
203
|
};
|
|
126
204
|
}
|
|
127
205
|
|
|
128
206
|
// 첫 화면 전용 조각: 시스템 식별자(경로·파일·sha)를 뺀다. 개요는 이것만 받는다.
|
|
129
|
-
|
|
207
|
+
// 창은 generatedAt의 Asia/Seoul 날짜로 끝나는 opts.sinceDays(기본 14)일, 커밋 수는 작성자가 [bot]으로 끝나지 않는 사람 커밋만 센다.
|
|
208
|
+
export function overviewSlice(d, opts = {}) {
|
|
130
209
|
const A = d.semantic.actors;
|
|
210
|
+
const days = opts.sinceDays ?? 14;
|
|
211
|
+
const lastDay = Date.parse(`${seoulDay(d.generatedAt)}T00:00:00+09:00`);
|
|
212
|
+
const dates = Array.from({ length: days }, (_, i) => seoulDay(lastDay - (days - 1 - i) * 864e5));
|
|
213
|
+
const dayIndex = new Map(dates.map((x, i) => [x, i]));
|
|
214
|
+
const zeros = () => new Array(days).fill(0);
|
|
215
|
+
const sum = (xs) => xs.reduce((a, b) => a + b, 0);
|
|
216
|
+
const isBot = (c) => /\[bot\]$/.test(c.author || '');
|
|
217
|
+
const inWindow = (d.commits || []).filter((c) => dayIndex.has(seoulDay(c.date)));
|
|
218
|
+
const human = inWindow.filter((c) => !isBot(c));
|
|
219
|
+
const act = { commits: zeros(), runtime: zeros(), bots: zeros() };
|
|
220
|
+
for (const c of inWindow) { const i = dayIndex.get(seoulDay(c.date)); if (isBot(c)) act.bots[i] += 1; else { act.commits[i] += 1; if (c.runtime) act.runtime[i] += 1; } }
|
|
221
|
+
|
|
222
|
+
// 기능별 계열, 단계 hits, 기능 쌍 연결(닿은 기능이 2개 이상·전체 절반 이하인 커밋만)
|
|
223
|
+
const nJourneys = d.semantic.journeys.length;
|
|
224
|
+
const series = {}, hits = {}, pairs = new Map();
|
|
225
|
+
for (const c of human) {
|
|
226
|
+
const i = dayIndex.get(seoulDay(c.date));
|
|
227
|
+
const js = [...new Set(c.journeys.map((x) => x.journey))];
|
|
228
|
+
for (const j of js) (series[j] ||= zeros())[i] += 1;
|
|
229
|
+
for (const k of new Set(c.journeys.map((x) => `${x.journey}/${x.step}`))) hits[k] = (hits[k] || 0) + 1;
|
|
230
|
+
if (js.length >= 2 && js.length <= Math.floor(nJourneys / 2)) {
|
|
231
|
+
for (let a = 0; a < js.length; a++) for (let b = a + 1; b < js.length; b++) { const k = [js[a], js[b]].sort().join('|'); pairs.set(k, (pairs.get(k) || 0) + 1); }
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const links = [...pairs].map(([k, n]) => { const [a, b] = k.split('|'); return { a, b, n }; }).sort((x, y) => y.n - x.n || `${x.a}${x.b}`.localeCompare(`${y.a}${y.b}`)).slice(0, 30);
|
|
235
|
+
|
|
236
|
+
// 로드맵 항목·마일스톤: 문구는 plain으로 줄이고, 항목 문구는 비완료만 싣는다
|
|
237
|
+
const milestoneList = d.milestones || [];
|
|
238
|
+
const milestoneById = new Map(milestoneList.map((m) => [m.id, m]));
|
|
239
|
+
const roadmapItems = (d.roadmap || []).slice().sort((a, b) => a.order - b.order).map((r) => {
|
|
240
|
+
const done = r.status === '완료';
|
|
241
|
+
const waitingOn = done ? '' : plain(r.waitingOn, 140);
|
|
242
|
+
const w = splitWaiting(waitingOn);
|
|
243
|
+
const scenes = (r.scenes || []).filter((s) => !s.missing);
|
|
244
|
+
return {
|
|
245
|
+
id: r.id, order: r.order, title: r.title, status: r.status, mode: r.mode, milestone: milestoneById.has(r.milestone) ? r.milestone : null,
|
|
246
|
+
goal: done ? '' : plain(r.goal, 140), waitingOn, waitingWho: w.who, waitingWhat: w.what, waitingSince: waitingOn ? r.waitingSince ?? null : null, completedAt: r.completedAt ?? null,
|
|
247
|
+
sceneCounts: Object.fromEntries(STEP_STATUS.map((k) => [k, scenes.filter((s) => s.status === k).length])),
|
|
248
|
+
tasks: (r.tasks || []).filter((t) => !t.missing).map((t) => ({ name: t.name, title: plain(t.title, 60), stage: t.stage, status: t.status, pnDone: t.pnDone, pnOpen: t.pnOpen })),
|
|
249
|
+
blockedBy: r.blockedBy || [],
|
|
250
|
+
};
|
|
251
|
+
});
|
|
252
|
+
const milestones = milestoneList.map((m) => {
|
|
253
|
+
const waitingOn = plain(m.waitingOn, 140);
|
|
254
|
+
const w = splitWaiting(waitingOn);
|
|
255
|
+
return {
|
|
256
|
+
id: m.id, order: m.order, title: m.title, status: m.status, goal: plain(m.goal, 140), completedOn: isDay(m.completedOn) ? m.completedOn : null, targetOn: isDay(m.targetOn) ? m.targetOn : null,
|
|
257
|
+
waitingWho: w.who, waitingWhat: w.what, waitingSince: waitingOn ? m.waitingSince ?? null : null, items: m.items, steps: m.progress, plans: m.plans, blocked: m.blocked,
|
|
258
|
+
};
|
|
259
|
+
});
|
|
260
|
+
const currentMilestone = (milestones.find((m) => m.status === '진행') || milestones.find((m) => m.status === '다음'))?.id ?? null;
|
|
261
|
+
const openItems = (d.roadmap || []).filter((r) => r.status !== '완료').sort((a, b) => a.order - b.order);
|
|
262
|
+
|
|
263
|
+
const captures = [];
|
|
264
|
+
for (const j of d.semantic.journeys) {
|
|
265
|
+
const s = j.steps.find((x) => x.status === 'live' && x.captureFile && !captures.some((c) => c.file === x.captureFile));
|
|
266
|
+
if (s && captures.length < 5) captures.push({ file: s.captureFile, journey: j.id, step: s.id });
|
|
267
|
+
}
|
|
268
|
+
const changes = human.slice().sort((a, b) => Date.parse(b.date) - Date.parse(a.date)).slice(0, 20).map((c) => {
|
|
269
|
+
const touched = new Set(c.journeys.map((x) => x.journey));
|
|
270
|
+
const titles = d.semantic.journeys.filter((j) => touched.has(j.id)).map((j) => j.title).slice(0, 3);
|
|
271
|
+
return { date: new Date(c.date).toISOString(), kind: changeKind(c.subject), subject: subjectPlain(c.subject, 70), journeys: titles, journeysMore: touched.size - titles.length };
|
|
272
|
+
});
|
|
273
|
+
const stepCounts = Object.fromEntries(STEP_STATUS.map((k) => [k, 0]));
|
|
274
|
+
for (const j of d.semantic.journeys) for (const s of j.steps) if (s.status in stepCounts) stepCounts[s.status] += 1;
|
|
131
275
|
const lastRun = d.testreport ? { fresh: d.testreport.fresh, failures: d.testreport.failures, total: d.testreport.total, at: d.testreport.at } : null;
|
|
132
276
|
return {
|
|
133
277
|
generatedAt: d.generatedAt, project: d.project, headDate: d.head?.date || null,
|
|
134
278
|
line: summaryLine(d),
|
|
135
|
-
journeys: d.semantic.journeys.map((j) =>
|
|
279
|
+
journeys: d.semantic.journeys.map((j) => {
|
|
280
|
+
const item = openItems.find((r) => (r.scenes || []).some((s) => !s.missing && s.journey === j.id));
|
|
281
|
+
const ms = item && milestoneById.get(item.milestone);
|
|
282
|
+
const js = series[j.id] || zeros();
|
|
283
|
+
return {
|
|
284
|
+
id: j.id, title: j.title, actor: A[j.actor] || j.actor, lane: j.lane, status: j.status, warnings: j.warnings,
|
|
285
|
+
steps: j.steps.map((s) => ({ id: s.id, label: s.label, status: s.status, grade: s.grade, warn: s.warnings.length > 0, hits: hits[`${j.id}/${s.id}`] || 0 })),
|
|
286
|
+
goal: plain(j.goal, 80), counts: j.counts, roadmapItem: item ? { id: item.id, title: item.title, status: item.status } : null, milestone: ms ? { id: ms.id, title: ms.title, status: ms.status } : null,
|
|
287
|
+
commits: sum(js), week: sum(js.slice(-7)), series: js,
|
|
288
|
+
};
|
|
289
|
+
}),
|
|
136
290
|
running: d.ledger.running.map((r) => ({ work: r.work, owner: r.owner })),
|
|
137
291
|
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
292
|
roadmapDone: d.roadmap.filter((m) => m.status === '완료').length, roadmapTotal: d.roadmap.length,
|
|
@@ -143,11 +297,16 @@ export function overviewSlice(d) {
|
|
|
143
297
|
tests: lastRun ? (lastRun.failures ? 'fail' : lastRun.fresh ? 'ok' : 'stale') : 'none', lastRun,
|
|
144
298
|
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
299
|
warnings: d.summary.warnings, orphans: d.summary.orphans, gated: d.tests.filter((t) => t.gated).reduce((n, t) => n + t.count, 0),
|
|
300
|
+
deployBehindAll: d.deploy?.behind ?? null,
|
|
146
301
|
},
|
|
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
|
|
302
|
+
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,
|
|
303
|
+
steps: stepCounts, journeys: nJourneys, journeysLive: d.semantic.journeys.filter((j) => j.status === 'live').length, tasksRunning: d.tasks.filter((t) => t.status === '진행').length },
|
|
148
304
|
areas: Object.entries(d.areaCounts).sort((a, b) => b[1] - a[1]).slice(0, 6),
|
|
149
305
|
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
306
|
openQuestions: d.plans.filter((p) => p.oq).map((p) => ({ title: p.title, oq: p.oq })),
|
|
307
|
+
roadmapItems, milestones, currentMilestone,
|
|
308
|
+
activity: { sinceDays: days, days: dates.map((date, i) => ({ date, commits: act.commits[i], runtime: act.runtime[i], bots: act.bots[i] })), total: sum(act.commits), runtime: sum(act.runtime), bots: sum(act.bots) },
|
|
309
|
+
changes, links, captures,
|
|
151
310
|
};
|
|
152
311
|
}
|
|
153
312
|
|
package/src/lib/graph.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// 그래프 모델. 어댑터는 노드·엣지만 넣고, 화면은 파생 뷰만 읽는다.
|
|
2
2
|
// 노드: { kind, id, label, props, src: { file, line, rule } } 엣지: { from, to, kind }
|
|
3
|
-
|
|
3
|
+
// 문제: { level: 'error'|'warn', label, message, adapter } — 어댑터가 g.issue로 낸 오류·경고. check가 줄로 출력한다.
|
|
4
|
+
export const NODE_KINDS = ['journey', 'step', 'screen', 'api', 'function', 'table', 'migration', 'test', 'commit', 'decision', 'task', 'ledger', 'deploy', 'testreport', 'milestone', 'release'];
|
|
4
5
|
export const EDGE_KINDS = ['has_step', 'shows', 'uses', 'calls', 'invokes', 'touches', 'covers', 'changes', 'refs', 'defines', 'contains', 'tracks'];
|
|
5
6
|
|
|
6
7
|
export class Graph {
|
|
7
|
-
constructor() { this.nodes = new Map(); this.edges = []; this.adapters = []; }
|
|
8
|
+
constructor() { this.nodes = new Map(); this.edges = []; this.adapters = []; this.issues = []; this.adapter = null; }
|
|
8
9
|
key(kind, id) { return `${kind}:${id}`; }
|
|
9
10
|
add(kind, id, label, props = {}, src = null) {
|
|
10
11
|
if (!NODE_KINDS.includes(kind)) throw new Error(`unknown node kind ${kind}`);
|
|
@@ -30,19 +31,28 @@ export class Graph {
|
|
|
30
31
|
const to = this.key(kind, id);
|
|
31
32
|
return this.edges.filter((e) => e.to === to && (!edgeKind || e.kind === edgeKind)).map((e) => this.nodes.get(e.from)).filter(Boolean);
|
|
32
33
|
}
|
|
34
|
+
// 어댑터가 발견한 문제를 기록한다. level이 틀리면 throw해 그 어댑터가 failed가 된다. adapter는 runAdapter가 설정한 실행 중 이름.
|
|
35
|
+
issue(level, label, message) {
|
|
36
|
+
if (level !== 'error' && level !== 'warn') throw new Error(`issue level은 'error' 또는 'warn': ${level}`);
|
|
37
|
+
if (typeof label !== 'string' || typeof message !== 'string') throw new Error('issue label·message는 문자열');
|
|
38
|
+
this.issues.push({ level, label, message, adapter: this.adapter });
|
|
39
|
+
}
|
|
33
40
|
report(name, status, count, error = null) { this.adapters.push({ name, status, count, error }); }
|
|
34
41
|
toJSON() {
|
|
35
|
-
return { schemaVersion: 1, adapters: this.adapters, nodes: [...this.nodes.values()], edges: this.edges };
|
|
42
|
+
return { schemaVersion: 1, adapters: this.adapters, nodes: [...this.nodes.values()], edges: this.edges, issues: this.issues };
|
|
36
43
|
}
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
// 어댑터 실행 래퍼: 실패해도 그래프 생성이 멈추지 않고 failed로 기록된다(빈 표를 "없음"으로 오해하지 않게).
|
|
40
47
|
export function runAdapter(graph, name, fn) {
|
|
41
48
|
const before = graph.nodes.size;
|
|
49
|
+
graph.adapter = name;
|
|
42
50
|
try {
|
|
43
51
|
const partial = fn(graph);
|
|
44
52
|
graph.report(name, partial ? 'partial' : 'ok', graph.nodes.size - before, partial || null);
|
|
45
53
|
} catch (e) {
|
|
46
54
|
graph.report(name, 'failed', graph.nodes.size - before, String(e.message || e));
|
|
55
|
+
} finally {
|
|
56
|
+
graph.adapter = null;
|
|
47
57
|
}
|
|
48
58
|
}
|
package/templates/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 프로젝트 상황판
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
처음 보는 동료·외부 독자가 코드를 읽지 않고 "지금 어느 마일스톤이고 얼마나 왔나, 무엇이 동작하고 무엇이 남았나, 무엇을 기다리나, 최근 얼마나 바뀌었나"를 한 화면에서 보고, 기능 → 단계 → 화면·API·DB·검사·결정·작업으로 파고드는 뷰다. 엔진은 npm 패키지 `@pghoya2956/livemap`이고, 이 폴더에는 프로젝트 소유 파일만 둔다. 배포본 주소: `<배포 주소>/map/`. 실시간은 로컬 `npm run map:serve`.
|
|
4
4
|
|
|
5
5
|
## 명령
|
|
6
6
|
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
| 파일 | 내용 |
|
|
19
19
|
|---|---|
|
|
20
20
|
| `config.json` | 어댑터 입력 경로·바닥값·화면 예산. `engine`은 엔진 major |
|
|
21
|
-
| `semantic/journeys.json` |
|
|
21
|
+
| `semantic/journeys.json` | 여정 파일: 배우·목표·장면. 장면 상태가 바뀌는 병합은 같은 커밋에서 이 파일을 고친다. 사람이 적는 곳은 이 파일과 로드맵(`config.json`의 `roadmap.file`, 마일스톤 포함)·작업 장부·작업 폴더 문서다 |
|
|
22
22
|
| `captures/*.jpg` | 장면 캡처 |
|
|
23
23
|
| `adapters/<이름>.mjs` | 이 프로젝트만의 어댑터(선택). 참조 어댑터와 이름이 같으면 참조 어댑터를 가린다 |
|
|
24
24
|
|