@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/CHANGELOG.md +143 -0
- package/README.md +24 -7
- package/budget/view-budget.spec.mjs +198 -0
- package/docs/adapter-contract.md +52 -4
- package/docs/issue-codes.md +159 -0
- package/docs/migrate.md +15 -0
- package/docs/semantic-authoring.md +158 -3
- package/docs/semantic-schema.md +65 -8
- package/docs/test-results.md +189 -0
- package/docs/view-budget.md +19 -1
- package/package.json +1 -1
- package/site/map.css +80 -0
- package/site/map.js +9 -9
- package/src/adapters/deploy.mjs +19 -8
- package/src/adapters/router.mjs +34 -22
- package/src/adapters/tasks.mjs +322 -29
- package/src/adapters/testreport.mjs +115 -19
- package/src/adapters/tests.mjs +5 -1
- package/src/check.mjs +102 -28
- package/src/cli.mjs +68 -13
- package/src/derive.mjs +124 -16
- package/src/init.mjs +52 -3
- package/src/lib/closure.mjs +143 -0
- package/src/lib/graph.mjs +7 -3
- package/src/lib/issues.mjs +164 -0
- package/src/lib/judgments.mjs +128 -0
- package/src/lib/literals.mjs +103 -0
- package/src/lib/md-blocks.mjs +113 -0
- package/src/lib/reading.mjs +55 -0
- package/src/link.mjs +76 -0
- package/src/reporters/node-results.mjs +33 -0
- package/src/results.mjs +187 -0
- package/src/test-report.mjs +47 -11
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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) =>
|
|
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)
|
|
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) => !
|
|
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
|
|
266
|
-
|
|
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
|
-
|
|
3
|
-
|
|
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
|
|
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
|
-
|
|
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() {
|