@pghoya2956/livemap 1.1.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/derive.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  // 파생: 그래프(코드 사실) + 여정 파일(뜻)을 합쳐 화면이 읽는 뷰 모델을 만든다. 화면은 여기서 만든 것만 그린다.
2
+ import { combineReading, countReadings, readingOf } from './lib/reading.mjs';
2
3
  const ROADMAP_STATUS = ['완료', '진행', '다음', '대기', '이후'];
3
4
  const RANK = { live: 0, partial: 1, mixed: 1, mock: 2, planned: 3, next: 4, static: 5 };
4
5
  const DAY = /^(\d{4})-(\d{2})-(\d{2})$/;
@@ -38,6 +39,44 @@ export function splitWaiting(text) {
38
39
  return m ? { who: m[1].trim(), what: m[2] } : { who: null, what: t };
39
40
  }
40
41
 
42
+ // 로드맵 선행의 흐름 문제(1.3.0): 선행 순환과 마일스톤 순서 역행. 화면 ui/lib/tree.js와 같은 규칙이고 check는 경고로 낸다.
43
+ // 순환: 자기에게 되돌아오는 항목마다 자기에서 시작해 선행 → 후속 방향으로 돌아오는 가장 짧은 경로 한 문장.
44
+ // 역행: 마일스톤이 있을 때 선행의 열(파일 순서, 미배정·없는 id는 맨 오른쪽)이 항목의 열보다 오른쪽이면 엣지마다 한 문장.
45
+ function roadmapFlowProblems(items, releases) {
46
+ const out = new Map();
47
+ const add = (id, text) => { if (!out.has(id)) out.set(id, []); out.get(id).push(text); };
48
+ const ids = new Set(items.map((m) => m.id));
49
+ const deps = new Map(items.map((m) => [m.id, [...new Set(m.props.deps)].filter((d) => ids.has(d))]));
50
+ const children = new Map(items.map((m) => [m.id, []]));
51
+ for (const m of items) for (const d of deps.get(m.id)) children.get(d).push(m.id);
52
+ for (const m of items) {
53
+ const prev = new Map([[m.id, null]]);
54
+ const queue = [m.id];
55
+ let back = null;
56
+ for (let i = 0; i < queue.length && !back; i++) {
57
+ for (const c of children.get(queue[i])) {
58
+ if (c === m.id) { back = queue[i]; break; }
59
+ if (!prev.has(c)) { prev.set(c, queue[i]); queue.push(c); }
60
+ }
61
+ }
62
+ if (!back) continue;
63
+ const path = [m.id];
64
+ for (let x = back; x !== m.id; x = prev.get(x)) path.splice(1, 0, x);
65
+ add(m.id, `선행 순환: ${[...path, m.id].join(' → ')}`);
66
+ }
67
+ if (releases.length) {
68
+ const col = new Map(releases.map((r, i) => [r.id, i]));
69
+ const colOf = (m) => col.get(m.props.milestone) ?? releases.length;
70
+ const label = (m) => (col.has(m.props.milestone) ? m.props.milestone : '마일스톤 없음');
71
+ const byId = new Map(items.map((m) => [m.id, m]));
72
+ for (const m of items) for (const d of deps.get(m.id)) {
73
+ const p = byId.get(d);
74
+ if (colOf(p) > colOf(m)) add(m.id, `마일스톤 순서 역행: ${d}(${label(p)}) → ${m.id}(${label(m)})`);
75
+ }
76
+ }
77
+ return out;
78
+ }
79
+
41
80
  export function derive(g, sem, cfg, { captureExists }) {
42
81
  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));
43
82
  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');
@@ -46,36 +85,68 @@ export function derive(g, sem, cfg, { captureExists }) {
46
85
  const report = g.get('testreport', 'last')?.props || null;
47
86
  const short = (t) => t.split('.').pop();
48
87
  const testsOf = (kind, id) => g.in(kind, id, 'covers').map((t) => t.label);
49
- const testsPassedFresh = (kind, id) => g.in(kind, id, 'covers').some((t) => t.props.lastRun?.passed && t.props.lastRun?.fresh);
88
+ // 등급 A: 덮는 검사 파일 하나가 최신 실행에서 검사 1개 이상·실패 0(파일 경로 정확 일치는 testreport 어댑터가 맞춘다)
89
+ const testsPassedFresh = (kind, id) => g.in(kind, id, 'covers').some((t) => t.props.lastRun?.passed && t.props.lastRun?.fresh && (t.props.lastRun.tests ?? 1) >= 1);
50
90
 
51
91
  // ---- 화면·API·함수 뷰 ----
52
92
  const screenView = screens.map((s) => ({
53
93
  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,
54
94
  apis: g.out('screen', s.id, 'calls').map((a) => a.id), tests: testsOf('screen', s.id), last: s.props.last, src: s.src,
55
- steps: [],
95
+ steps: [], reading: s.props.reading || {},
56
96
  }));
57
97
  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 }));
