@pghoya2956/livemap 1.1.0 → 1.2.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})$/;
@@ -46,36 +47,68 @@ export function derive(g, sem, cfg, { captureExists }) {
46
47
  const report = g.get('testreport', 'last')?.props || null;
47
48
  const short = (t) => t.split('.').pop();
48
49
  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);
50
+ // 등급 A: 덮는 검사 파일 하나가 최신 실행에서 검사 1개 이상·실패 0(파일 경로 정확 일치는 testreport 어댑터가 맞춘다)
51
+ 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
52
 
51
53
  // ---- 화면·API·함수 뷰 ----
52
54
  const screenView = screens.map((s) => ({
53
55
  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
56
  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: [],
57
+ steps: [], reading: s.props.reading || {},
56
58
  }));
57
59
  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
60
  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
61
  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 }));
62
+ 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
63
 
62
64
  // ---- 여정 해석 ----
63
65
  const byPath = Object.fromEntries(screenView.map((s) => [s.path, s]));
64
66
  const apiByPath = Object.fromEntries(apiView.map((a) => [a.path, a]));
65
67
  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; };
68
+ // 번호 참조: 정의 작업(definers) 하나면 작업(rule), 여럿이면 폴더 이름 작업(partial, tasks.ambiguous-ref).
69
+ // 정의 작업이 없는 노드(1.1.1 호환 줄에서만 만든 노드)는 1.1.1처럼 노드를 만든 작업으로 잇는다. <폴더>#<번호>는 작업의 정의만 본다
70
+ const defNode = Object.fromEntries(specDefs.map((d) => [d.id, d]));
71
+ const defOf = (d, task) => ({ task, file: d.props.file, done: d.props.done });
72
+ const resolveRef = (ref) => {
73
+ const q = ref.match(/^(\d{8}-[^\s#]+)#([A-Za-z]{1,4}[0-9]?-\d+)/);
74
+ if (q) {
75
+ const d = defNode[q[2].toUpperCase()];
76
+ 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));
77
+ return at ? { task: at.task, file: at.file, done: d.props.done, definers: d.props.definers || [], reading: 'rule' } : { qualified: true };
78
+ }
79
+ const m = ref.match(/^((?:DEC|PN|P\d)-\d+)/i) || ref.match(/^([A-Z]{1,4}[0-9]?-\d+)/);
80
+ if (!m) return null;
81
+ const d = defNode[m[1].toUpperCase()];
82
+ if (!d) return { id: m[1].toUpperCase() };
83
+ const definers = d.props.definers || [];
84
+ if (definers.length > 1) return { ...defOf(d, definers[0]), definers, reading: 'partial', ambiguous: m[1].toUpperCase() };
85
+ if (definers.length === 1) return { ...defOf(d, definers[0]), definers, reading: 'rule' };
86
+ const task = g.in('decision', d.id, 'defines')[0]?.id || null;
87
+ return task ? { ...defOf(d, task), definers, reading: 'rule' } : { id: d.id };
88
+ };
89
+ const seenIssue = new Set(g.issues.map((i) => `${i.code}|${i.subject?.id}|${i.message}`));
90
+ // 관측한 화면 호출(calls 엣지). 고아 API와 journey.api-not-observed는 이것만 센다
91
+ const observedApis = new Set(screenView.flatMap((s) => s.apis));
68
92
  const journeys = (sem.journeys || []).map((j) => {
69
93
  const steps = j.steps.map((st) => {
70
94
  const actor = st.actor || j.actor;
71
95
  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
96
  const apiSet = new Set(st.apis || []);
97
+ for (const a of st.apis || []) {
98
+ if (observedApis.has(a)) continue;
99
+ const message = `${j.title} › ${st.label}: 여정 apis ${a}를 부르는 화면이 관측되지 않음`;
100
+ const subject = { kind: 'step', id: `${j.id}/${st.id}` };
101
+ const key = `journey.api-not-observed|${subject.id}|${message}`;
102
+ if (seenIssue.has(key)) continue;
103
+ seenIssue.add(key);
104
+ g.issue('warn', '여정 API', message, { code: 'journey.api-not-observed', subject, anchors: cfg.semantic ? [{ file: cfg.semantic }] : [] });
105
+ }
73
106
  for (const p of st.screens || []) for (const a of byPath[p]?.apis || []) apiSet.add(a);
74
107
  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
108
  const fnNames = [...new Set(apiNodes.flatMap((a) => a.calls.filter((c) => !c.startsWith('auth:'))))];
76
109
  const functionNodes = fnNames.map((n) => ({ name: n, tables: fnView.find((f) => f.name === n)?.tables || [], tests: testsOf('function', n) }));
77
110
  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 }));
111
+ 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
112
  const taskNames = [...new Set(refNodes.map((r) => r.task).filter(Boolean))];
80
113
  const warnings = [];
81
114
  for (const n of screenNodes) {
@@ -83,7 +116,22 @@ export function derive(g, sem, cfg, { captureExists }) {
83
116
  else if (st.status === 'live' && n.source !== 'live') warnings.push(`장면은 동작인데 화면은 ${n.source}: ${n.path}`);
84
117
  else if ((st.status === 'planned' || st.status === 'next') && n.source === 'live') warnings.push(`장면은 ${st.status}인데 화면은 동작: ${n.path}`);
85
118
  }
86
- for (const r of refNodes) if (/^(DEC|PN|P\d)-/i.test(r.ref) && !r.task) warnings.push(`참조 미해결: ${r.ref}`);
119
+ for (const r of refNodes) {
120
+ if ((/^(DEC|PN|P\d)-/i.test(r.ref) || r._q) && !r.task) warnings.push(`참조 미해결: ${r.ref}`);
121
+ // 1.1.1이 인식하지 않던 접두어는 풀리지 않아도 경고(1.x 종료 코드 보호)
122
+ else if (r._id && !r.task) warnings.push(`번호 참조 대상 없음: ${r.ref}`);
123
+ if (r._amb) {
124
+ const d = defNode[r._amb];
125
+ const message = `${j.title} › ${st.label}: ${r._amb}을 정의한 작업 ${r.definers.length}개, 폴더 이름 순 첫 작업 ${r.task}로 이음`;
126
+ const subject = { kind: 'step', id: `${j.id}/${st.id}` };
127
+ const key = `tasks.ambiguous-ref|${subject.id}|${message}`;
128
+ if (!seenIssue.has(key)) {
129
+ seenIssue.add(key);
130
+ g.issue('warn', '여정 참조', message, { code: 'tasks.ambiguous-ref', subject, anchors: (d.props.definedAt || []).map((x) => ({ file: x.file, line: x.line })) });
131
+ }
132
+ }
133
+ delete r._q; delete r._id; delete r._amb;
134
+ }
87
135
  // 등급: D 주장 / C 관측 / B 검사 존재 / A 최신 커밋에서 통과
88
136
  const observed = screenNodes.length > 0 && screenNodes.every((n) => n.source === 'live');
89
137
  const covered = observed && testFiles.length > 0;
@@ -106,10 +154,9 @@ export function derive(g, sem, cfg, { captureExists }) {
106
154
 
107
155
  // ---- 고아·커버리지 ----
108
156
  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
157
  const orphans = {
111
158
  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),
159
+ apis: apiView.filter((a) => !observedApis.has(a.path)).map((a) => a.path),
113
160
  functions: fnView.filter((f) => !f.usedByApi).map((f) => f.name),
114
161
  tests: testView.filter((t) => !g.out('test', t.file, 'covers').length).map((t) => t.label),
115
162
  };
@@ -125,7 +172,7 @@ export function derive(g, sem, cfg, { captureExists }) {
125
172
  for (const c of commitView) for (const a of c.areas) areaCounts[a] = (areaCounts[a] || 0) + 1;
126
173
 
127
174
  // ---- 작업·장부·결정 ----
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));
175
+ 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
176
  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
177
  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
178
  // ---- 로드맵: 항목 순서대로 장면 상태·작업 단계를 붙인다. 해석 실패는 check가 오류로 막는다 ----
@@ -189,6 +236,8 @@ export function derive(g, sem, cfg, { captureExists }) {
189
236
  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
237
  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
238
  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),
239
+ // 답이 없는 잔여 질문 합계(폐기 제외, 못 읽은 작업은 0으로 더하고 읽기 상태가 partial을 알린다)
240
+ openQuestions: taskView.filter((t) => t.status !== '폐기').reduce((n, t) => n + (t.openQuestions || 0), 0),
192
241
  commits: commitView.length, warnings: journeys.reduce((n, j) => n + j.warnings, 0), orphans: Object.values(orphans).reduce((n, a) => n + a.length, 0),
193
242
  stepsLive: journeys.reduce((n, j) => n + j.counts.live, 0), stepsTotal: journeys.reduce((n, j) => n + j.steps.length, 0),
194
243
  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 +245,16 @@ export function derive(g, sem, cfg, { captureExists }) {
196
245
 
197
246
  return {
198
247
  schemaVersion: 1, generatedAt: new Date().toISOString(), project: sem.project || cfg.project, head, deploy: homelab, testreport: report,
248
+ // 결과 실행 목록(러너·출처·sha·시각·exit·최신 여부만, dirtyPaths는 싣지 않는다)
249
+ testRuns: report?.runs || [],
199
250
  adapters: g.toJSON().adapters, semantic: { actors: sem.actors || {}, statusLegend: sem.statusLegend || {}, journeys },
200
251
  summary, orphans, coverage, tasks: taskView, roadmap, ledger, decisions: decisionView, plans, commits: commitView, areaCounts,
201
252
  screens: screenView, apis: apiView, functions: fnView, migrations: migView, tests: testView,
202
253
  milestones: milestoneView, issues: g.issues.map((i) => ({ ...i })), sources: { semantic: cfg.semantic, roadmap: cfg.roadmap?.file ?? null },
254
+ // 읽기 상태 건수: 노드에 적힌 상태만 값별·필드별로 센다(적지 않은 필드는 rule로 보지만 세지 않는다)
255
+ readings: countReadings([...g.nodes.values()]),
256
+ // 판정 파일: 작업마다 판정 파일 경로·by·note와 적용·낡음·무효 항목 수(작업 폴더 이름 순). 파일 모양이 틀린 판정 파일은 issues에만 있다
257
+ 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
258
  };
204
259
  }
205
260
 
@@ -291,16 +346,23 @@ export function overviewSlice(d, opts = {}) {
291
346
  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
347
  roadmapDone: d.roadmap.filter((m) => m.status === '완료').length, roadmapTotal: d.roadmap.length,
293
348
  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 })),
