@pghoya2956/livemap 1.2.0 → 1.4.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.
@@ -2,6 +2,8 @@
2
2
  // `## 마일스톤: 제목` 절은 release 노드(마일스톤, 1.x 임시 이름)이고, 항목의 `마일스톤` 키가 release → milestone contains 엣지가 된다.
3
3
  // 순서는 파일 안 위치다. 항목 order·자동 id는 항목 절만 센다. 장면·작업·선행은 문자열로만 남기고 해석(존재 확인)은 derive·check가 한다.
4
4
  // 완료일(completedAt)·결정 대기 시작일(waitingSince)은 로드맵 파일의 git 이력에서 계산한다.
5
+ import { parseSections } from '../lib/md-props.mjs';
6
+
5
7
  const KEYS = { id: 'id', 상태: 'status', '진행 방식': 'mode', 작업: 'tasks', 장면: 'scenes', 선행: 'deps', '결정 대기': 'waitingOn', '완료 기준': 'done', 마일스톤: 'milestone' };
6
8
  const RELEASE_KEYS = { id: 'id', 상태: 'status', 완료일: 'completedOn', 목표일: 'targetOn', '결정 대기': 'waitingOn' };
7
9
  const LISTS = new Set(['tasks', 'scenes', 'deps']);
@@ -9,29 +11,27 @@ const RELEASE_HEAD = /^마일스톤\s*[::]\s*(.*)$/;
9
11
  const SHALLOW = '얕은 클론: 완료일·결정 대기 시작일 생략';
10
12
 
11
13
  // 로드맵 본문 → { items, releases }. 이력의 옛 판도 같은 규칙으로 읽는다.