58
98
  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 }));
59
99
  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 }));
60
- 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 }));
100
+ 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, reading: t.props.reading || {}, readingNotes: t.props.readingNotes || {} }));
61
101
 
62
102
  // ---- 여정 해석 ----
63
103
  const byPath = Object.fromEntries(screenView.map((s) => [s.path, s]));
64
104
  const apiByPath = Object.fromEntries(apiView.map((a) => [a.path, a]));
65
105
  const decisionBySlug = Object.fromEntries(decisions.map((d) => [d.id, d]));
66
- 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 }]));
67
- const resolveRef = (ref) => { const m = ref.match(/^((?:DEC|PN|P\d)-\d+)/i); return m ? defBy[m[1].toUpperCase()] || null : null; };
106
+ // 번호 참조: 정의 작업(definers) 하나면 작업(rule), 여럿이면 폴더 이름 작업(partial, tasks.ambiguous-ref).
107
+ // 정의 작업이 없는 노드(1.1.1 호환 줄에서만 만든 노드)는 1.1.1처럼 노드를 만든 작업으로 잇는다. <폴더>#<번호>는 작업의 정의만 본다
108
+ const defNode = Object.fromEntries(specDefs.map((d) => [d.id, d]));
109
+ const defOf = (d, task) => ({ task, file: d.props.file, done: d.props.done });
110
+ const resolveRef = (ref) => {
111
+ const q = ref.match(/^(\d{8}-[^\s#]+)#([A-Za-z]{1,4}[0-9]?-\d+)/);
112
+ if (q) {
113
+ const d = defNode[q[2].toUpperCase()];
114
+ const at = d && ((d.props.definedAt || []).find((x) => x.task === q[1]) || (g.in('decision', d.id, 'defines').some((t) => t.id === q[1]) ? { task: q[1], file: d.props.file } : null));
115
+ return at ? { task: at.task, file: at.file, done: d.props.done, definers: d.props.definers || [], reading: 'rule' } : { qualified: true };
116
+ }
117
+ const m = ref.match(/^((?:DEC|PN|P\d)-\d+)/i) || ref.match(/^([A-Z]{1,4}[0-9]?-\d+)/);
118
+ if (!m) return null;
119
+ const d = defNode[m[1].toUpperCase()];
120
+ if (!d) return { id: m[1].toUpperCase() };
121
+ const definers = d.props.definers || [];
122
+ if (definers.length > 1) return { ...defOf(d, definers[0]), definers, reading: 'partial', ambiguous: m[1].toUpperCase() };
123
+ if (definers.length === 1) return { ...defOf(d, definers[0]), definers, reading: 'rule' };
124
+ const task = g.in('decision', d.id, 'defines')[0]?.id || null;
125
+ return task ? { ...defOf(d, task), definers, reading: 'rule' } : { id: d.id };
126
+ };
127
+ const seenIssue = new Set(g.issues.map((i) => `${i.code}|${i.subject?.id}|${i.message}`));
128
+ // 관측한 화면 호출(calls 엣지). 고아 API와 journey.api-not-observed는 이것만 센다
129
+ const observedApis = new Set(screenView.flatMap((s) => s.apis));
68
130
  const journeys = (sem.journeys || []).map((j) => {
69
131
  const steps = j.steps.map((st) => {
70
132
  const actor = st.actor || j.actor;
71
133
  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' });
72
134
  const apiSet = new Set(st.apis || []);
135
+ for (const a of st.apis || []) {
136
+ if (observedApis.has(a)) continue;
137
+ const message = `${j.title} › ${st.label}: 여정 apis ${a}를 부르는 화면이 관측되지 않음`;
138
+ const subject = { kind: 'step', id: `${j.id}/${st.id}` };
139
+ const key = `journey.api-not-observed|${subject.id}|${message}`;
140
+ if (seenIssue.has(key)) continue;
141
+ seenIssue.add(key);
142
+ g.issue('warn', '여정 API', message, { code: 'journey.api-not-observed', subject, anchors: cfg.semantic ? [{ file: cfg.semantic }] : [] });
143
+ }
73
144
  for (const p of st.screens || []) for (const a of byPath[p]?.apis || []) apiSet.add(a);
74
145
  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 });
75
146
  const fnNames = [...new Set(apiNodes.flatMap((a) => a.calls.filter((c) => !c.startsWith('auth:'))))];
76
147
  const functionNodes = fnNames.map((n) => ({ name: n, tables: fnView.find((f) => f.name === n)?.tables || [], tests: testsOf('function', n) }));
77
148
  const testFiles = [...new Set([...screenNodes.flatMap((x) => x.tests || []), ...apiNodes.flatMap((x) => x.tests || []), ...functionNodes.flatMap((x) => x.tests || [])])];
78
- 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 }));
149
+ const refNodes = (st.refs || []).map((r) => { const { qualified, id, ambiguous, ...hit } = resolveRef(r) || {}; return { ref: r, ...hit, wiki: decisionBySlug[r] ? { title: decisionBySlug[r].label, file: decisionBySlug[r].props.file, status: decisionBySlug[r].props.status } : null, _q: qualified, _id: id, _amb: ambiguous }; });
79
150
  const taskNames = [...new Set(refNodes.map((r) => r.task).filter(Boolean))];
80
151
  const warnings = [];
81
152
  for (const n of screenNodes) {
@@ -83,7 +154,22 @@ export function derive(g, sem, cfg, { captureExists }) {
83
154
  else if (st.status === 'live' && n.source !== 'live') warnings.push(`장면은 동작인데 화면은 ${n.source}: ${n.path}`);
84
155
  else if ((st.status === 'planned' || st.status === 'next') && n.source === 'live') warnings.push(`장면은 ${st.status}인데 화면은 동작: ${n.path}`);
85
156
  }
86
- for (const r of refNodes) if (/^(DEC|PN|P\d)-/i.test(r.ref) && !r.task) warnings.push(`참조 미해결: ${r.ref}`);
157
+ for (const r of refNodes) {
158
+ if ((/^(DEC|PN|P\d)-/i.test(r.ref) || r._q) && !r.task) warnings.push(`참조 미해결: ${r.ref}`);
159
+ // 1.1.1이 인식하지 않던 접두어는 풀리지 않아도 경고(1.x 종료 코드 보호)
160
+ else if (r._id && !r.task) warnings.push(`번호 참조 대상 없음: ${r.ref}`);
161
+ if (r._amb) {
162
+ const d = defNode[r._amb];
163
+ const message = `${j.title} › ${st.label}: ${r._amb}을 정의한 작업 ${r.definers.length}개, 폴더 이름 순 첫 작업 ${r.task}로 이음`;
164
+ const subject = { kind: 'step', id: `${j.id}/${st.id}` };
165
+ const key = `tasks.ambiguous-ref|${subject.id}|${message}`;
166
+ if (!seenIssue.has(key)) {
167
+ seenIssue.add(key);
168
+ g.issue('warn', '여정 참조', message, { code: 'tasks.ambiguous-ref', subject, anchors: (d.props.definedAt || []).map((x) => ({ file: x.file, line: x.line })) });
169
+ }
170
+ }
171
+ delete r._q; delete r._id; delete r._amb;
172
+ }
87
173
  // 등급: D 주장 / C 관측 / B 검사 존재 / A 최신 커밋에서 통과
88
174
  const observed = screenNodes.length > 0 && screenNodes.every((n) => n.source === 'live');
89
175
  const covered = observed && testFiles.length > 0;
@@ -106,10 +192,9 @@ export function derive(g, sem, cfg, { captureExists }) {
106
192
 
107
193
  // ---- 고아·커버리지 ----
108
194
  const inJourney = new Set(journeys.flatMap((j) => j.steps.flatMap((s) => s.screens || [])));
109
- const calledApis = new Set(screenView.flatMap((s) => s.apis).concat(journeys.flatMap((j) => j.steps.flatMap((s) => s.apis || []))));
110
195
  const orphans = {
111
196
  screens: screenView.filter((s) => !inJourney.has(s.path)).map((s) => s.path),
112
- apis: apiView.filter((a) => !calledApis.has(a.path)).map((a) => a.path),
197
+ apis: apiView.filter((a) => !observedApis.has(a.path)).map((a) => a.path),
113
198
  functions: fnView.filter((f) => !f.usedByApi).map((f) => f.name),
114
199
  tests: testView.filter((t) => !g.out('test', t.file, 'covers').length).map((t) => t.label),
115
200
  };
@@ -125,7 +210,7 @@ export function derive(g, sem, cfg, { captureExists }) {
125
210
  for (const c of commitView) for (const a of c.areas) areaCounts[a] = (areaCounts[a] || 0) + 1;
126
211
 
127
212
  // ---- 작업·장부·결정 ----
128
- 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));
213
+ const taskView = tasks.map((t) => ({ name: t.id, title: t.label, ...t.props, readLines: undefined, reading: t.props.reading || {}, readingNotes: t.props.readingNotes || {}, 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));
129
214
  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' })) };
130
215
  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) }));
