@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.
@@ -1,24 +1,120 @@
1
- // 검사 결과 어댑터: 마지막 검사 실행의 JUnit XML(map/.out/junit.xml)을 읽는다. 기준 커밋이 main HEAD와 같을 때만 "최신 검증"으로 친다.
2
- // 리포트는 `npm run test:report`가 남긴다. 없으면 등급 A를 없을 생성은 계속된다.
1
+ // 검사 결과 어댑터: 결과 JSON(tests.report 폴더의 test-results.json)을 먼저 읽고, 없으면 JUnit(tests.report)과 메타를 같은 해석기로 읽는다.
2
+ // 없으면 1.1.1 문구를 그대로 돌려준다(골든 1.0.1이 adapters[]·adapterNotes에서 위치로 비교한다).
3
+ // 최신(DEC-18): 실행 sha가 git 어댑터의 HEAD와 같거나 조상이고, 그 뒤 커밋과 실행 때 바뀐 경로(dirtyPaths)가 문서 경로 밖을 건드리지 않으면 최신이다.
4
+ // 문서 경로는 설정 키에서만 정한다: tasks.dir, wiki.index 폴더, semantic, roadmap.file, captures.site, deploy.manifest, map/judgments.
5
+ // 검사 노드(id = 파일 경로)와 결과 filePath를 그대로 맞춘다. 결과가 없거나 파일별 결과가 없는 검사의 lastRun 읽기 상태는 unknown이다.
6
+ import { posix } from 'node:path';
7
+ import { parseJunit, readResults, resultsPath, totalsOf } from '../results.mjs';
8
+ import { setReading } from '../lib/reading.mjs';
9
+
10
+ const NO_REPORT = '검사 리포트 없음(npm run test:report 미실행)';
11
+ const NO_HISTORY = '결과 커밋이 이력에 없음';
12
+
13
+ export function docPaths(cfg) {
14
+ const wikiDir = cfg.wiki?.index ? posix.dirname(cfg.wiki.index) : null;
15
+ const paths = [
16
+ cfg.tasks?.dir,
17
+ // 위키 인덱스가 루트에 있으면 폴더 대신 그 파일만 문서 경로로 본다(루트 전체가 문서가 되지 않게)
18
+ wikiDir && wikiDir !== '.' ? wikiDir : cfg.wiki?.index,
19
+ cfg.semantic, cfg.roadmap?.file, cfg.captures?.site, cfg.deploy?.manifest, 'map/judgments',
20
+ ];
21
+ return [...new Set(paths.filter((p) => typeof p === 'string' && p).map((p) => p.replace(/^\.\//, '').replace(/\/+$/, '')))].filter((p) => p && p !== '.');
22
+ }
23
+ const inDocs = (docs, p) => { const x = String(p).replace(/\/+$/, ''); return docs.some((d) => x === d || x.startsWith(d + '/')); };
24
+
25
+ // 실행 하나의 최신 여부와 낡은 이유
26
+ function freshness(run, fs, cfg, head) {
27
+ if (!run.sha || !head || !fs.hasGit()) return { fresh: false, note: NO_HISTORY };
28
+ const sha = fs.git('rev-parse', '--verify', '--quiet', `${run.sha}^{commit}`);
29
+ if (!sha) return { fresh: false, note: NO_HISTORY };
30
+ if (sha !== head && fs.git('merge-base', sha, head) !== sha) return { fresh: false, note: '결과 커밋이 HEAD의 조상이 아님' };
31
+ const docs = docPaths(cfg);
32
+ if (sha !== head) {
33
+ const n = Number(fs.git('rev-list', '--count', `${sha}..${head}`, '--', '.', ...docs.map((d) => `:(exclude)${d}`)) || 0);
34
+ if (n > 0) return { fresh: false, note: `결과 커밋 뒤 문서 경로 밖 변경 커밋 ${n}` };
35
+ }
36
+ // JUnit 메타처럼 dirtyPaths가 없는 실행은 이 조건을 건너뛴다
37
+ if (Array.isArray(run.dirtyPaths)) {
38
+ const out = run.dirtyPaths.filter((p) => !inDocs(docs, p));
39
+ if (out.length) return { fresh: false, note: `실행 때 문서 경로 밖 변경 ${out.length}(${out.slice(0, 3).join(', ')}${out.length > 3 ? ' …' : ''})` };
40
+ }
41
+ return { fresh: true, note: null };
42
+ }
43
+
44
+ const failedOf = (run) => (run.totals || totalsOf(run.files || [])).failed;
45
+ // 개요 신호: 최신 실행 중 실패가 있거나 exit가 0이 아니면 fail, 모두 최신이고 실패 0이면 ok, 최신이 아닌 실행이 있으면 stale, 실행이 없으면 none
46
+ export function testSignal(runs) {
47
+ if (!runs.length) return 'none';
48
+ const fresh = runs.filter((r) => r.fresh);
49
+ if (fresh.some((r) => failedOf(r) > 0 || (typeof r.exit === 'number' && r.exit !== 0))) return 'fail';
50
+ return fresh.length === runs.length ? 'ok' : 'stale';
51
+ }
52
+
3
53
  export default function testreport(g, fs, cfg) {
4
54
  const rel = cfg.tests.report;
5
- if (!fs.has(rel)) return '검사 리포트 없음(npm run test:report 미실행)';
6
- const xml = fs.read(rel);
7
- const suites = [...xml.matchAll(/<testsuite\b([^>]*)>/g)].map((m) => Object.fromEntries([...m[1].matchAll(/(\w+)="([^"]*)"/g)].map((a) => [a[1], a[2]])));
8
- const total = suites.reduce((n, s) => n + Number(s.tests || 0), 0);
9
- const failures = suites.reduce((n, s) => n + Number(s.failures || 0) + Number(s.errors || 0), 0);
10
- const skipped = suites.reduce((n, s) => n + Number(s.skipped || 0), 0);
11
- const metaFile = rel.replace(/\.xml$/, '.json');
12
- const meta = fs.has(metaFile) ? JSON.parse(fs.read(metaFile)) : {};
55
+ const tests = g.of('test');
13
56
  const head = g.get('deploy', 'head')?.props.full || null;
14
- // suite가 하나도 없는 리포트(러너가 파일을 찾은 경우 ) 통과 근거가 아니다.
15
- const fresh = total > 0 && !!(meta.sha && head && meta.sha === head);
16
- g.add('testreport', 'last', '마지막 검사', { total, failures, skipped, sha: meta.sha || null, at: meta.at || null, fresh, files: suites.map((s) => s.name) }, { file: rel, line: 1, rule: 'testreport:junit' });
17
- // 검사 파일 노드에 통과 여부를 붙인다(파일명 기준 매칭)
18
- for (const s of suites) {
19
- const t = g.of('test').find((x) => s.name && (s.name.includes(x.label) || s.name.includes(x.id)));
20
- if (t) { t.props.lastRun = { passed: Number(s.failures || 0) + Number(s.errors || 0) === 0, tests: Number(s.tests || 0), fresh }; }
57
+ const unknownAll = (note) => { for (const t of tests) setReading(t, 'lastRun', 'unknown', note); };
58
+ const resRel = resultsPath(cfg);
59
+ let runs;
60
+ let perFile = true;
61
+ if (fs.has(resRel)) {
62
+ try { runs = readResults(fs.abs(resRel)).runs; } catch (e) { unknownAll('결과 JSON을 읽지 못함'); return String(e.message).replace(fs.abs(resRel), resRel); }
63
+ if (!runs.length) { unknownAll('결과 JSON에 실행이 없음'); return '결과 JSON에 실행이 없음'; }
64
+ } else if (rel && fs.has(rel)) {
65
+ const metaFile = rel.replace(/\.xml$/, '.json');
66
+ let meta = {};
67
+ try { meta = fs.has(metaFile) ? JSON.parse(fs.read(metaFile)) : {}; } catch { meta = {}; }
68
+ const parsed = parseJunit(fs.read(rel), fs.ROOT);
69
+ // 메타에는 dirtyPaths가 없다(1.x 메타 모양)
70
+ runs = [{ runner: 'junit', source: rel, sha: meta.sha || null, at: meta.at || parsed.at || null, exit: typeof meta.exit === 'number' ? meta.exit : null, files: parsed.files, totals: parsed.totals }];
71
+ perFile = parsed.files.length > 0;
72
+ } else {
73
+ unknownAll('검사 결과 없음');
74
+ return NO_REPORT;
75
+ }
76
+
77
+ const judged = runs.map((run) => ({ ...run, ...freshness(run, fs, cfg, head) }));
78
+ // 파일마다 결과 하나: 최신 실행을 먼저, 같으면 나중 시각
79
+ const byFile = new Map();
80
+ for (const run of judged) {
81
+ for (const f of run.files || []) {
82
+ const cur = byFile.get(f.filePath);
83
+ const better = !cur || (run.fresh && !cur.run.fresh) || (run.fresh === cur.run.fresh && String(run.at || '') > String(cur.run.at || ''));
84
+ if (better) byFile.set(f.filePath, { run, file: f });
85
+ }
86
+ }
87
+ for (const t of tests) {
88
+ const hit = byFile.get(t.id);
89
+ if (!hit) { setReading(t, 'lastRun', 'unknown', perFile ? '결과에 이 검사 파일이 없음' : 'JUnit에 파일(file 속성)이 없어 파일별 결과 없음'); continue; }
90
+ const { run, file } = hit;
91
+ t.props.lastRun = {
92
+ passed: file.failed === 0, tests: file.tests, failed: file.failed, skipped: file.skipped, pending: file.pending,
93
+ ...(file.flaky !== undefined ? { flaky: file.flaky } : {}),
94
+ runner: run.runner, sha: run.sha || null, at: run.at || null, fresh: run.fresh, tags: file.tags || [],
95
+ };
96
+ t.props.runCount = file.tests;
97
+ if (run.fresh) {
98
+ t.props.count = file.tests;
99
+ setReading(t, 'count', 'observed');
100
+ setReading(t, 'lastRun', 'observed');
101
+ } else {
102
+ setReading(t, 'lastRun', 'stale', run.note);
103
+ }
104
+ }
105
+
106
+ const totals = judged.reduce((s, r) => { const x = r.totals || totalsOf(r.files || []); s.tests += x.tests; s.failed += x.failed; s.skipped += x.skipped; return s; }, { tests: 0, failed: 0, skipped: 0 });
107
+ const latest = judged.slice().sort((a, b) => String(b.at || '').localeCompare(String(a.at || '')))[0];
108
+ const signal = testSignal(judged);
109
+ g.add('testreport', 'last', '마지막 검사', {
110
+ total: totals.tests, failures: totals.failed, skipped: totals.skipped, sha: latest.sha || null, at: latest.at || null,
111
+ fresh: judged.every((r) => r.fresh), files: [...byFile.keys()].sort(), signal,
112
+ runs: judged.map((r) => ({ runner: r.runner, source: r.source ?? null, sha: r.sha || null, at: r.at || null, exit: r.exit ?? null, fresh: r.fresh })),
113
+ }, { file: fs.has(resRel) ? resRel : rel, line: 1, rule: fs.has(resRel) ? 'testreport:results' : 'testreport:junit' });
114
+ if (totals.tests === 0) return '리포트에 검사가 없음(러너 인자·경로 확인)';
115
+ if (signal === 'fail') {
116
+ const failed = judged.filter((r) => r.fresh).reduce((n, r) => n + failedOf(r), 0);
117
+ return failed ? `실패 ${failed}건` : `실행 종료 코드 ${judged.find((r) => r.fresh && typeof r.exit === 'number' && r.exit !== 0).exit}`;
21
118
  }
22
- if (total === 0) return '리포트에 검사가 없음(러너 인자·경로 확인)';
23
- return failures ? `실패 ${failures}건` : null;
119
+ return null;
24
120
  }
@@ -1,4 +1,5 @@
1
1
  import { join } from 'node:path';
2
+ import { setReading } from '../lib/reading.mjs';
2
3
  // 검사 어댑터: tests/ 파일에서 test 노드를 만들고, 파일 안의 goto('/…')·'/api/…' 문자열로 screen·api를 덮는(covers) 엣지를 잇는다.
3
4
  export default function tests(g, fs, cfg) {
4
5
  const c = cfg.tests;
@@ -19,8 +20,11 @@ export default function tests(g, fs, cfg) {
19
20
  const own = fs.read(f);
20
21
  const t = [...closure(f)].map(fs.read).join('\n');
21
22
  const count = (own.match(/^\s*(?:test|it)\(/gm) || []).length;
23
+ // 제목이 ${ 를 가진 템플릿 문자열이면 반복문으로 여러 번 등록될 수 있어 줄 수가 실제 개수보다 작을 수 있다.
24
+ const templated = own.split('\n').flatMap((line, i) => (/^\s*(?:test|it)\(\s*`[^`]*\$\{/.test(line) ? [i + 1] : []));
22
25
  const id = f;
23
- g.add('test', id, f.replace(`${c.dir}/`, '').replace(/\.(test|spec)\.mjs$/, ''), { kind: f.endsWith('.spec.mjs') ? 'e2e' : 'unit', count, gated: gate.test(own) }, { file: f, line: 1, rule: 'tests:test(|it(' });
26
+ const node = g.add('test', id, f.replace(`${c.dir}/`, '').replace(/\.(test|spec)\.mjs$/, ''), { kind: f.endsWith('.spec.mjs') ? 'e2e' : 'unit', count, gated: gate.test(own) }, { file: f, line: 1, rule: 'tests:test(|it(' });
27
+ if (templated.length) setReading(node, 'count', 'partial', `제목이 템플릿 문자열인 호출(${f}:${templated.join(',')})은 반복 등록이면 실제 개수가 더 많다`);
24
28
  for (const s of g.of('screen')) {
25
29
  const goto = s.id.replace(/:\w+/g, '');
26
30
  if ((goto.length > 1 && t.includes(`goto('${goto}`)) || (s.id === '/' && t.includes("goto('/')"))) g.link('test', id, 'covers', 'screen', s.id);
package/src/check.mjs CHANGED
@@ -1,56 +1,124 @@
1
1
  // 검증: 생성물 바이트 비교가 아니라 뜻(여정)과 사실(코드)의 정합을 본다.
2
2
  // error → CI 실패. warn → 화면에 뜨는 경고와 같은 것들.
3
- export function check(d, cfg) {
3
+ // 문제마다 이슈 계약 코드(docs/issue-codes.md)를 붙인다. 문구(msg) 순서는 1.1.1 텍스트 출력 그대로다.
4
+ import { problem } from './lib/issues.mjs';
5
+
6
+ // derive가 만든 완성 문장을 코드로 가른다. 맞는 규칙이 없으면 마지막 대체 코드
7
+ const STEP_WARNING = [[/^라우트 없음/, 'step.route-missing'], [/^참조 미해결/, 'step.ref-unresolved'], [/^장면은 동작인데/, 'step.screen-not-live'], [/^장면은 \S+인데 화면은 동작/, 'step.screen-live-early'], [/관측 근거 없음/, 'step.no-evidence'], [/^확인 필요/, 'step.review-stale']];
8
+ const ROADMAP_PROBLEM = [[/^장면 없음/, 'roadmap.scene-missing'], [/^작업 폴더 없음/, 'roadmap.task-missing'], [/^선행 항목 없음/, 'roadmap.dep-missing'], [/^알 수 없는 상태/, 'roadmap.unknown-status'], [/^마일스톤 없음/, 'roadmap.milestone-missing']];
9
+ const MILESTONE_PROBLEM = [[/: id 없음$/, 'milestone.no-id'], [/^마일스톤 id 중복/, 'milestone.duplicate-id'], [/: 알 수 없는 상태 /, 'milestone.unknown-status'], [/: 날짜 형식 /, 'milestone.bad-date']];
10
+ const MILESTONE_WARNING = [[/: 완료인데 미완료 항목/, 'milestone.done-open-items'], [/: 항목이 모두 완료인데 상태/, 'milestone.all-items-done'], [/: 진행 항목이 있는데 상태/, 'milestone.running-items'], [/: 묶인 항목 없음$/, 'milestone.no-items'], [/: 완료일과 상태가 맞지 않음$/, 'milestone.completed-on-mismatch']];
11
+ const pick = (rules, text, fallback) => rules.find(([re]) => re.test(text))?.[1] || fallback;
12
+
13
+ // 이슈 계약 모양의 문제 목록: { level, code, msg, subject, anchors, resolutions }
14
+ export function checkProblems(d, cfg) {
4
15
  const out = [];
5
- const err = (msg) => out.push({ level: 'error', msg });
6
- const warn = (msg) => out.push({ level: 'warn', msg });
16
+ const err = (code, msg, extra) => out.push(problem('error', code, msg, extra));
17
+ const warn = (code, msg, extra) => out.push(problem('warn', code, msg, extra));
18
+ const subj = (kind, id) => ({ subject: { kind, id: String(id) } });
7
19
 
8
- for (const a of d.adapters) if (a.status === 'failed') err(`어댑터 실패 ${a.name}: ${a.error}`);
20
+ for (const a of d.adapters) if (a.status === 'failed') err('adapter.failed', `어댑터 실패 ${a.name}: ${a.error}`, subj('adapter', a.name));
9
21
  const counts = { screen: d.summary.routes, api: d.summary.apis, function: d.summary.dbFunctions, test: d.tests.length, task: d.tasks.length, decision: d.decisions.length };
10
- for (const [k, floor] of Object.entries(cfg.floors || {})) if ((counts[k] ?? 0) < floor) err(`바닥값 미달 ${k}: ${counts[k] ?? 0} < ${floor} (스캐너가 깨졌을 가능성)`);
22
+ for (const [k, floor] of Object.entries(cfg.floors || {})) if ((counts[k] ?? 0) < floor) err('floor.below', `바닥값 미달 ${k}: ${counts[k] ?? 0} < ${floor} (스캐너가 깨졌을 가능성)`, subj('config', `floors.${k}`));
11
23
 
12
24
  const ids = new Set();
13
25
  for (const j of d.semantic.journeys) {
14
- if (ids.has(j.id)) err(`여정 id 중복: ${j.id}`); ids.add(j.id);
15
- if (!j.steps?.length) err(`여정에 장면 없음: ${j.id}`);
26
+ const js = subj('journey', j.id);
27
+ if (ids.has(j.id)) err('journey.duplicate-id', `여정 id 중복: ${j.id}`, js); ids.add(j.id);
28
+ if (!j.steps?.length) err('journey.no-steps', `여정에 장면 없음: ${j.id}`, js);
16
29
  const stepIds = new Set();
17
30
  for (const s of j.steps) {
18
- if (stepIds.has(s.id)) err(`${j.title}: 장면 id 중복 ${s.id}`); stepIds.add(s.id);
19
- if (!s.intent && s.status !== 'next') warn(`${j.title} ${s.label}: intent 비어 있음`);
20
- for (const w of s.warnings) (/라우트 없음|참조 미해결|장면은 동작인데|관측 근거 없음/.test(w) ? err : warn)(`${j.title} › ${s.label}: ${w}`);
21
- if (!['live', 'mock', 'planned', 'next'].includes(s.status)) err(`${j.title} › ${s.label}: 알 수 없는 상태 ${s.status}`);
22
- if (s.status === 'planned' && (s.screens || []).length) warn(`${j.title} › ${s.label}: planned 인데 화면이 있음(mock 맞는지 확인)`);
23
- if (s.capture && !s.captureFile) warn(`${j.title} › ${s.label}: 캡처 파일 없음 ${s.capture}`);
31
+ const ss = subj('step', `${j.id}/${s.id}`);
32
+ if (stepIds.has(s.id)) err('step.duplicate-id', `${j.title}: 장면 id 중복 ${s.id}`, js); stepIds.add(s.id);
33
+ if (!s.intent && s.status !== 'next') warn('step.intent-empty', `${j.title} › ${s.label}: intent 비어 있음`, ss);
34
+ for (const w of s.warnings) (/라우트 없음|참조 미해결|장면은 동작인데|관측 근거 없음/.test(w) ? err : warn)(pick(STEP_WARNING, w, 'step.warning'), `${j.title} › ${s.label}: ${w}`, ss);
35
+ if (!['live', 'mock', 'planned', 'next'].includes(s.status)) err('step.unknown-status', `${j.title} › ${s.label}: 없는 상태 ${s.status}`, ss);
36
+ if (s.status === 'planned' && (s.screens || []).length) warn('step.planned-has-screen', `${j.title} › ${s.label}: planned 인데 화면이 있음(mock 이 맞는지 확인)`, ss);
37
+ if (s.capture && !s.captureFile) warn('step.capture-missing', `${j.title} › ${s.label}: 캡처 파일 없음 ${s.capture}`, ss);
24
38
  }
25
39
  }
26
40
  // 로드맵: 가리키는 장면·작업·선행 항목이 없거나 상태 어휘가 틀리면 오류. 진행 중인데 작업 폴더가 없으면 경고.
27
41
  const mids = new Set();
28
42
  for (const m of d.roadmap || []) {
29
- if (mids.has(m.id)) err(`로드맵 id 중복: ${m.id}`); mids.add(m.id);
30
- for (const p of m.problems) err(`로드맵 ${m.title}: ${p}`);
31
- if (m.status === '진행' && !m.tasks.length) warn(`로드맵 ${m.title}: 진행인데 작업 폴더가 없음`);
43
+ const ms = subj('milestone', m.id);
44
+ if (mids.has(m.id)) err('roadmap.duplicate-id', `로드맵 id 중복: ${m.id}`, ms); mids.add(m.id);
45
+ for (const p of m.problems) err(pick(ROADMAP_PROBLEM, p, 'roadmap.problem'), `로드맵 ${m.title}: ${p}`, ms);
46
+ if (m.status === '진행' && !m.tasks.length) warn('roadmap.running-no-task', `로드맵 ${m.title}: 진행인데 작업 폴더가 없음`, ms);
32
47
  }
33
48
  // 마일스톤: 문장은 derive가 만든다. 마일스톤 절이 없으면 줄이 없다
34
- for (const m of d.milestones || []) { for (const p of m.problems) err(p); for (const w of m.warnings) warn(w); }
49
+ for (const m of d.milestones || []) {
50
+ const rs = subj('release', m.id);
51
+ for (const p of m.problems) err(pick(MILESTONE_PROBLEM, p, 'milestone.problem'), p, rs);
52
+ for (const w of m.warnings) warn(pick(MILESTONE_WARNING, w, 'milestone.warning'), w, rs);
53
+ }
35
54
  const running = (d.milestones || []).filter((m) => m.status === '진행');
36
- if (running.length > 1) warn(`진행 마일스톤 ${running.length}개: ${running.map((m) => m.title).join(', ')}`);
55
+ if (running.length > 1) warn('milestone.multiple-running', `진행 마일스톤 ${running.length}개: ${running.map((m) => m.title).join(', ')}`);
37
56
  for (const m of d.roadmap || []) {
38
57
  if (m.status !== '진행') continue;
39
- if ((d.milestones || []).length && !m.milestone) warn(`로드맵 ${m.title}: 진행인데 마일스톤 없음`);
58
+ const ms = subj('milestone', m.id);
59
+ if ((d.milestones || []).length && !m.milestone) warn('roadmap.running-no-milestone', `로드맵 ${m.title}: 진행인데 마일스톤 없음`, ms);
40
60
  const open = (m.deps || []).filter((x) => x.status !== '완료');
41
- if (open.length) warn(`로드맵 ${m.title}: 진행인데 선행 미완 ${open.map((x) => x.title).join(', ')}`);
61
+ if (open.length) warn('roadmap.running-open-deps', `로드맵 ${m.title}: 진행인데 선행 미완 ${open.map((x) => x.title).join(', ')}`, ms);
42
62
  }
43
63
  // 배우 사전: 여정 배우, 여정과 다른 단계 배우가 사전 키에 없으면 경고(사전이 없는 프로젝트는 건너뜀)
44
64
  const actors = d.semantic.actors || {};
45
65
  if (Object.keys(actors).length) for (const j of d.semantic.journeys) {
46
- if (j.actor && !(j.actor in actors)) warn(`${j.title}: 배우 사전에 없는 값 ${j.actor}`);
47
- for (const s of j.steps) if (s.actor && s.actor !== j.actor && !(s.actor in actors)) warn(`${j.title} › ${s.label}: 배우 사전에 없는 값 ${s.actor}`);
66
+ if (j.actor && !(j.actor in actors)) warn('journey.actor-unknown', `${j.title}: 배우 사전에 없는 값 ${j.actor}`, subj('journey', j.id));
67
+ for (const s of j.steps) if (s.actor && s.actor !== j.actor && !(s.actor in actors)) warn('journey.actor-unknown', `${j.title} › ${s.label}: 배우 사전에 없는 값 ${s.actor}`, subj('step', `${j.id}/${s.id}`));
68
+ }
69
+ if (d.orphans.screens.length) warn('orphan.screens', `여정에 없는 화면 ${d.orphans.screens.length}: ${d.orphans.screens.join(', ')}`);
70
+ if (d.orphans.apis.length) warn('orphan.apis', `어느 화면도 부르지 않는 API ${d.orphans.apis.length}: ${d.orphans.apis.join(', ')}`);
71
+ if (d.orphans.tests.length) warn('orphan.tests', `라우트·API에 붙지 않는 검사 ${d.orphans.tests.length}: ${d.orphans.tests.join(', ')}`);
72
+ if (d.deploy && d.deploy.behind === null) warn('deploy.behind-unknown', '배포 sha가 main 이력에 없어 뒤처짐을 계산하지 못함', subj('deploy', 'homelab'));
73
+ // 어댑터가 g.issue로 낸 문제: 기존 검사 뒤에 그대로 싣는다. error는 종료 코드 1에 센다. 코드 없이 낸 문제는 adapter.issue
74
+ for (const i of d.issues || []) {
75
+ const p = problem(i.level, i.code || 'adapter.issue', `${i.label}: ${i.message}`, { subject: i.subject ?? null, anchors: i.anchors || [], resolutions: i.resolutions || [] });
76
+ if (i.judgmentDraft) p.judgmentDraft = i.judgmentDraft;
77
+ out.push(p);
48
78
  }
49
- if (d.orphans.screens.length) warn(`여정에 없는 화면 ${d.orphans.screens.length}: ${d.orphans.screens.join(', ')}`);
50
- if (d.orphans.apis.length) warn(`어느 화면도 부르지 않는 API ${d.orphans.apis.length}: ${d.orphans.apis.join(', ')}`);
51
- if (d.orphans.tests.length) warn(`라우트·API에 붙지 않는 검사 ${d.orphans.tests.length}: ${d.orphans.tests.join(', ')}`);
52
- if (d.deploy && d.deploy.behind === null) warn('배포 sha가 main 이력에 없어 뒤처짐을 계산하지 못함');
53
- // 어댑터가 g.issue로 낸 문제: 기존 검사 뒤에 그대로 싣는다. error는 종료 코드 1에 센다.
54
- for (const i of d.issues || []) (i.level === 'error' ? err : warn)(`${i.label}: ${i.message}`);
55
79
  return out;
56
80
  }
81
+
82
+ // 1.1.1 모양({ level, msg })의 문제 목록. 텍스트 출력과 기존 호출자가 쓴다
83
+ export function check(d, cfg) {
84
+ return checkProblems(d, cfg).map(({ level, msg }) => ({ level, msg }));
85
+ }
86
+
87
+ // check --staged: 커밋 전 훅이 쓰는 고르기. 대상은 스테이징된 tasks.dir 안 작업 폴더 파일·장부(tasks.index)와 map/judgments/*.json이다.
88
+ // 대상 파일에 걸린 tasks.*·judgment.* 문제만 오류로 센다. 걸림은 문제 대상(작업·장부·판정 파일)이 스테이징된 작업 폴더·판정 파일이거나
89
+ // 근거 줄이 스테이징된 대상 파일에 있는 것이다. 대상이 단계(여정 refs)인 문제는 고칠 곳이 여정 파일이라 세지 않는다.
90
+ const STAGED_PREFIXES = ['tasks.', 'judgment.'];
91
+ const STAGED_SUBJECTS = ['task', 'ledger', 'judgment'];
92
+ const JUDGMENT_FILE = /^map\/judgments\/([^/]+)\.json$/;
93
+ const taskFolderOf = (path, dir) => { if (!dir || !path.startsWith(`${dir}/`)) return null; const m = path.slice(dir.length + 1).match(/^(\d{8}-[^/]+)\//); return m ? m[1] : null; };
94
+
95
+ export function stagedTargets(paths, cfg) {
96
+ const dir = cfg?.tasks?.dir ? cfg.tasks.dir.replace(/^\.\//, '').replace(/\/+$/, '') : null;
97
+ const index = cfg?.tasks?.index ? cfg.tasks.index.replace(/^\.\//, '') : null;
98
+ return paths.filter((p) => taskFolderOf(p, dir) || (index && p === index) || JUDGMENT_FILE.test(p));
99
+ }
100
+
101
+ export function stagedProblems(problems, paths, cfg) {
102
+ const targets = stagedTargets(paths, cfg);
103
+ const dir = cfg?.tasks?.dir ? cfg.tasks.dir.replace(/^\.\//, '').replace(/\/+$/, '') : null;
104
+ const files = new Set(targets);
105
+ const folders = new Set(targets.map((p) => taskFolderOf(p, dir)).filter(Boolean));
106
+ const judged = new Set(targets.map((p) => p.match(JUDGMENT_FILE)?.[1]).filter(Boolean));
107
+ return problems.filter((p) => STAGED_PREFIXES.some((x) => p.code.startsWith(x)) && STAGED_SUBJECTS.includes(p.subject?.kind) && (
108
+ p.anchors.some((a) => files.has(a.file))
109
+ || (p.subject.kind === 'task' && folders.has(p.subject.id))
110
+ || (p.subject.kind === 'judgment' && (judged.has(p.subject.id) || folders.has(p.subject.id)))
111
+ )).map((p) => ({ ...p, level: 'error' }));
112
+ }
113
+
114
+ // 훅 출력: 문제마다 코드·문구, 처리, 근거 줄, 판정 초안. 받은 에이전트 세션이 판정 파일을 쓰거나 원문을 고친다
115
+ export function stagedText(problems) {
116
+ const lines = [];
117
+ for (const p of problems) {
118
+ lines.push(`✗ ${p.code} ${p.msg}`);
119
+ if (p.resolutions.length) lines.push(` 처리: ${p.resolutions.join('·')}`);
120
+ for (const a of p.anchors) lines.push(` 근거: ${a.file}${a.line ? `:${a.line}` : ''}${a.excerpt ? ` ${a.excerpt}` : ''}`);
121
+ if (p.judgmentDraft) lines.push(` 판정 초안(judgmentDraft): ${JSON.stringify(p.judgmentDraft)}`);
122
+ }
123
+ return lines;
124
+ }
package/src/cli.mjs CHANGED
@@ -1,10 +1,13 @@
1
1
  // 프로젝트 상황판 CLI. 의존성 없음(Node 22). 명령 진입은 bin/livemap.mjs가 main(argv)를 부른다.
2
2
  // livemap build [--root .] [--out map/.out] 저장소 스캔 → graph.json·data.json·overview.json
3
- // livemap check [--root .] 검증(바닥값·라우트 존재·상태 모순·참조 미해결·어댑터 실패) → exit 1이면 실패
3
+ // livemap check [--root .] [--json] [--strict] 검증(바닥값·라우트 존재·상태 모순·참조 미해결·어댑터 실패) → exit 1이면 실패
4
+ // --json은 stdout에 이슈 계약 JSON만, --strict는 tasks.*·judgment.* 경고도 오류로 센다
5
+ // [--staged] 커밋 전 훅: 스테이징된 작업 문서·판정 파일에 걸린 tasks.*·judgment.*만 오류로 센다(git 없음·대상 없음 0)
4
6
  // livemap serve [--port 4180] [--static <dir>] loopback 서빙. 기본은 요청마다 재빌드(5초 캐시), --static은 export 폴더를 그대로 준다
5
7
  // livemap export <dir> [--out map/.out] 화면·서체·캡처·생성물을 /map/ 주소 배치 그대로 한 폴더에 모은다
6
- // livemap init 없는 파일만 템플릿으로 만들고 .gitignore·npm 스크립트를 넣는다
7
- // livemap test-report 단위 검사를 JUnit으로 남긴다(config.tests.dir config.tests.report)
8
+ // livemap init 없는 파일만 템플릿으로 만들고 .gitignore·npm 스크립트·커밋 전 훅(.githooks/pre-commit)을 넣는다
9
+ // livemap test-report [--import <파일> [--sha <커밋>]] 단위 검사를 JUnit 결과 JSON(config.tests.report 폴더의 test-results.json)으로 남긴다.
10
+ // --import는 러너를 돌리지 않고 livemap 리포터·Playwright JSON·JUnit 출력을 결과 JSON에 넣는다
8
11
  // livemap --version
9
12
  import { mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
10
13
  import { resolve, dirname, join } from 'node:path';
@@ -12,7 +15,10 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
12
15
  import { Graph, runAdapter } from './lib/graph.mjs';
13
16
  import { makeFs } from './lib/util.mjs';
14
17
  import { derive, overviewSlice } from './derive.mjs';
15
- import { check } from './check.mjs';
18
+ import { execFileSync } from 'node:child_process';
19
+ import { checkProblems, stagedTargets, stagedProblems, stagedText } from './check.mjs';
20
+ import { linkScreenApis } from './link.mjs';
21
+ import { applyStrict, problemsJson, sortProblems, textLines } from './lib/issues.mjs';
16
22
 
17
23
  const here = dirname(fileURLToPath(import.meta.url));
18
24
  export const PKG_ROOT = resolve(here, '..');
@@ -50,7 +56,10 @@ export async function buildGraph(root = process.cwd()) {
50
56
  if (loaded.shadowed) shadowed.push(name);
51
57
  runAdapter(g, name, (g) => loaded.fn(g, fs, cfg));
52
58
  }
53
- const sem = fs.has(cfg.semantic) ? JSON.parse(fs.read(cfg.semantic)) : { journeys: [] };
59
+ // 연결 단계: 모든 어댑터 뒤에 화면 리터럴을 API 노드에 잇는다. adapters[] 들지 않고, 실패하면 오류 이슈로 남긴다
60
+ try { linkScreenApis(g, fs, cfg); } catch (e) { g.issue('error', '연결 단계', String(e?.message || e)); }
61
+ // 설정에 semantic 키가 없으면 여정 입력이 없는 것으로 본다(없는 키는 뺀다)
62
+ const sem = cfg.semantic && fs.has(cfg.semantic) ? JSON.parse(fs.read(cfg.semantic)) : { journeys: [] };
54
63
  const captureExists = (id) => (id && fs.has(`${capturesDir(cfg)}/${id}.jpg`) ? `${id}.jpg` : null);
55
64
  const data = derive(g, sem, cfg, { captureExists });
56
65
  return { g, cfg, sem, data, fs, shadowed };
@@ -76,11 +85,13 @@ export function engineMismatch(cfg) {
76
85
 
77
86
  const USAGE = `usage: livemap <command>
78
87
  build [--root .] [--out map/.out] 저장소 스캔 → graph.json · data.json · overview.json
79
- check [--root .] 정합 검사, 오류가 있으면 exit 1
88
+ check [--root .] [--json] [--strict] 정합 검사, 오류가 있으면 exit 1(--json: 이슈 JSON만)
89
+ check --staged 커밋 전 훅: 스테이징된 작업 문서·판정 파일의 문제만 오류로
80
90
  serve [--port 4180] [--static <dir>] 로컬 뷰 http://127.0.0.1:<port>/map/
81
91
  export <dir> [--out map/.out] 화면·서체·캡처·생성물을 한 폴더에(먼저 build)
82
- init map/ 초안 파일·.gitignore·npm 스크립트
83
- test-report 단위 검사를 JUnit 리포트로
92
+ init map/ 초안 파일·.gitignore·npm 스크립트·커밋 전 훅
93
+ test-report 단위 검사를 JUnit 리포트와 결과 JSON으로
94
+ test-report --import <파일> [--sha <커밋>] Playwright JSON·JUnit·livemap 리포터 출력을 결과 JSON에
84
95
  --version 엔진 버전`;
85
96
 
86
97
  function readConfig(root) {
@@ -91,6 +102,37 @@ function readConfig(root) {
91
102
  }
92
103
 
93
104
  // 명령을 실행하고 종료 코드를 돌려준다. serve는 서버를 띄우고 undefined를 돌려준다(프로세스가 계속 산다).
105
+ // check --staged: 스테이징 목록(git diff --cached, 내용은 작업트리 기준)에 작업 문서·판정 파일이 없으면 빌드하지 않고 0
106
+ const STAGED_FAIL = '원문을 식별자 줄 규칙대로 고치거나 map/judgments/<작업 폴더>.json에 판정을 적어 스테이징하고 다시 커밋한다. --no-verify로 넘기지 않는다';
107
+ async function checkStaged(root, cfg, json) {
108
+ let paths;
109
+ try {
110
+ const out = execFileSync('git', ['-C', root, 'diff', '--cached', '--name-only', '--relative', '--no-renames', '-z'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
111
+ paths = out.split('\0').filter(Boolean);
112
+ } catch {
113
+ if (json) process.stdout.write(JSON.stringify(problemsJson([], VERSION), null, 2) + '\n');
114
+ else console.log('map check --staged: git 저장소 아님, 건너뜀');
115
+ return 0;
116
+ }
117
+ const targets = stagedTargets(paths, cfg);
118
+ if (!targets.length) {
119
+ if (json) process.stdout.write(JSON.stringify(problemsJson([], VERSION), null, 2) + '\n');
120
+ else console.log('map check --staged: 대상 없음');
121
+ return 0;
122
+ }
123
+ // 빌드 중 어댑터가 찍는 줄은 훅 출력에 섞지 않는다
124
+ const log = console.log;
125
+ console.log = (...a) => console.error(...a);
126
+ let built;
127
+ try { built = await buildGraph(root); } finally { console.log = log; }
128
+ const problems = sortProblems(stagedProblems(checkProblems(built.data, built.cfg), paths, built.cfg));
129
+ if (json) { process.stdout.write(JSON.stringify(problemsJson(problems, VERSION), null, 2) + '\n'); return problems.length ? 1 : 0; }
130
+ if (!problems.length) { console.log(`map check --staged: 통과 (대상 파일 ${targets.length})`); return 0; }
131
+ for (const line of stagedText(problems)) console.log(line);
132
+ console.log(`map check --staged: 오류 ${problems.length} (스테이징된 작업 문서·판정 파일의 tasks.*·judgment.* 문제). ${STAGED_FAIL}`);
133
+ return 1;
134
+ }
135
+
94
136
  export async function main(argv = []) {
95
137
  const cmd = argv[0] || 'build';
96
138
  const opt = (k, d) => { const i = argv.indexOf(`--${k}`); return i >= 0 ? argv[i + 1] : d; };
@@ -121,12 +163,24 @@ export async function main(argv = []) {
121
163
  for (const a of bad) console.log(` ${a.status === 'failed' ? '✗' : '△'} ${a.name}: ${a.error}`);
122
164
  return 0;
123
165
  }
166
+ if (cmd === 'check' && argv.includes('--staged')) return checkStaged(root, cfg, argv.includes('--json'));
124
167
  if (cmd === 'check') {
125
- const { data, cfg: c, shadowed } = await buildGraph(root);
126
- notifyShadow(shadowed);
127
- const problems = check(data, c);
128
- for (const p of problems) console.log(`${p.level === 'error' ? '✗' : '△'} ${p.msg}`);
168
+ const json = argv.includes('--json');
169
+ // --json: stdout에는 JSON만. 빌드 중 어댑터가 찍는 줄과 가림 알림은 stderr로 보낸다
170
+ const log = console.log;
171
+ if (json) console.log = (...a) => console.error(...a);
172
+ let built;
173
+ try { built = await buildGraph(root); } finally { console.log = log; }
174
+ const { data, cfg: c, shadowed } = built;
175
+ if (json) for (const n of shadowed) console.error(`프로젝트 어댑터가 참조 어댑터를 가림: ${n}`);
176
+ else notifyShadow(shadowed);
177
+ const problems = applyStrict(checkProblems(data, c), argv.includes('--strict'));
129
178
  const errors = problems.filter((p) => p.level === 'error').length;
179
+ if (json) {
180
+ process.stdout.write(JSON.stringify(problemsJson(problems, VERSION), null, 2) + '\n');
181
+ return errors ? 1 : 0;
182
+ }
183
+ for (const line of textLines(problems)) console.log(line);
130
184
  console.log(errors ? `map check: 오류 ${errors}` : `map check: 통과 (경고 ${problems.length})`);
131
185
  return errors ? 1 : 0;
132
186
  }
@@ -149,7 +203,8 @@ export async function main(argv = []) {
149
203
  return exportSite({ root, out, captures: resolve(root, capturesDir(cfg)), target: resolve(process.cwd(), target) });
150
204
  }
151
205
  if (cmd === 'test-report') {
152
- const { testReport } = await import('./test-report.mjs');
206
+ const { testReport, importReport } = await import('./test-report.mjs');
207
+ if (argv.includes('--import')) return importReport({ root, cfg, file: opt('import'), sha: opt('sha') });
153
208
  return testReport({ root, cfg });
154
209
  }
155
210
  return 2;