@pghoya2956/livemap 1.3.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  버전마다 `## [X.Y.Z] - YYYY-MM-DD` 절을 둔다. 릴리스 워크플로가 태그 버전의 절이 있는지 확인한다.
4
4
 
5
+ ## [1.4.0] - 2026-09-19
6
+
7
+ 작업 문서·검사 파일을 읽는 규칙을 넓히고 바뀐 화면의 검사를 고르는 명령을 더한다. 더하기만 하고 지우는 것은 없다.
8
+
9
+ ### 추가
10
+
11
+ - `livemap affected [--base <ref>]`: 바뀐 파일 → 그 파일을 쓰는 화면 → 그 화면을 지나는 브라우저 검사와 실행 명령. 화면 밖 코드가 섞이면 전체 실행, 문서 경로만 바뀌면 고를 검사 없음.
12
+ - 검사 어댑터가 Playwright 단계 태그 `@<여정>/<단계>`를 읽어 검사를 그 단계의 화면에 잇는다. 여정에 없는 태그는 `tests.tag-unknown` 경고이고, 템플릿 태그는 읽기 상태 `partial`이다.
13
+ - `src/lib/md-props.mjs`: 제목 2단 절마다 `- 키: 값`·목표 문장·표를 읽는 파서. 로드맵 어댑터가 이것을 쓴다.
14
+ - `src/lib/literals.mjs`에 `extractPathLiterals`(임의 접두어).
15
+
16
+ ### 고침
17
+
18
+ - 검사→화면 연결을 이동 호출(`page.goto`와 닫힘 안에서 goto를 부르는 헬퍼) 기준으로 좁혔다. 글자 어디에나 있는 주소를 세던 규칙은 "이 화면에는 닿지 않는다"는 음성 단언의 주소까지 덮은 것으로 잡았다.
19
+ - md 속성 파서가 NBSP·엔 스페이스·전각 공백을 일반 공백으로 맞춘다. 편집기가 `- 사용자 확인:` 뒤에 넣는 문자 때문에 키·값을 놓치던 자리다.
20
+
5
21
  ## [1.3.0] - 2026-09-19
6
22
 
7
23
  로드맵 화면이 항목 목록에서 선행 관계가 보이는 기술 트리로 바뀐다. 열은 자료가 정한다 — 로드맵 파일에 `## 마일스톤:` 절이 있으면 열이 마일스톤이고, 없으면 열이 선행 깊이다. 개요에서 기능을 고르면 화면 캡처 패널이 그 기능의 캡처만 돌린다. 고르기 전 첫 화면은 1.2.0과 같다. 1.2.0 생성물의 필드는 지우거나 이름을 바꾸지 않았고 새 설정 키도 없다. 값이 바뀌는 것은 `overview.json`의 `captures` 항목 수 하나다.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pghoya2956/livemap",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Project status board engine: scans a repository into a graph and serves a one-screen map of journeys, screens, APIs, tests and work.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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/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') });
@@ -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;