131
216
  // ---- 로드맵: 항목 순서대로 장면 상태·작업 단계를 붙인다. 해석 실패는 check가 오류로 막는다 ----
@@ -134,6 +219,7 @@ export function derive(g, sem, cfg, { captureExists }) {
134
219
  const milestoneIds = new Set(milestones.map((m) => m.id));
135
220
  const releases = g.of('release').sort((a, b) => a.props.order - b.props.order);
136
221
  const releaseIds = new Set(releases.map((r) => r.id));
222
+ const flowProblems = roadmapFlowProblems(milestones, releases);
137
223
  const roadmap = milestones.map((m) => {
138
224
  const p = m.props;
139
225
  const scenes = p.scenes.map((r) => stepByRef[r] ? { ref: r, ...stepByRef[r] } : { ref: r, missing: true });
@@ -141,6 +227,7 @@ export function derive(g, sem, cfg, { captureExists }) {
141
227
  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}`)];
142
228
  if (!ROADMAP_STATUS.includes(p.status)) problems.push(`알 수 없는 상태: ${p.status || '(비어 있음)'}`);
143
229
  if (p.milestone && !releaseIds.has(p.milestone)) problems.push(`마일스톤 없음 ${p.milestone}`);
230
+ problems.push(...(flowProblems.get(m.id) || []));
144
231
  const live = scenes.filter((s) => s.status === 'live').length;
145
232
  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
233
  const waiting = splitWaiting(p.waitingOn);
@@ -189,6 +276,8 @@ export function derive(g, sem, cfg, { captureExists }) {
189
276
  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,
190
277
  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),
191
278
  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),
279
+ // 답이 없는 잔여 질문 합계(폐기 제외, 못 읽은 작업은 0으로 더하고 읽기 상태가 partial을 알린다)
280
+ openQuestions: taskView.filter((t) => t.status !== '폐기').reduce((n, t) => n + (t.openQuestions || 0), 0),
192
281
  commits: commitView.length, warnings: journeys.reduce((n, j) => n + j.warnings, 0), orphans: Object.values(orphans).reduce((n, a) => n + a.length, 0),
193
282
  stepsLive: journeys.reduce((n, j) => n + j.counts.live, 0), stepsTotal: journeys.reduce((n, j) => n + j.steps.length, 0),
194
283
  grades: Object.fromEntries(['A', 'B', 'C', 'D'].map((k) => [k, journeys.reduce((n, j) => n + j.steps.filter((s) => s.grade === k).length, 0)])),
@@ -196,10 +285,16 @@ export function derive(g, sem, cfg, { captureExists }) {
196
285
 
197
286
  return {
198
287
  schemaVersion: 1, generatedAt: new Date().toISOString(), project: sem.project || cfg.project, head, deploy: homelab, testreport: report,
288
+ // 결과 실행 목록(러너·출처·sha·시각·exit·최신 여부만, dirtyPaths는 싣지 않는다)
289
+ testRuns: report?.runs || [],
199
290
  adapters: g.toJSON().adapters, semantic: { actors: sem.actors || {}, statusLegend: sem.statusLegend || {}, journeys },
200
291
  summary, orphans, coverage, tasks: taskView, roadmap, ledger, decisions: decisionView, plans, commits: commitView, areaCounts,
201
292
  screens: screenView, apis: apiView, functions: fnView, migrations: migView, tests: testView,
202
293
  milestones: milestoneView, issues: g.issues.map((i) => ({ ...i })), sources: { semantic: cfg.semantic, roadmap: cfg.roadmap?.file ?? null },
294
+ // 읽기 상태 건수: 노드에 적힌 상태만 값별·필드별로 센다(적지 않은 필드는 rule로 보지만 세지 않는다)
295
+ readings: countReadings([...g.nodes.values()]),
296
+ // 판정 파일: 작업마다 판정 파일 경로·by·note와 적용·낡음·무효 항목 수(작업 폴더 이름 순). 파일 모양이 틀린 판정 파일은 issues에만 있다
297
+ judgments: tasks.filter((t) => t.props.judged).map((t) => ({ task: t.id, file: t.props.judged, ...t.props.judgment })).sort((a, b) => a.task.localeCompare(b.task)),
203
298
  };
204
299
  }
205
300
 
@@ -260,10 +355,16 @@ export function overviewSlice(d, opts = {}) {
260
355
  const currentMilestone = (milestones.find((m) => m.status === '진행') || milestones.find((m) => m.status === '다음'))?.id ?? null;
261
356
  const openItems = (d.roadmap || []).filter((r) => r.status !== '완료').sort((a, b) => a.order - b.order);
262
357
 
358
+ // 캡처: 기능마다 동작 단계의 캡처 파일을 단계 순서로 최대 5장. 중복 제거는 기능 안에서만 하고 전체 상한은 없다(1.3.0, DEC-23)
263
359
  const captures = [];
264
360
  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 });
361
+ const files = new Set();
362
+ for (const s of j.steps) {
363
+ if (files.size >= 5) break;
364
+ if (s.status !== 'live' || !s.captureFile || files.has(s.captureFile)) continue;
365
+ files.add(s.captureFile);
366
+ captures.push({ file: s.captureFile, journey: j.id, step: s.id });
367
+ }
267
368
  }
268
369
  const changes = human.slice().sort((a, b) => Date.parse(b.date) - Date.parse(a.date)).slice(0, 20).map((c) => {
269
370
  const touched = new Set(c.journeys.map((x) => x.journey));
@@ -291,16 +392,23 @@ export function overviewSlice(d, opts = {}) {
291
392
  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 })),
292
393
  roadmapDone: d.roadmap.filter((m) => m.status === '완료').length, roadmapTotal: d.roadmap.length,
293
394
  waiting: d.ledger.waiting.filter((w) => !w.done).map((w) => ({ work: w.work, state: (w.state || '').split('(')[0] })),
294
- 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 })),
395
+ 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, openQuestions: t.openQuestions ?? null, reading: t.reading || {} })),
295
396
  signals: {
296
397
  deploy: d.deploy ? (d.deploy.behindRuntime === 0 ? 'ok' : d.deploy.behindRuntime === null ? 'unknown' : 'behind') : 'unknown', deployBehind: d.deploy?.behindRuntime ?? null,
297
- tests: lastRun ? (lastRun.failures ? 'fail' : lastRun.fresh ? 'ok' : 'stale') : 'none', lastRun,
398
+ tests: d.testreport?.signal || (lastRun ? (lastRun.failures ? 'fail' : lastRun.fresh ? 'ok' : 'stale') : 'none'), lastRun,
298
399
  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}`),
299
400
  warnings: d.summary.warnings, orphans: d.summary.orphans, gated: d.tests.filter((t) => t.gated).reduce((n, t) => n + t.count, 0),
300
401
  deployBehindAll: d.deploy?.behind ?? null,
301
402
  },
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 },
403
+ 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, openQuestions: d.summary.openQuestions, decisions: d.decisions.filter((x) => x.status === 'current').length, proposed: d.decisions.filter((x) => x.status === 'proposed').length, grades: d.summary.grades,
404
+ steps: stepCounts, journeys: nJourneys, journeysLive: d.semantic.journeys.filter((j) => j.status === 'live').length, tasksRunning: d.tasks.filter((t) => t.status === '진행').length,
405
+ // 개요 수치의 읽기 상태(값만, 경로·파일명 없음). 등급은 검사 결과 최신 여부와 화면→API 연결에 기댄다
406
+ reading: {
407
+ plans: combineReading(d.tasks.map((t) => readingOf(t, 'plan'))),
408
+ openQuestions: combineReading(d.tasks.map((t) => readingOf(t, 'openQuestions'))),
409
+ tests: combineReading(d.tests.map((t) => readingOf(t, 'count'))),
410
+ grades: combineReading([...d.tests.map((t) => readingOf(t, 'lastRun')), ...d.screens.map((x) => readingOf(x, 'apis'))]),
411
+ } },
304
412
  areas: Object.entries(d.areaCounts).sort((a, b) => b[1] - a[1]).slice(0, 6),