14
+ // 절 나누기·속성·목표 문장은 md 속성 파서가 읽고(`## 제목` 1단), 여기서는 키를 필드로 옮긴다.
15
+ // 1단이라 `###` 아래 줄은 그 절의 자식으로 빠진다. 옛 파서는 그것을 목표 문장과 속성에 섞었다.
12
16
  function parse(text) {
13
17
  const items = [], releases = [];
14
- for (const block of text.split(/^## /m).slice(1)) {
15
- const [head, ...lines] = block.split('\n');
16
- const rel = head.trim().match(RELEASE_HEAD);
18
+ const LIST_KEYS = Object.entries(KEYS).filter(([, v]) => LISTS.has(v)).map(([k]) => k);
19
+ for (const sec of parseSections(text, { listKeys: LIST_KEYS })) {
20
+ const rel = sec.title.match(RELEASE_HEAD);
17
21
  const keys = rel ? RELEASE_KEYS : KEYS;
18
22
  const props = rel
19
23
  ? { order: releases.length + 1, goal: '', status: '', completedOn: '', targetOn: '', waitingOn: '' }
20
24
  : { order: items.length + 1, goal: '', status: '', mode: '', tasks: [], scenes: [], deps: [], waitingOn: '', done: '', milestone: null };
21
- const goal = [];
22
- for (const line of lines) {
23
- const m = line.match(/^- ([^:]+):\s*(.*)$/);
24
- if (m && keys[m[1].trim()]) {
25
- const key = keys[m[1].trim()];
26
- const value = m[2].replace(/`/g, '').trim();
27
- props[key] = LISTS.has(key) ? value.split(/[,,]\s*/).map((x) => x.trim()).filter((x) => x && x !== '—') : key === 'milestone' ? value || null : value;
28
- } else if (!line.startsWith('- ') && line.trim()) goal.push(line.trim());
25
+ for (const [name, raw] of Object.entries(sec.props)) {
26
+ const key = keys[name];
27
+ if (!key) continue;
28
+ props[key] = LISTS.has(key) ? (Array.isArray(raw) ? raw : []) : key === 'milestone' ? (raw || null) : raw;
29
29
  }
30
- props.goal = goal.join(' ');
30
+ props.goal = sec.prose;
31
31
  const declared = props.id;
32
32
  delete props.id;
33
- if (rel) releases.push({ id: declared || `r${props.order}`, idMissing: !declared, title: rel[1].trim(), head, props });
34
- else items.push({ id: declared || `m${props.order}`, title: head.trim(), head, props });
33
+ if (rel) releases.push({ id: declared || `r${props.order}`, idMissing: !declared, title: rel[1].trim(), head: sec.title, props });
34
+ else items.push({ id: declared || `m${props.order}`, title: sec.title, head: sec.title, props });
35
35
  }
36
36
  return { items, releases };
37
37
  }
@@ -1,11 +1,76 @@
1
1
  import { join } from 'node:path';
2
2
  import { setReading } from '../lib/reading.mjs';
3
- // 검사 어댑터: tests/ 파일에서 test 노드를 만들고, 파일 안의 goto('/…')·'/api/…' 문자열로 screen·api를 덮는(covers) 엣지를 잇는다.
3
+ import { extractPathLiterals } from '../lib/literals.mjs';
4
+ // 검사 어댑터: tests/ 파일에서 test 노드를 만들고, 화면·API·DB 함수를 덮는(covers) 엣지를 잇는다.
5
+ // 화면은 두 길로 잇는다. 하나는 파일(로컬 import 닫힘 포함)의 경로 리터럴이고, 둘은 Playwright 단계 태그 `@<여정>/<단계>`다.
6
+ // 리터럴은 작은·큰따옴표와 백틱 템플릿을 같은 규칙으로 읽는다(`/r/${slug}` → `/r/:param`). 헬퍼가 돌려주는 템플릿도 닫힘에 들어와 잡힌다.
7
+ // 태그는 여정 파일의 그 단계가 가리키는 화면으로 푼다. 주소가 변수·환경 변수라 글자에 없는 검사는 이 길로만 이어진다.
8
+ const TAG = /tag\s*:\s*(\[[^\]]*\]|'[^']*'|"[^"]*"|`[^`]*`)/g;
9
+ const TAG_ITEM = /['"`]@([^'"`,\s]+)['"`]/g;
10
+ // 이동 호출: page.goto(…)와, 닫힘 안에서 .goto(를 부르는 헬퍼(진입 헬퍼 등)의 호출
11
+ const DEF = /(?:export\s+)?(?:async\s+)?function\s+(\w+)|(?:export\s+)?const\s+(\w+)\s*=/g;
12
+ // 이름 = 경로 값: 문자열·템플릿 하나, 문자열 배열, 경로를 돌려주는 화살표 함수
13
+ const CONST_PATH = /(?:export\s+)?const\s+(\w+)\s*=\s*(?:\([^)]*\)\s*=>\s*)?(['"`])(\/[^'"`\n]*)\2/g;
14
+ const CONST_LIST = /(?:export\s+)?const\s+(\w+)\s*=\s*\[([^\]]*)\]/g;
15
+
16
+ // 파일에서 이동 헬퍼 이름을 찾는다. 정의마다 본문 범위를 정해 그 안에서 .goto(를 부르면 이동으로 본다.
17
+ // 본문은 중괄호 블록이면 짝을 맞춰 자르고, 식 하나짜리 화살표 함수면 그 줄 끝까지다.
18
+ function bodyOf(text, from) {
19
+ const open = text.indexOf('{', from);
20
+ const nl = text.indexOf('\n', from);
21
+ if (open < 0 || (nl >= 0 && nl < open && !/^[\s)=>]*$/.test(text.slice(from, nl)))) return text.slice(from, nl < 0 ? text.length : nl);
22
+ let depth = 0;
23
+ for (let i = open; i < text.length; i += 1) {
24
+ if (text[i] === '{') depth += 1;
25
+ else if (text[i] === '}') { depth -= 1; if (depth === 0) return text.slice(open, i); }
26
+ }
27
+ return text.slice(open);
28
+ }
29
+
30
+ function navNames(text) {
31
+ const names = new Set(['goto']);
32
+ for (const m of text.matchAll(DEF)) {
33
+ const name = m[1] || m[2];
34
+ if (name && /\.goto\s*\(/.test(bodyOf(text, m.index + m[0].length))) names.add(name);
35
+ }
36
+ return names;
37
+ }
38
+
39
+ // 파일 안 상수 이름 → 경로 목록. 인자를 받아 경로를 돌려주는 헬퍼도 같은 표에 둔다.
40
+ function constPaths(text) {
41
+ const map = new Map();
42
+ for (const m of text.matchAll(CONST_PATH)) map.set(m[1], [m[3]]);
43
+ for (const m of text.matchAll(CONST_LIST)) {
44
+ const paths = [...m[2].matchAll(/(['"`])(\/[^'"`\n]*)\1/g)].map((x) => x[2]);
45
+ if (paths.length) map.set(m[1], paths);
46
+ }
47
+ return map;
48
+ }
49
+
50
+ // 리터럴 경로가 화면 id에 맞나. 조각 수가 같고, 화면의 `:param` 자리는 아무 조각이나 받는다.
51
+ export function matchesScreen(litPath, screenId) {
52
+ if (litPath === screenId) return true;
53
+ const a = litPath.split('/'), b = screenId.split('/');
54
+ if (a.length !== b.length) return false;
55
+ return b.every((seg, i) => seg === a[i] || (seg.startsWith(':') && a[i] !== ''));
56
+ }
57
+
58
+ // 여정 파일에서 단계 id → 화면 목록. 1.x는 JSON 한 파일이다(2.0.0에서 md 디렉터리로 바뀐다).
59
+ function stepScreens(fs, cfg) {
60
+ const map = new Map();
61
+ if (!cfg.semantic || !fs.has(cfg.semantic)) return map;
62
+ let sem;
63
+ try { sem = JSON.parse(fs.read(cfg.semantic)); } catch { return map; }
64
+ for (const j of sem.journeys || []) for (const s of j.steps || []) map.set(`${j.id}/${s.id}`, s.screens || []);
65
+ return map;
66
+ }
67
+
4
68
  export default function tests(g, fs, cfg) {
5
69
  const c = cfg.tests;
6
70
  const files = fs.walk(c.dir, (p) => /\.(test|spec)\.mjs$/.test(p));
7
71
  if (!files.length) return `검사 파일 없음: ${c.dir}`;
8
72
  const gate = new RegExp(c.gatePattern);
73
+ const steps = stepScreens(fs, cfg);
9
74
  // 검사 파일이 helpers를 거쳐 API를 부르는 경우가 많아, 같은 폴더 안 로컬 import를 닫힘으로 합쳐 본다.
10
75
  const closure = (file, depth = 2, seen = new Set()) => {
11
76
  if (seen.has(file) || depth < 0) return seen;
@@ -23,19 +88,44 @@ export default function tests(g, fs, cfg) {
23
88
  // 제목이 ${ 를 가진 템플릿 문자열이면 반복문으로 여러 번 등록될 수 있어 줄 수가 실제 개수보다 작을 수 있다.
24
89
  const templated = own.split('\n').flatMap((line, i) => (/^\s*(?:test|it)\(\s*`[^`]*\$\{/.test(line) ? [i + 1] : []));
25
90
  const id = f;
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(' });
91
+ // 단계 태그: test(…, { tag: ['@여정/단계'] }) 검사가 여러 단계를 지나면 여러 개다
92
+ const tags = [];
93
+ for (const m of own.matchAll(TAG)) for (const x of m[1].matchAll(TAG_ITEM)) if (!x[1].includes('${') && !tags.includes(x[1])) tags.push(x[1]);
94
+ 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), tags }, { file: f, line: 1, rule: 'tests:test(|it(' });
95
+ if (/tag\s*:\s*\[[^\]]*\$\{/.test(own)) setReading(node, 'tags', 'partial', `태그가 템플릿 문자열인 검사(${f})는 단계를 글자로 알 수 없다`);
27
96
  if (templated.length) setReading(node, 'count', 'partial', `제목이 템플릿 문자열인 호출(${f}:${templated.join(',')})은 반복 등록이면 실제 개수가 더 많다`);
97
+ // 화면: 이동 호출에 들어간 경로와 단계 태그가 가리키는 화면.
98
+ // 글자 어디에나 있는 주소를 세면 "닿지 않아야 한다"는 음성 단언의 주소까지 덮은 것으로 잡힌다.
99
+ // 이름 표는 닫힘 전체에서 모은다. 헬퍼 파일이 정의한 경로 상수·경로 헬퍼가 검사 파일의 이동 호출 인자로 들어온다
100
+ const texts = [...closure(f)].map((x) => fs.read(x));
101
+ const names = new Set(texts.flatMap((x) => [...navNames(x)]));
102
+ const consts = new Map(texts.flatMap((x) => [...constPaths(x)]));
103
+ const NAV = new RegExp(`\\b(${[...names].join('|')})\\s*\\(([\\s\\S]{0,300}?)\\)`, 'g');
104
+ const lits = [];
105
+ for (const text of texts) {
106
+ for (const m of text.matchAll(NAV)) {
107
+ const args = m[2];
108
+ for (const l of extractPathLiterals(args)) if (!l.open) lits.push(l);
109
+ for (const id of args.matchAll(/\b([A-Za-z_$][\w$]*)\b/g)) for (const p of consts.get(id[1]) || []) lits.push({ path: p });
110
+ }
111
+ }
112
+ const tagged = new Set(tags.flatMap((tag) => steps.get(tag) || []));
113
+ const unknown = steps.size ? tags.filter((tag) => !steps.has(tag)) : [];
114
+ if (unknown.length) {
115
+ g.issue('warn', '검사 태그', `${f}: 여정에 없는 단계 태그 ${unknown.join(', ')}`, {
116
+ code: 'tests.tag-unknown', subject: { kind: 'test', id }, anchors: [{ file: f, line: 1 }], resolutions: ['source'],
117
+ });
118
+ }
28
119
  for (const s of g.of('screen')) {
29
- const goto = s.id.replace(/:\w+/g, '');
30
- if ((goto.length > 1 && t.includes(`goto('${goto}`)) || (s.id === '/' && t.includes("goto('/')"))) g.link('test', id, 'covers', 'screen', s.id);
120
+ if (tagged.has(s.id) || lits.some((l) => matchesScreen(l.path, s.id))) g.link('test', id, 'covers', 'screen', s.id);
31
121
  }
32
122
  for (const a of g.of('api')) {
33
123
  const key = a.id.replace(':id', '');
34
124
  if (t.includes(`'${a.id}'`) || (a.id.includes(':id') && new RegExp(`'${key}[^']`).test(t))) g.link('test', id, 'covers', 'api', a.id);
35
125
  }
36
126
  // DB 함수 직접 호출(rpc('name') 또는 /rest/v1/rpc/name)
37
- for (const f of g.of('function')) {
38
- if (new RegExp(`rpc\\(\\s*['\"\`]${f.id}['\"\`]|/rpc/${f.id}\\b`).test(t)) g.link('test', id, 'covers', 'function', f.id);
127
+ for (const fn of g.of('function')) {
128
+ if (new RegExp(`rpc\\(\\s*['\"\`]${fn.id}['\"\`]|/rpc/${fn.id}\\b`).test(t)) g.link('test', id, 'covers', 'function', fn.id);
39
129
  }
40
130
  }
41
131
  return null;
@@ -0,0 +1,74 @@
1
+ // affected: 바뀐 파일 → 그 파일을 쓰는 화면 → 그 화면을 지나는 브라우저 검사(spec)를 고른다.
2
+ // 작업 중 피드백을 줄이는 용도이고 병합 전 전체 실행을 대신하지 않는다. node 검사는 고르지 않는다(화면과 이어지지 않는다).
3
+ // 화면 밖 코드(서버·DB·헬퍼·공용 라이브러리·설정)가 하나라도 바뀌면 무엇이 깨질지 좁힐 수 없어 전체 실행이다.
4
+ // 문서 경로(작업 문서·여정·로드맵·위키·캡처·판정)만 바뀐 변경은 브라우저 검사를 고르지 않는다.
5
+ import { buildGraph } from './cli.mjs';
6
+ import { docPaths } from './adapters/testreport.mjs';
7
+
8
+ const SPEC = /\.spec\.mjs$/;
9
+ const NODE_TEST = /\.test\.mjs$/;
10
+
11
+ // 바뀐 파일 목록(루트 기준). base가 있으면 그 커밋부터 HEAD까지, 없으면 작업트리와 HEAD의 차이(추적되지 않은 파일 포함)
12
+ export function changedFiles(fs, base) {
13
+ const out = new Set();
14
+ const add = (text) => { for (const line of String(text || '').split('\n')) { const p = line.trim(); if (p) out.add(p); } };
15
+ if (base) add(fs.git('diff', '--name-only', base, 'HEAD'));
16
+ else {
17
+ add(fs.git('diff', '--name-only', 'HEAD'));
18
+ add(fs.git('ls-files', '--others', '--exclude-standard'));
19
+ }
20
+ return [...out];
21
+ }
22
+
23
+ export async function affected({ root = process.cwd(), base = null } = {}) {
24
+ const log = console.log;
25
+ console.log = (...a) => console.error(...a); // 어댑터 진행 출력이 명령 줄에 섞이지 않게 한다
26
+ let built;
27
+ try { built = await buildGraph(root); } finally { console.log = log; }
28
+ const { g, fs, cfg } = built;
29
+ const docs = docPaths(cfg);
30
+ const isDoc = (f) => docs.some((d) => f === d || f.startsWith(`${d}/`));
31
+ const changed = changedFiles(fs, base);
32
+
33
+ // 화면 → 그 화면을 지나는 spec
34
+ const specsOf = new Map();
35
+ for (const e of g.toJSON().edges) {
36
+ if (e.kind !== 'covers' || !e.to.startsWith('screen:')) continue;
37
+ const spec = e.from.replace(/^test:/, '');
38
+ if (!SPEC.test(spec)) continue;
39
+ const id = e.to.replace(/^screen:/, '');
40
+ if (!specsOf.has(id)) specsOf.set(id, new Set());
41
+ specsOf.get(id).add(spec);
42
+ }
43
+ // 파일 → 화면
44
+ const screensOf = new Map();
45
+ for (const s of g.of('screen')) for (const f of s.props.files || []) {
46
+ if (!screensOf.has(f)) screensOf.set(f, new Set());
47
+ screensOf.get(f).add(s.id);
48
+ }
49
+
50
+ const specs = new Set();
51
+ const outside = [];
52
+ for (const f of changed) {
53
+ if (isDoc(f)) continue;
54
+ if (SPEC.test(f)) { specs.add(f); continue; }
55
+ // node 검사 파일은 브라우저 검사를 고르는 근거가 아니다(화면과 이어지지 않는다). 헬퍼는 아래 화면 밖으로 간다
56
+ if (cfg.tests?.dir && f.startsWith(`${cfg.tests.dir}/`) && NODE_TEST.test(f) && !f.startsWith(`${cfg.tests.dir}/helpers/`)) continue;
57
+ const screens = screensOf.get(f);
58
+ if (screens) { for (const id of screens) for (const spec of specsOf.get(id) || []) specs.add(spec); continue; }
59
+ outside.push(f);
60
+ }
61
+
62
+ const cmd = 'npx --no playwright test';
63
+ if (outside.length) {
64
+ return { all: true, specs: [], changed, reason: `화면 밖 파일 ${outside.length}: ${outside.slice(0, 3).join(', ')}${outside.length > 3 ? ' 외' : ''}`, command: cmd };
65
+ }
66
+ const list = [...specs].sort();
67
+ return { all: false, specs: list, changed, reason: list.length ? `바뀐 파일 ${changed.length}` : '바뀐 파일이 문서뿐', command: list.length ? `${cmd} ${list.join(' ')}` : null };
68
+ }
69
+
70
+ export function affectedText(r) {
71
+ if (r.all) return [`전체 실행: ${r.reason}`, r.command];
72
+ if (!r.specs.length) return [`고를 브라우저 검사 없음: ${r.reason}`];
73
+ return [`브라우저 검사 ${r.specs.length}건 (${r.reason})`, r.command];
74
+ }
package/src/check.mjs CHANGED
@@ -5,6 +5,8 @@ import { problem } from './lib/issues.mjs';
5
5
 
6
6
  // derive가 만든 완성 문장을 코드로 가른다. 맞는 규칙이 없으면 마지막 대체 코드
7
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
+ // 로드맵 problem 중 경고로 내는 문장(1.3.0). 선행 흐름 문제라 화면은 멈추지 않고 CI도 막지 않는다
9
+ const ROADMAP_WARNING = [[/^선행 순환/, 'roadmap.dep-cycle'], [/^마일스톤 순서 역행/, 'roadmap.milestone-backward']];
8
10
  const ROADMAP_PROBLEM = [[/^장면 없음/, 'roadmap.scene-missing'], [/^작업 폴더 없음/, 'roadmap.task-missing'], [/^선행 항목 없음/, 'roadmap.dep-missing'], [/^알 수 없는 상태/, 'roadmap.unknown-status'], [/^마일스톤 없음/, 'roadmap.milestone-missing']];
9
11
  const MILESTONE_PROBLEM = [[/: id 없음$/, 'milestone.no-id'], [/^마일스톤 id 중복/, 'milestone.duplicate-id'], [/: 알 수 없는 상태 /, 'milestone.unknown-status'], [/: 날짜 형식 /, 'milestone.bad-date']];
10
12
  const MILESTONE_WARNING = [[/: 완료인데 미완료 항목/, 'milestone.done-open-items'], [/: 항목이 모두 완료인데 상태/, 'milestone.all-items-done'], [/: 진행 항목이 있는데 상태/, 'milestone.running-items'], [/: 묶인 항목 없음$/, 'milestone.no-items'], [/: 완료일과 상태가 맞지 않음$/, 'milestone.completed-on-mismatch']];
@@ -42,7 +44,11 @@ export function checkProblems(d, cfg) {
42
44
  for (const m of d.roadmap || []) {
43
45
  const ms = subj('milestone', m.id);
44
46
  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);
47
+ for (const p of m.problems) {
48
+ const w = pick(ROADMAP_WARNING, p, null);
49
+ if (w) warn(w, `로드맵 ${m.title}: ${p}`, ms);
50
+ else err(pick(ROADMAP_PROBLEM, p, 'roadmap.problem'), `로드맵 ${m.title}: ${p}`, ms);
51
+ }
46
52
  if (m.status === '진행' && !m.tasks.length) warn('roadmap.running-no-task', `로드맵 ${m.title}: 진행인데 작업 폴더가 없음`, ms);
47
53
  }
48
54
  // 마일스톤: 문장은 derive가 만든다. 마일스톤 절이 없으면 줄이 없다
package/src/cli.mjs CHANGED
@@ -90,6 +90,7 @@ const USAGE = `usage: livemap <command>
90
90
  serve [--port 4180] [--static <dir>] 로컬 뷰 http://127.0.0.1:<port>/map/
91
91
  export <dir> [--out map/.out] 화면·서체·캡처·생성물을 한 폴더에(먼저 build)
92
92
  init map/ 초안 파일·.gitignore·npm 스크립트·커밋 전 훅
93
+ affected [--base <ref>] 바뀐 화면을 지나는 브라우저 검사와 실행 명령
93
94
  test-report 단위 검사를 JUnit 리포트와 결과 JSON으로
94
95
  test-report --import <파일> [--sha <커밋>] Playwright JSON·JUnit·livemap 리포터 출력을 결과 JSON에
95
96
  --version 엔진 버전`;
@@ -142,7 +143,7 @@ export async function main(argv = []) {
142
143
 
143
144
  const root = resolve(opt('root', process.cwd()));
144
145
  const out = resolve(root, opt('out', 'map/.out'));
145
- if (!['build', 'check', 'serve', 'export', 'test-report'].includes(cmd)) { console.error(USAGE); return 2; }
146
+ if (!['build', 'check', 'serve', 'export', 'test-report', 'affected'].includes(cmd)) { console.error(USAGE); return 2; }
146
147
 
147
148
  const staticDir = cmd === 'serve' ? opt('static') : undefined;
148
149
  let cfg = null;
@@ -202,6 +203,12 @@ export async function main(argv = []) {
202
203
  const { exportSite } = await import('./serve.mjs');
203
204
  return exportSite({ root, out, captures: resolve(root, capturesDir(cfg)), target: resolve(process.cwd(), target) });
204
205
  }
206
+ if (cmd === 'affected') {
207
+ const { affected, affectedText } = await import('./affected.mjs');
208
+ const r = await affected({ root, base: opt('base', null) });
209
+ for (const line of affectedText(r)) console.log(line);
210
+ return 0;
211
+ }
205
212
  if (cmd === 'test-report') {
206
213
  const { testReport, importReport } = await import('./test-report.mjs');
207
214
  if (argv.includes('--import')) return importReport({ root, cfg, file: opt('import'), sha: opt('sha') });
package/src/derive.mjs CHANGED
@@ -39,6 +39,44 @@ export function splitWaiting(text) {
39
39
  return m ? { who: m[1].trim(), what: m[2] } : { who: null, what: t };
40
40
  }
41
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
+
42
80
  export function derive(g, sem, cfg, { captureExists }) {
43
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));
44
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');
@@ -181,6 +219,7 @@ export function derive(g, sem, cfg, { captureExists }) {
181
219
  const milestoneIds = new Set(milestones.map((m) => m.id));
182
220
  const releases = g.of('release').sort((a, b) => a.props.order - b.props.order);
183
221
  const releaseIds = new Set(releases.map((r) => r.id));
222
+ const flowProblems = roadmapFlowProblems(milestones, releases);
184
223
  const roadmap = milestones.map((m) => {
185
224
  const p = m.props;
186
225
  const scenes = p.scenes.map((r) => stepByRef[r] ? { ref: r, ...stepByRef[r] } : { ref: r, missing: true });
@@ -188,6 +227,7 @@ export function derive(g, sem, cfg, { captureExists }) {
188
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}`)];
189
228
  if (!ROADMAP_STATUS.includes(p.status)) problems.push(`알 수 없는 상태: ${p.status || '(비어 있음)'}`);
190
229
  if (p.milestone && !releaseIds.has(p.milestone)) problems.push(`마일스톤 없음 ${p.milestone}`);
230
+ problems.push(...(flowProblems.get(m.id) || []));
191
231
  const live = scenes.filter((s) => s.status === 'live').length;
192
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 }));
193
233
  const waiting = splitWaiting(p.waitingOn);
@@ -315,10 +355,16 @@ export function overviewSlice(d, opts = {}) {
315
355
  const currentMilestone = (milestones.find((m) => m.status === '진행') || milestones.find((m) => m.status === '다음'))?.id ?? null;
316
356
  const openItems = (d.roadmap || []).filter((r) => r.status !== '완료').sort((a, b) => a.order - b.order);
317
357
 
358
+ // 캡처: 기능마다 동작 단계의 캡처 파일을 단계 순서로 최대 5장. 중복 제거는 기능 안에서만 하고 전체 상한은 없다(1.3.0, DEC-23)
318
359
  const captures = [];
319
360
  for (const j of d.semantic.journeys) {
320
- const s = j.steps.find((x) => x.status === 'live' && x.captureFile && !captures.some((c) => c.file === x.captureFile));
321
- if (s && captures.length < 5) captures.push({ file: s.captureFile, journey: j.id, step: s.id });
361
+ const files = new Set();
362
+ for (const s of j.steps) {
363
+ if (files.size >= 5) break;
364
+ if (s.status !== 'live' || !s.captureFile || files.has(s.captureFile)) continue;
365
+ files.add(s.captureFile);
366
+ captures.push({ file: s.captureFile, journey: j.id, step: s.id });
367
+ }
322
368
  }
323
369
  const changes = human.slice().sort((a, b) => Date.parse(b.date) - Date.parse(a.date)).slice(0, 20).map((c) => {
324
370
  const touched = new Set(c.journeys.map((x) => x.journey));
@@ -57,6 +57,9 @@ export const ISSUE_CODES = {
57
57
  'roadmap.running-no-task': code('warn', '로드맵 항목', ['source']),
58
58
  'roadmap.running-no-milestone': code('warn', '로드맵 항목', ['source']),
59
59
  'roadmap.running-open-deps': code('warn', '로드맵 항목', ['source']),
60
+ // 1.3.0: 선행 흐름 경고
61
+ 'roadmap.dep-cycle': code('warn', '로드맵 항목', ['source']),
62
+ 'roadmap.milestone-backward': code('warn', '로드맵 항목', ['source']),
60
63
  'milestone.no-id': code('error', '마일스톤', ['source']),
61
64
  'milestone.duplicate-id': code('error', '마일스톤', ['source']),
62
65
  'milestone.unknown-status': code('error', '마일스톤', ['source']),
@@ -62,14 +62,19 @@ export function normalizeApiLiteral(raw, template = true) {
62
62
  return open ? { path, open: true } : { path };
63
63
  }
64
64
 
65
- // 글자에서 리터럴을 뽑는다: [{ path, open?, line }]. startLine은 text 첫 줄의 줄 번호
65
+ // 글자에서 API 리터럴을 뽑는다: [{ path, open?, line }]. startLine은 text 첫 줄의 줄 번호
66
66
  export function extractApiLiterals(text, startLine = 1) {
67
+ return extractPathLiterals(text, startLine, API_PREFIX);
68
+ }
69
+
70
+ // 같은 규칙으로 임의 접두어의 경로 리터럴을 뽑는다(화면 주소는 '/'). 백틱 템플릿의 ${…}는 :param이 된다
71
+ export function extractPathLiterals(text, startLine = 1, prefix = '/') {
67
72
  const out = [];
68
73
  let line = startLine;
69
74
  for (let i = 0; i < text.length; i += 1) {
70
75
  const ch = text[i];
71
76
  if (ch === '\n') { line += 1; continue; }
72
- if (!QUOTES.has(ch) || !text.startsWith(API_PREFIX, i + 1)) continue;
77
+ if (!QUOTES.has(ch) || !text.startsWith(prefix, i + 1)) continue;
73
78
  // 닫는 따옴표까지(백틱은 ${…} 안을 건너뛴다). 작은·큰따옴표는 줄을 넘지 않는다
74
79
  let j = i + 1, depth = 0;
75
80
  for (; j < text.length; j += 1) {
@@ -0,0 +1,78 @@
1
+ // md 속성 파서: 제목 2단(`## `/`### `)으로 나눈 절마다 `- 키: 값` 속성, 목표 문장, md 표를 읽는다.
2
+ // md 블록 읽개(md-blocks.mjs) 위에 올린다. 코드 펜스 안 줄은 블록이 아니므로 자동으로 빠진다.
3
+ //
4
+ // 절 { level, title, line, props, prose, tables, children }
5
+ // props `- 키: 값` — 값의 백틱은 뗀다. listKeys에 든 키는 쉼표(,·,)로 나누고 대시 한 칸(—·-)은 뺀다.
6
+ // prose 제목·목록·표가 아닌 줄을 공백으로 이어 붙인 것(절의 목표 문장)
7
+ // tables { header, rows } — 칸은 원문 그대로(백틱만 뗀다). 구분 행은 들어오지 않는다.
8
+ // children 한 단계 아래 절. 자식의 속성·문장은 부모에 섞이지 않는다.
9
+ //
10
+ // 경계: 키 이름을 필드로 옮기거나 값의 어휘를 판정하는 일은 부르는 어댑터가 한다. 이 모듈은 문서 모양만 읽는다.
11
+ import { readBlocks } from './md-blocks.mjs';
12
+
13
+ const KV = /^([^::]{1,40})[::]\s*(.*)$/;
14
+ const DASH = new Set(['—', '–', '-', '']);
15
+
16
+ const clean = (s) => String(s ?? '').replace(/`/g, '').trim();
17
+
18
+ const value = (key, raw, listKeys) => {
19
+ const v = clean(raw);
20
+ if (!listKeys.has(key)) return v;
21
+ return v.split(/[,,]/).map((x) => x.trim()).filter((x) => x && !DASH.has(x));
22
+ };
23
+
24
+ const emptySection = (level, title, line) => ({ level, title, line, props: {}, prose: '', tables: [], children: [] });
25
+
26
+ // 절 하나에 블록을 담는다. 표는 표 번호로 묶는다.
27
+ function put(section, block, listKeys, tables) {
28
+ if (block.type === 'item') {
29
+ const m = clean(block.text).match(KV);
30
+ if (m) { section.props[m[1].trim()] = value(m[1].trim(), m[2], listKeys); return; }
31
+ return;
32
+ }
33
+ if (block.type === 'row') {
34
+ let t = tables.get(block.table);
35
+ if (!t) { t = { header: null, rows: [] }; tables.set(block.table, t); section.tables.push(t); }
36
+ const cells = block.cells.map(clean);
37
+ if (block.header && !t.header) t.header = cells; else t.rows.push(cells);
38
+ return;
39
+ }
40
+ if (block.type === 'text') section.prose = section.prose ? `${section.prose} ${block.text.trim()}` : block.text.trim();
41
+ }
42
+
43
+ // text → 절 배열. { head: true }면 문서 하나({ title, props, prose, tables, children })로 돌려준다.
44
+ // title은 첫 `# ` 제목이고 그 앞뒤의 `- 키: 값`이 문서 속성이다.
45
+ export function parseSections(text, { listKeys = [], levels = [2, 3], head = false } = {}) {
46
+ const keys = new Set(listKeys);
47
+ const [top, sub] = levels;
48
+ const doc = emptySection(1, '', 0);
49
+ const sections = [];
50
+ const tablesOf = new Map(); // 절 → (표 번호 → 표)
51
+ let current = doc, parent = null;
52
+ const tables = (s) => { let m = tablesOf.get(s); if (!m) { m = new Map(); tablesOf.set(s, m); } return m; };
53
+
54
+ for (const block of readBlocks(text)) {
55
+ if (block.type === 'heading') {
56
+ if (block.level < top) { if (!doc.title) { doc.title = clean(block.text); doc.line = block.line; } current = doc; parent = null; continue; }
57
+ if (block.level === top) {
58
+ current = emptySection(top, clean(block.text), block.line);
59
+ parent = current;
60
+ sections.push(current);
61
+ continue;
62
+ }
63
+ if (block.level === sub && parent) {
64
+ current = emptySection(sub, clean(block.text), block.line);
65
+ parent.children.push(current);
66
+ continue;
67
+ }
68
+ continue; // 더 깊은 제목은 지금 절에 붙는 글로 본다
69
+ }
70
+ put(current, block, keys, tables(current));
71
+ }
72
+
73
+ if (!head) return sections;
74
+ doc.children = sections;
75
+ return doc;
76
+ }
77
+
78
+ export default parseSections;