349
+ 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
350
  signals: {
296
351
  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,
352
+ tests: d.testreport?.signal || (lastRun ? (lastRun.failures ? 'fail' : lastRun.fresh ? 'ok' : 'stale') : 'none'), lastRun,
298
353
  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
354
  warnings: d.summary.warnings, orphans: d.summary.orphans, gated: d.tests.filter((t) => t.gated).reduce((n, t) => n + t.count, 0),
300
355
  deployBehindAll: d.deploy?.behind ?? null,
301
356
  },
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 },
357
+ 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,
358
+ steps: stepCounts, journeys: nJourneys, journeysLive: d.semantic.journeys.filter((j) => j.status === 'live').length, tasksRunning: d.tasks.filter((t) => t.status === '진행').length,
359
+ // 개요 수치의 읽기 상태(값만, 경로·파일명 없음). 등급은 검사 결과 최신 여부와 화면→API 연결에 기댄다
360
+ reading: {
361
+ plans: combineReading(d.tasks.map((t) => readingOf(t, 'plan'))),
362
+ openQuestions: combineReading(d.tasks.map((t) => readingOf(t, 'openQuestions'))),
363
+ tests: combineReading(d.tests.map((t) => readingOf(t, 'count'))),
364
+ grades: combineReading([...d.tests.map((t) => readingOf(t, 'lastRun')), ...d.screens.map((x) => readingOf(x, 'apis'))]),
365
+ } },
304
366
  areas: Object.entries(d.areaCounts).sort((a, b) => b[1] - a[1]).slice(0, 6),