305
413
  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) })),
306
414
  openQuestions: d.plans.filter((p) => p.oq).map((p) => ({ title: p.title, oq: p.oq })),
package/src/init.mjs CHANGED
@@ -1,6 +1,10 @@
1
- // livemap init: 없는 파일만 템플릿으로 만들고 .gitignore 줄과 npm 스크립트를 넣는다. 다시 실행하면 아무것도 바꾸지 않는다.
2
- import { existsSync, mkdirSync, copyFileSync, readFileSync, writeFileSync } from 'node:fs';
3
- import { dirname, join } from 'node:path';
1
+ // livemap init: 없는 파일만 템플릿으로 만들고 .gitignore 줄과 npm 스크립트, 커밋 전 git 훅을 넣는다. 다시 실행하면 아무것도 바꾸지 않는다.
2
+ // 커밋 훅: .githooks/pre-commit(livemap check --staged)을 만들고 git config core.hooksPath .githooks를 둔다.
3
+ // 이미 다른 core.hooksPath, 관리자(.husky·lefthook·pre-commit), .git/hooks의 pre-commit, 다른 내용의 .githooks/pre-commit이 있으면
4
+ // 덮지 않고 그 설정에 넣을 한 줄을 출력한다. git 저장소 루트가 아니면 설치하지 않는다.
5
+ import { existsSync, mkdirSync, copyFileSync, readFileSync, writeFileSync, chmodSync, statSync } from 'node:fs';
6
+ import { execFileSync } from 'node:child_process';
7
+ import { dirname, join, resolve } from 'node:path';
4
8
 
5
9
  export const INIT_FILES = [
6
10
  ['templates/config.json', 'map/config.json'],
@@ -18,6 +22,49 @@ export const SCRIPTS = {
18
22
  'test:report': 'livemap test-report',
19
23
  };
20
24
 
25
+ export const HOOK_DIR = '.githooks';
26
+ export const HOOK_LINE = 'npx --no livemap check --staged';
27
+ export const HOOK_SCRIPT = `#!/bin/sh
28
+ # livemap: 스테이징된 작업 문서(tasks)·판정 파일(map/judgments)에 걸린 tasks.*·judgment.* 문제가 있으면 커밋을 멈춘다.
29
+ # 멈추면 출력의 문제 코드·판정 초안대로 원문을 고치거나 판정 파일을 적어 같은 커밋을 다시 시도한다(--no-verify로 넘기지 않는다).
30
+ ${HOOK_LINE}
31
+ `;
32
+ const OTHER_MANAGERS = [['.husky', 'husky(.husky)'], ['lefthook.yml', 'lefthook(lefthook.yml)'], ['lefthook.yaml', 'lefthook(lefthook.yaml)'], ['.lefthook.yml', 'lefthook(.lefthook.yml)'], ['.pre-commit-config.yaml', 'pre-commit(.pre-commit-config.yaml)']];
33
+
34
+ const gitOut = (root, ...args) => { try { return execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return null; } };
35
+
36
+ // 커밋 전 훅 설치. created·notes에 줄을 더한다
37
+ function installHook(root, created, notes) {
38
+ const top = gitOut(root, 'rev-parse', '--show-toplevel');
39
+ if (top === null) { notes.push('git 저장소 아님: 커밋 전 훅을 설치하지 않음(git init 뒤 다시 livemap init)'); return; }
40
+ const keep = (what) => notes.push(`커밋 전 훅: 기존 ${what}이 있어 덮지 않음. pre-commit 단계에 넣을 줄: ${HOOK_LINE}`);
41
+ if (gitOut(root, 'rev-parse', '--show-prefix')) { keep('저장소 루트가 아닌 위치'); return; }
42
+ const hook = join(root, HOOK_DIR, 'pre-commit');
43
+ const hooksPath = gitOut(root, 'config', '--get', 'core.hooksPath') || '';
44
+ const ours = existsSync(hook) && readFileSync(hook, 'utf8').split(/\r?\n/).some((l) => l.trim() === HOOK_LINE);
45
+ if (hooksPath && hooksPath !== HOOK_DIR) { keep(`core.hooksPath(${hooksPath})`); return; }
46
+ if (hooksPath === HOOK_DIR) {
47
+ if (!existsSync(hook)) { writeHook(hook, created); return; }
48
+ if (!ours) keep(`${HOOK_DIR}/pre-commit`);
49
+ else if ((statSync(hook).mode & 0o111) !== 0o111) { chmodSync(hook, 0o755); created.push(`${HOOK_DIR}/pre-commit: 실행 권한`); }
50
+ return;
51
+ }
52
+ const manager = OTHER_MANAGERS.find(([p]) => existsSync(join(root, p)));
53
+ if (manager) { keep(manager[1]); return; }
54
+ const gitHooks = gitOut(root, 'rev-parse', '--git-path', 'hooks');
55
+ if (gitHooks && existsSync(join(resolve(root, gitHooks), 'pre-commit'))) { keep(`${gitHooks}/pre-commit`); return; }
56
+ if (existsSync(hook) && !ours) { keep(`${HOOK_DIR}/pre-commit`); return; }
57
+ if (!existsSync(hook)) writeHook(hook, created);
58
+ gitOut(root, 'config', 'core.hooksPath', HOOK_DIR);
59
+ created.push(`git config core.hooksPath ${HOOK_DIR}`);
60
+ }
61
+ function writeHook(hook, created) {
62
+ mkdirSync(dirname(hook), { recursive: true });
63
+ writeFileSync(hook, HOOK_SCRIPT);
64
+ chmodSync(hook, 0o755);
65
+ created.push(`${HOOK_DIR}/pre-commit`);
66
+ }
67
+
21
68
  const hasPlaywright = (root, pkg) => existsSync(join(root, 'node_modules/@playwright/test/package.json'))
22
69
  || Boolean(pkg?.devDependencies?.['@playwright/test'] || pkg?.dependencies?.['@playwright/test']);
23
70
 
@@ -62,6 +109,8 @@ export function init({ root, pkgRoot }) {
62
109
  }
63
110
  }