305
367
  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
368
  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() {
@@ -0,0 +1,161 @@
1
+ // 이슈 계약: 코드 표, g.issue 넷째 인자 검증, check 문제 정렬·JSON·묶음 줄·--strict.
2
+ // 코드 표의 뜻과 허용 처리는 docs/issue-codes.md가 정본이고, 이 표는 그 문서와 같은 코드 집합을 가진다(검사로 확인).
3
+ // 엔진은 원문과 판정 파일을 고치지 않는다. 처리(resolutions)는 이슈를 받은 에이전트 세션이 고르는 방법 목록이다.
4
+
5
+ export const RESOLUTIONS = ['source', 'judge', 'config', 'code', 'engine'];
6
+ export const ISSUE_SCHEMA = 1;
7
+ // 같은 새 코드가 이 수 이상이면 텍스트 출력에서 한 줄로 묶는다
8
+ export const GROUP_MIN = 6;
9
+ // --strict가 오류로 세는 경고의 코드 접두어
10
+ const STRICT_PREFIXES = ['tasks.', 'judgment.'];
11
+ const EXCERPT_MAX = 120;
12
+ const CODE_FORMAT = /^[a-z][a-z0-9]*(\.[a-z0-9]+(-[a-z0-9]+)*)+$/;
13
+
14
+ // level: 'error'|'warn'|null(부르는 쪽이 정함). target: 묶음 줄·문서에 쓰는 대상 이름. isNew: 1.2.0 새 코드(묶음 대상)
15
+ const code = (level, target, resolutions, isNew = false) => ({ level, target, resolutions, isNew });
16
+ export const ISSUE_CODES = {
17
+ // 1.2.0 새 코드
18
+ 'tasks.unread-definition': code('warn', '작업', ['source', 'judge'], true),
19
+ 'tasks.unread-checklist': code('warn', '작업', ['source', 'judge'], true),
20
+ 'tasks.stage-unknown': code('warn', '작업', ['source'], true),
21
+ 'tasks.questions-unknown': code('warn', '작업', ['source', 'judge'], true),
22
+ 'tasks.questions-open-done': code('warn', '작업', ['judge'], true),
23
+ 'tasks.index-section-unknown': code('warn', '장부', ['source'], true),
24
+ 'tasks.ambiguous-ref': code('warn', '단계', ['source'], true),
25
+ 'judgment.invalid': code('error', '판정 파일', ['judge'], true),
26
+ 'judgment.stale': code('warn', '판정 파일', ['judge'], true),
27
+ 'router.unknown-api': code('warn', '화면', ['code', 'config'], true),
28
+ 'router.hookapi-redundant': code('warn', '설정', ['config'], true),
29
+ 'router.hookapi-only': code('warn', '설정', ['code', 'config'], true),
30
+ 'journey.api-not-observed': code('warn', '단계', ['source', 'code'], true),
31
+ // 1.1.1 check 줄(문구는 그대로 두고 코드만 붙인다)
32
+ 'adapter.failed': code('error', '어댑터', ['code', 'engine']),
33
+ 'adapter.issue': code(null, '어댑터', []),
34
+ 'floor.below': code('error', '설정', ['code', 'config', 'engine']),
35
+ 'journey.duplicate-id': code('error', '여정', ['source']),
36
+ 'journey.no-steps': code('error', '여정', ['source']),
37
+ 'journey.actor-unknown': code('warn', '여정', ['source']),
38
+ 'step.duplicate-id': code('error', '여정', ['source']),
39
+ 'step.intent-empty': code('warn', '단계', ['source']),
40
+ 'step.route-missing': code('error', '단계', ['source', 'code']),
41
+ 'step.ref-unresolved': code('error', '단계', ['source']),
42
+ 'step.screen-not-live': code('error', '단계', ['source', 'code']),
43
+ 'step.screen-live-early': code('warn', '단계', ['source']),
44
+ 'step.no-evidence': code('error', '단계', ['source', 'code']),
45
+ 'step.review-stale': code('warn', '단계', ['source']),
46
+ 'step.warning': code(null, '단계', ['source']),
47
+ 'step.unknown-status': code('error', '단계', ['source']),
48
+ 'step.planned-has-screen': code('warn', '단계', ['source']),
49
+ 'step.capture-missing': code('warn', '단계', ['source']),
50
+ 'roadmap.duplicate-id': code('error', '로드맵 항목', ['source']),
51
+ 'roadmap.scene-missing': code('error', '로드맵 항목', ['source']),
52
+ 'roadmap.task-missing': code('error', '로드맵 항목', ['source']),
53
+ 'roadmap.dep-missing': code('error', '로드맵 항목', ['source']),
54
+ 'roadmap.unknown-status': code('error', '로드맵 항목', ['source']),
55
+ 'roadmap.milestone-missing': code('error', '로드맵 항목', ['source']),
56
+ 'roadmap.problem': code('error', '로드맵 항목', ['source']),
57
+ 'roadmap.running-no-task': code('warn', '로드맵 항목', ['source']),
58
+ 'roadmap.running-no-milestone': code('warn', '로드맵 항목', ['source']),
59
+ 'roadmap.running-open-deps': code('warn', '로드맵 항목', ['source']),
60
+ 'milestone.no-id': code('error', '마일스톤', ['source']),
61
+ 'milestone.duplicate-id': code('error', '마일스톤', ['source']),
62
+ 'milestone.unknown-status': code('error', '마일스톤', ['source']),
63
+ 'milestone.bad-date': code('error', '마일스톤', ['source']),
64
+ 'milestone.problem': code('error', '마일스톤', ['source']),
65
+ 'milestone.done-open-items': code('warn', '마일스톤', ['source']),
66
+ 'milestone.all-items-done': code('warn', '마일스톤', ['source']),
67
+ 'milestone.running-items': code('warn', '마일스톤', ['source']),
68
+ 'milestone.no-items': code('warn', '마일스톤', ['source']),
69
+ 'milestone.completed-on-mismatch': code('warn', '마일스톤', ['source']),
70
+ 'milestone.warning': code('warn', '마일스톤', ['source']),
71
+ 'milestone.multiple-running': code('warn', '마일스톤', ['source']),
72
+ 'orphan.screens': code('warn', '화면', ['source']),
73
+ 'orphan.apis': code('warn', 'API', ['code', 'source']),
74
+ 'orphan.tests': code('warn', '검사', ['code']),
75
+ 'deploy.behind-unknown': code('warn', '배포', ['config']),
76
+ };
77
+ export const NEW_CODES = new Set(Object.keys(ISSUE_CODES).filter((c) => ISSUE_CODES[c].isNew));
78
+
79
+ const isObj = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
80
+ const nonEmpty = (s) => typeof s === 'string' && s.length > 0;
81
+ // 근거 줄 조각: 120 코드 포인트까지(서로게이트 쌍을 가르지 않는다)
82
+ export const cutExcerpt = (s) => { const cp = [...s]; return cp.length > EXCERPT_MAX ? cp.slice(0, EXCERPT_MAX).join('') : s; };
83
+
84
+ // g.issue 넷째 인자를 검증해 기록할 필드를 돌려준다. 형식이 틀리면 throw(그 어댑터가 failed가 된다)
85
+ export function issueDetail(level, detail) {
86
+ if (!isObj(detail)) throw new Error('issue 넷째 인자는 객체');
87
+ for (const k of Object.keys(detail)) if (!['code', 'subject', 'anchors', 'resolutions', 'judgmentDraft'].includes(k)) throw new Error(`issue 넷째 인자에 모르는 키: ${k}`);
88
+ if (typeof detail.code !== 'string' || !CODE_FORMAT.test(detail.code)) throw new Error(`issue code 형식은 <영역>.<이름>(소문자·숫자·하이픈): ${detail.code}`);
89
+ const known = ISSUE_CODES[detail.code];
90
+ if (known?.level && known.level !== level) throw new Error(`issue code ${detail.code}의 수준은 ${known.level}: ${level}`);
91
+ let subject = null;
92
+ if (detail.subject != null) {
93
+ if (!isObj(detail.subject) || !nonEmpty(detail.subject.kind) || !nonEmpty(detail.subject.id)) throw new Error('issue subject는 { kind, id } 문자열');
94
+ subject = { kind: detail.subject.kind, id: detail.subject.id };
95
+ }
96
+ let anchors = [];
97
+ if (detail.anchors != null) {
98
+ if (!Array.isArray(detail.anchors)) throw new Error('issue anchors는 배열');
99
+ anchors = detail.anchors.map((a) => {
100
+ if (!isObj(a) || !nonEmpty(a.file)) throw new Error('issue anchor는 file 문자열을 가진 객체');
101
+ if (a.line != null && !(Number.isInteger(a.line) && a.line >= 1)) throw new Error(`issue anchor line은 1 이상 정수: ${a.line}`);
102
+ if (a.excerpt != null && typeof a.excerpt !== 'string') throw new Error('issue anchor excerpt는 문자열');
103
+ return a.excerpt == null ? { file: a.file, line: a.line ?? null } : { file: a.file, line: a.line ?? null, excerpt: cutExcerpt(a.excerpt) };
104
+ });
105
+ }
106
+ let resolutions = known ? [...known.resolutions] : [];
107
+ if (detail.resolutions != null) {
108
+ if (!Array.isArray(detail.resolutions) || !detail.resolutions.every((r) => RESOLUTIONS.includes(r))) throw new Error(`issue resolutions 값은 ${RESOLUTIONS.join('·')}: ${JSON.stringify(detail.resolutions)}`);
109
+ resolutions = [...detail.resolutions];
110
+ }
111
+ const out = { code: detail.code, subject, anchors, resolutions };
112
+ if (detail.judgmentDraft != null) {
113
+ if (!isObj(detail.judgmentDraft)) throw new Error('issue judgmentDraft는 객체');
114
+ out.judgmentDraft = detail.judgmentDraft;
115
+ }
116
+ return out;
117
+ }
118
+
119
+ // check 문제 하나: { level, code, msg, subject, anchors, resolutions[, judgmentDraft] }
120
+ export function problem(level, codeName, msg, { subject = null, anchors = [], resolutions } = {}) {
121
+ return { level, code: codeName, msg, subject, anchors, resolutions: resolutions ?? [...(ISSUE_CODES[codeName]?.resolutions || [])] };
122
+ }
123
+
124
+ // --strict: tasks.*·judgment.* 경고를 오류로 올린다(원래 목록은 바꾸지 않는다)
125
+ export function applyStrict(problems, strict) {
126
+ if (!strict) return problems;
127
+ return problems.map((p) => (p.level === 'warn' && STRICT_PREFIXES.some((x) => p.code.startsWith(x)) ? { ...p, level: 'error' } : p));
128
+ }
129
+
130
+ // 정렬: 수준(error 먼저) → 코드 → 첫 근거 파일 → 줄. 같으면 check 순서를 지킨다. 근거 없는 문제는 같은 코드 안에서 앞에 온다
131
+ const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
132
+ export function sortProblems(problems) {
133
+ return problems.map((p, i) => [p, i]).sort(([a, i], [b, j]) =>
134
+ cmp(a.level === 'error' ? 0 : 1, b.level === 'error' ? 0 : 1) || cmp(a.code, b.code)
135
+ || cmp(a.anchors[0]?.file ?? '', b.anchors[0]?.file ?? '') || cmp(a.anchors[0]?.line ?? 0, b.anchors[0]?.line ?? 0) || i - j).map(([p]) => p);
136
+ }
137
+
138
+ export function problemsJson(problems, engine) {
139
+ const errors = problems.filter((p) => p.level === 'error').length;
140
+ return { schema: ISSUE_SCHEMA, engine, errors, warnings: problems.length - errors, problems: sortProblems(problems) };
141
+ }
142
+
143
+ // 텍스트 줄: 1.1.1 줄은 그대로, 같은 새 코드가 GROUP_MIN건 이상이면 첫 자리에 한 줄로 묶는다
144
+ export function textLines(problems) {
145
+ const byCode = new Map();
146
+ for (const p of problems) if (NEW_CODES.has(p.code)) byCode.set(p.code, [...(byCode.get(p.code) || []), p]);
147
+ const lines = [], grouped = new Set();
148
+ for (const p of problems) {
149
+ const group = byCode.get(p.code);
150
+ if (group && group.length >= GROUP_MIN) {
151
+ if (grouped.has(p.code)) continue;
152
+ grouped.add(p.code);
153
+ const subjects = new Set(group.filter((x) => x.subject).map((x) => `${x.subject.kind}:${x.subject.id}`));
154
+ const sign = group.some((x) => x.level === 'error') ? '✗' : '△';
155
+ lines.push(`${sign} ${p.code} ${group.length}건(${ISSUE_CODES[p.code].target} ${subjects.size}): livemap check --json`);
156
+ continue;
157
+ }
158
+ lines.push(`${p.level === 'error' ? '✗' : '△'} ${p.msg}`);
159
+ }
160
+ return lines;
161
+ }