64
111
 
112
+ installHook(root, created, notes);
113
+
65
114
  for (const c of created) console.log(`+ ${c}`);
66
115
  for (const k of kept) console.log(`= ${k}`);
67
116
  for (const n of notes) console.log(`! ${n}`);
@@ -0,0 +1,143 @@
1
+ // import 닫힘: 화면 페이지 파일에서 로컬 import를 따라간다(router 어댑터에서 분리).
2
+ // fileClosure 1.1.1 파일 닫힘. 화면 files·source·mockVia·fixedVia와 git 변경 연결이 쓴다(바꾸지 않는다)
3
+ // literalSources 리터럴 추출 전용 닫힘. localDirs 안 파일은 파일 전체(깊이 3, import type 제외, 재수출 추적),
4
+ // 앱 폴더 안이지만 localDirs 밖인 모듈은 가져온 이름의 최상위 선언만(깊이 2), 그 부분 모듈의 import는 따라가지 않는다
5
+ import { join, dirname } from 'node:path';
6
+
7
+ export function resolveLocal(fs, from, spec) {
8
+ const base = join(from, '..', spec);
9
+ for (const cand of [base + '.tsx', base + '.ts', join(base, 'index.tsx'), join(base, 'index.ts')]) if (fs.has(cand)) return cand;
10
+ return null;
11
+ }
12
+
13
+ const inDirs = (file, dirs) => dirs.some((d) => file.startsWith(d + '/'));
14
+
15
+ // 1.1.1 파일 닫힘(깊이 3, localDirs 안만). 순서는 방문 순서
16
+ export function fileClosure(fs, file, localDirs, 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+(['"])(\.[^'"]+)\1/g)) {
20
+ const t = resolveLocal(fs, file, m[2]);
21
+ if (t && inDirs(t, localDirs)) fileClosure(fs, t, localDirs, depth - 1, seen);
22
+ }
23
+ return seen;
24
+ }
25
+
26
+ // import·export … from 문: { kind: 'import'|'export', spec, names: [가져올 원래 이름], as: { 이 파일 쪽 이름: 원래 이름 }, namespace, all }. import type·type 지정자는 뺀다
27
+ const FROM_STMT = /^[ \t]*(import|export)\s+(type\s+)?([\w$*{}\s,]*?)\s*from\s*(['"])(\.[^'"]+)\4/gm;
28
+ export function importsOf(text) {
29
+ const out = [];
30
+ for (const m of text.matchAll(FROM_STMT)) {
31
+ if (m[2]) continue;
32
+ const clause = m[3].trim();
33
+ const st = { kind: m[1], spec: m[5], names: [], as: {}, namespace: null, all: false };
34
+ const braces = clause.match(/\{([^}]*)\}/);
35
+ if (braces) {
36
+ for (const part of braces[1].split(',')) {
37
+ const p = part.trim();
38
+ if (!p || /^type\s/.test(p)) continue;
39
+ const [orig, local = orig] = p.split(/\s+as\s+/).map((x) => x.trim());
40
+ st.names.push(orig);
41
+ st.as[local] = orig;
42
+ }
43
+ }
44
+ const rest = clause.replace(/\{[^}]*\}/, '').split(',').map((s) => s.trim()).filter(Boolean);
45
+ for (const r of rest) {
46
+ const ns = r.match(/^\*\s*as\s+([\w$]+)$/);
47
+ if (ns) { st.namespace = ns[1]; st.all = true; }
48
+ else if (r === '*') st.all = true;
49
+ else if (m[1] === 'import' && /^[\w$]+$/.test(r)) { st.names.push('default'); st.as[r] = 'default'; }
50
+ }
51
+ out.push(st);
52
+ }
53
+ return out;
54
+ }
55
+
56
+ // 최상위 선언: 열 0에서 시작하는 선언 줄부터 다음 선언 줄 앞까지. [{ name, start, end }] (0부터 센 줄, end 제외)
57
+ const DECL = /^(?:export\s+)?(?:default\s+)?(?:declare\s+)?(?:async\s+)?(?:function\*?|const|let|var|class|interface|type|enum)\s+([\w$]+)/;
58
+ const BOUNDARY = /^(?:export|import|function|async\s+function|const|let|var|class|interface|type\s+[\w$]+|enum|declare)\b/;
59
+ export function topLevelDeclarations(text) {
60
+ const lines = text.split('\n');
61
+ const starts = [];
62
+ lines.forEach((l, i) => { if (BOUNDARY.test(l)) starts.push(i); });
63
+ const out = [];
64
+ starts.forEach((s, k) => {
65
+ const m = lines[s].match(DECL);
66
+ const def = /^export\s+default\b/.test(lines[s]);
67
+ if (!m && !def) return;
68
+ out.push({ name: def ? 'default' : m[1], alias: def && m ? m[1] : null, start: s, end: k + 1 < starts.length ? starts[k + 1] : lines.length });
69
+ });
70
+ return out;
71
+ }
72
+
73
+ // 리터럴 추출 닫힘: [{ file, ranges: null(파일 전체) | [[start, end]] }]. 파일은 방문 순서, 범위는 줄 순
74
+ export function literalSources(fs, page, { localDirs, appDir, depth = 3 }) {
75
+ const whole = new Set();
76
+ const parsed = new Map();
77
+ const imports = (f) => { if (!parsed.has(f)) parsed.set(f, importsOf(fs.read(f))); return parsed.get(f); };
78
+ const queue = [];
79
+ const want = (file, names) => queue.push([file, names]);
80
+ const inApp = (f) => !appDir || appDir === '.' || f.startsWith(appDir + '/');
81
+
82
+ const walk = (file, d) => {
83
+ if (whole.has(file) || d < 0) return;
84
+ whole.add(file);
85
+ const text = fs.read(file);
86
+ for (const st of imports(file)) {
87
+ const t = resolveLocal(fs, file, st.spec);
88
+ if (!t) continue;
89
+ if (inDirs(t, localDirs)) { walk(t, d - 1); continue; }
90
+ if (!inApp(t)) continue;
91
+ // 네임스페이스 import는 파일에서 ns.이름으로 쓴 이름만, export *는 이름을 모르므로 넣지 않는다
92
+ const names = st.namespace ? [...new Set([...text.matchAll(new RegExp(`\\b${st.namespace.replace(/\$/g, '\\$')}\\.([\\w$]+)`, 'g'))].map((m) => m[1]))] : st.names;
93
+ if (names.length) want(t, names);
94
+ }
95
+ };
96
+ walk(page, depth);
97
+
98
+ // 부분 모듈: 가져온 이름의 최상위 선언(깊이 1)과 그 범위가 쓰는 같은 모듈의 다른 선언(깊이 2). 없는 이름은 재수출을 따라간다
99
+ const ranges = new Map();
100
+ const done = new Map();
101
+ while (queue.length) {
102
+ const [file, names] = queue.shift();
103
+ if (whole.has(file)) continue;
104
+ const seen = done.get(file) || new Set();
105
+ done.set(file, seen);
106
+ const todo = names.filter((n) => !seen.has(n));
107
+ if (!todo.length) continue;
108
+ for (const n of todo) seen.add(n);
109
+ const text = fs.read(file);
110
+ const lines = text.split('\n');
111
+ const decls = topLevelDeclarations(text);
112
+ const byName = new Map();
113
+ for (const dcl of decls) { if (!byName.has(dcl.name)) byName.set(dcl.name, dcl); if (dcl.alias && !byName.has(dcl.alias)) byName.set(dcl.alias, dcl); }
114
+ const picked = ranges.get(file) || new Map();
115
+ ranges.set(file, picked);
116
+ const add = (dcl) => picked.set(dcl.start, dcl);
117
+ const reexports = importsOf(text).filter((s) => s.kind === 'export');
118
+ for (const n of todo) {
119
+ const dcl = byName.get(n);
120
+ if (dcl) {
121
+ add(dcl);
122
+ const body = lines.slice(dcl.start, dcl.end).join('\n');
123
+ for (const other of decls) if (other !== dcl && other.name !== 'default' && new RegExp(`(^|[^\\w$.])${other.name.replace(/\$/g, '\\$')}(?![\\w$])`).test(body)) add(other);
124
+ continue;
125
+ }
126
+ for (const st of reexports) {
127
+ if (!(st.all || n in st.as)) continue;
128
+ const t = resolveLocal(fs, file, st.spec);
129
+ if (!t) continue;
130
+ if (inDirs(t, localDirs)) walk(t, 0);
131
+ else if (inApp(t)) want(t, [st.as[n] ?? n]);
132
+ }
133
+ }
134
+ }
135
+ const out = [...whole].map((file) => ({ file, ranges: null }));
136
+ for (const [file, picked] of ranges) {
137
+ if (whole.has(file) || !picked.size) continue;
138
+ out.push({ file, ranges: [...picked.values()].sort((a, b) => a.start - b.start).map((d) => [d.start, d.end]) });
139
+ }
140
+ return out;
141
+ }
142
+
143
+ export const appDirOf = (app) => dirname(app);
package/src/lib/graph.mjs CHANGED
@@ -1,6 +1,9 @@
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
+ // 넷째 인자를 주면 code·subject·anchors·resolutions(이슈 계약, src/lib/issues.mjs)를 더 싣는다. 세 인자 호출은 1.1.0 모양 그대로다.
5
+ import { issueDetail } from './issues.mjs';
6
+
4
7
  export const NODE_KINDS = ['journey', 'step', 'screen', 'api', 'function', 'table', 'migration', 'test', 'commit', 'decision', 'task', 'ledger', 'deploy', 'testreport', 'milestone', 'release'];
5
8
  export const EDGE_KINDS = ['has_step', 'shows', 'uses', 'calls', 'invokes', 'touches', 'covers', 'changes', 'refs', 'defines', 'contains', 'tracks'];
6
9
 
@@ -31,11 +34,12 @@ export class Graph {
31
34
  const to = this.key(kind, id);
32
35
  return this.edges.filter((e) => e.to === to && (!edgeKind || e.kind === edgeKind)).map((e) => this.nodes.get(e.from)).filter(Boolean);
33
36
  }
34
- // 어댑터가 발견한 문제를 기록한다. level 틀리면 throw해 그 어댑터가 failed가 된다. adapter는 runAdapter가 설정한 실행 중 이름.
35
- issue(level, label, message) {
37
+ // 어댑터가 발견한 문제를 기록한다. level·넷째 인자 형식이 틀리면 throw해 그 어댑터가 failed가 된다. adapter는 runAdapter가 설정한 실행 중 이름.
38
+ issue(level, label, message, detail) {
36
39
  if (level !== 'error' && level !== 'warn') throw new Error(`issue level은 'error' 또는 'warn': ${level}`);
37
40
  if (typeof label !== 'string' || typeof message !== 'string') throw new Error('issue label·message는 문자열');
38
- this.issues.push({ level, label, message, adapter: this.adapter });
41
+ const extra = detail == null ? {} : issueDetail(level, detail);
42
+ this.issues.push({ level, label, message, adapter: this.adapter, ...extra });
39
43
  }
40
44
  report(name, status, count, error = null) { this.adapters.push({ name, status, count, error }); }
41
45
  toJSON() {