@pghoya2956/livemap 1.3.0 → 2.0.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,44 @@
2
2
 
3
3
  버전마다 `## [X.Y.Z] - YYYY-MM-DD` 절을 둔다. 릴리스 워크플로가 태그 버전의 절이 있는지 확인한다.
4
4
 
5
+ ## [2.0.0] - 2026-09-19
6
+
7
+ 여정 정본을 역할별 마크다운 디렉터리에서 읽고, 정본 자체의 어긋남과 정본↔검사 양방향을 검사한다.
8
+
9
+ ### 깨지는 변경
10
+
11
+ - 설정 `semantic`이 디렉터리를 가리키면 역할별 md를 읽는다. `.json` 한 파일도 그대로 읽으므로 1.x 프로젝트는 설정을 바꾸지 않으면 동작이 같다.
12
+ - 화면 주소·캡처·참조는 정본에 두지 않고 프로젝트 대응표(설정 `journeyScreens`, 기본 `map/journey-screens.json`)에서 읽는다.
13
+ - md 정본을 읽는 프로젝트에서 `step.no-test`가 오류다. 검사가 없는 단계는 대응표 `noTest`에 이유를 적어 면제한다.
14
+
15
+ ### 추가
16
+
17
+ - `src/lib/journeys-md.mjs`: README 표에서 배우 사전·상태 어휘, 역할 파일에서 여정·단계·상태·사용자 확인·하위 유형 시작 지점·넘겨받는 일·단계 넘김을 읽는다.
18
+ - 정본 검사 코드 다섯: `journey.subtype-unknown`·`journey.handoff-missing-step`·`journey.handoff-unpaired`·`journey.start-unknown`·`journey.doc-changed-after-review`.
19
+ - 양방향 코드 둘: `step.no-test`(정본 → 검사), `tests.route-not-in-journey`(검사 → 정본).
20
+ - `build`·`check`에 `--semantic <경로>`. 다른 판의 정본으로 산출을 재현할 때 쓴다.
21
+
22
+ ### 고침
23
+
24
+ - 화면 없이 API로만 도는 단계(알림 발송 등)는 선언한 API가 모두 코드에 있으면 관측으로 본다. 전에는 화면이 없으면 무조건 등급 D라 정본의 `동작` 주장이 오류가 됐다.
25
+ - 검사 어댑터가 단계 태그를 풀 때 여정 정본을 같은 읽개로 읽는다. `JSON.parse`만 하던 탓에 md 정본에서 태그 연결이 조용히 사라져 검사→화면 엣지가 줄던 자리다.
26
+
27
+ ## [1.4.0] - 2026-09-19
28
+
29
+ 작업 문서·검사 파일을 읽는 규칙을 넓히고 바뀐 화면의 검사를 고르는 명령을 더한다. 더하기만 하고 지우는 것은 없다.
30
+
31
+ ### 추가
32
+
33
+ - `livemap affected [--base <ref>]`: 바뀐 파일 → 그 파일을 쓰는 화면 → 그 화면을 지나는 브라우저 검사와 실행 명령. 화면 밖 코드가 섞이면 전체 실행, 문서 경로만 바뀌면 고를 검사 없음.
34
+ - 검사 어댑터가 Playwright 단계 태그 `@<여정>/<단계>`를 읽어 검사를 그 단계의 화면에 잇는다. 여정에 없는 태그는 `tests.tag-unknown` 경고이고, 템플릿 태그는 읽기 상태 `partial`이다.
35
+ - `src/lib/md-props.mjs`: 제목 2단 절마다 `- 키: 값`·목표 문장·표를 읽는 파서. 로드맵 어댑터가 이것을 쓴다.
36
+ - `src/lib/literals.mjs`에 `extractPathLiterals`(임의 접두어).
37
+
38
+ ### 고침
39
+
40
+ - 검사→화면 연결을 이동 호출(`page.goto`와 닫힘 안에서 goto를 부르는 헬퍼) 기준으로 좁혔다. 글자 어디에나 있는 주소를 세던 규칙은 "이 화면에는 닿지 않는다"는 음성 단언의 주소까지 덮은 것으로 잡았다.
41
+ - md 속성 파서가 NBSP·엔 스페이스·전각 공백을 일반 공백으로 맞춘다. 편집기가 `- 사용자 확인:` 뒤에 넣는 문자 때문에 키·값을 놓치던 자리다.
42
+
5
43
  ## [1.3.0] - 2026-09-19
6
44
 
7
45
  로드맵 화면이 항목 목록에서 선행 관계가 보이는 기술 트리로 바뀐다. 열은 자료가 정한다 — 로드맵 파일에 `## 마일스톤:` 절이 있으면 열이 마일스톤이고, 없으면 열이 선행 깊이다. 개요에서 기능을 고르면 화면 캡처 패널이 그 기능의 캡처만 돌린다. 고르기 전 첫 화면은 1.2.0과 같다. 1.2.0 생성물의 필드는 지우거나 이름을 바꾸지 않았고 새 설정 키도 없다. 값이 바뀌는 것은 `overview.json`의 `captures` 항목 수 하나다.
@@ -2,6 +2,18 @@
2
2
 
3
3
  어댑터는 저장소의 한 종류 사실을 읽어 그래프에 노드·엣지로 넣는 함수다. 화면은 어댑터를 모르고 파생 뷰만 읽으므로, 스택이 달라지면 어댑터만 바꾸면 된다.
4
4
 
5
+ ## 개요 조각 g.badge(label, text)
6
+
7
+ 어댑터가 개요 요약 줄에 한 조각을 얹는다. 엔진은 프로젝트의 빚·대장 어휘를 모르므로 값이 아니라 글자를 받는다.
8
+
9
+ ```js
10
+ g.badge('빚과 어긋남', `미선언 ${undeclared} · 직접 goto ${baseline}`);
11
+ ```
12
+
13
+ - 줄바꿈·연속 공백은 한 칸으로 접고 60자에서 자른다. 빈 글자를 주면 그 어댑터만 `failed`가 된다.
14
+ - 요약 줄의 기존 항목(장면·화면·커밋·미배포·경고) 뒤에 어댑터 실행 순서대로 붙는다. `data.json`·`overview.json`의 `badges[]`에도 `{ label, text, adapter }`로 실린다.
15
+ - 모르는 값은 0으로 적지 않는다. "측정 안 됨"이나 `?`로 적어 빈 값과 0을 가른다.
16
+
5
17
  ## 시그니처
6
18
 
7
19
  ```js
@@ -119,6 +119,15 @@ g.issue('warn', '작업 문서', '완료 작업에 닫히지 않은 잔여 질
119
119
  | `journey.duplicate-id` | error | 여정 | source | 여정 id 중복 |
120
120
  | `journey.no-steps` | error | 여정 | source | 여정에 장면 없음 |
121
121
  | `journey.actor-unknown` | warn | 여정 | source | 여정·장면 배우가 배우 사전에 없음 |
122
+ | `journey.subtype-unknown` | warn | 단계 | source | 단계의 `하는 사람`이 역할 파일 `## 하위 유형` 표에 없음(2.0.0, md 정본) |
123
+ | `semantic.empty` | error | 설정 | config·source | 설정 `semantic`이 있는데 여정이 0건(읽기가 조용히 비었을 때 막는다) |
124
+ | `step.no-test` | error | 단계 | source | 동작·목업 단계에 지나는 검사가 없음. 대응표 `noTest`에 이유를 적으면 면제(2.0.0) |
125
+ | `tests.route-not-in-journey` | warn | 검사 | source | 검사가 지나는데 어느 단계에도 없는 화면(2.0.0) |
126
+ | `journey.handoff-missing-step` | error | 단계 | source | 단계의 `**넘김**`이 가리키는 단계 ID가 없음(2.0.0) |
127
+ | `journey.handoff-unpaired` | warn | 단계 | source | 받는 역할 파일의 `## 넘겨받는 일`에 그 단계가 없음(2.0.0) |
128
+ | `journey.start-unknown` | error | 여정 문서 | source | 파일 머리·하위 유형 표의 `시작 지점`이 단계 ID가 아님(2.0.0) |
129
+ | `journey.doc-changed-after-review` | warn | 여정 문서 | source | 역할 파일이 `사용자 확인` 날짜 뒤에 바뀜(2.0.0) |
130
+ | `tests.tag-unknown` | warn | 검사 | source | 브라우저 검사의 단계 태그가 여정에 없는 단계를 가리킴 |
122
131
  | `step.duplicate-id` | error | 여정 | source | 한 여정 안에서 장면 id 중복 |
123
132
  | `step.intent-empty` | warn | 단계 | source | 장면 intent 비어 있음 |
124
133
  | `step.route-missing` | error | 단계 | source·code | 장면이 가리키는 라우트 없음 |
package/docs/migrate.md CHANGED
@@ -32,6 +32,6 @@
32
32
  | 1.0.1 잔여 필드(`line`, `running`, `waiting`, `tasks`, 최상위 `openQuestions`, `areas`, `recent`, `roadmap[]`) | 지운다 | `overview.json` 1.1.0 필드를 읽는다 |
33
33
  | 결정·계획 항목 노드 id | 전역 번호(`DEC-57`)에서 `<작업 폴더>#<번호>`로 | 여정 `refs`를 `<작업 폴더>#<번호>` 한정 참조로 적는다(`tasks.ambiguous-ref` 0) |
34
34
  | 노드 종류 이름 | `milestone` → `roadmapItem`, `release` → `milestone` | 프로젝트 어댑터·스크립트가 노드 종류 이름에 기대는 곳을 찾아 둔다 |
35
- | 여정 파일 | md 여정 어댑터를 더할 있다 | 없음 |
35
+ | 여정 파일 | 설정 `semantic`이 역할별 md 디렉터리를 가리킬있다. 화면·캡처·참조는 대응표(`journeyScreens`)로 옮긴다. `.json` 한 파일도 계속 읽는다 | 단계 ID를 `<여정>/<단계>`로 맞추고, 화면·캡처를 대응표로 옮길 준비를 한다 |
36
36
  | 읽기 상태 강제 | `partial`·`stale`·`unknown`을 기본으로 오류로 셀지 정한다 | `livemap check --strict`로 과거 작업 채우기가 끝났는지 본다 |
37
37
  | `config.json` `engine` | `2` | 2.0.0으로 올리는 커밋에서 바꾼다. 엔진은 major가 다르면 멈추고 이 문서를 가리킨다 |
@@ -1,6 +1,36 @@
1
1
  # 여정 파일 작성
2
2
 
3
- `map/semantic/journeys.json`(설정 `semantic`)은 제품의 뜻을 사람이 적는 파일이다. 스토리 맵의 backbone(배우가 하는 활동을 순서대로) 같다. 코드가 아니라 제품의 뜻을 적는 자리이므로 사용자 어휘만 쓰고 라우트·파일명은 `screens`·`capture` 필드에만 둔다.
3
+ 제품의 뜻을 사람이 적는 자리는 둘 중 하나다. 역할별 마크다운 디렉터리(2.0.0 권장)이거나 파일 하나(`journeys.json`, 1.x 호환)다. 설정 `semantic`이 디렉터리를 가리키면 앞, `.json`을 가리키면 뒤로 읽는다. 어느 쪽이든 스토리 맵의 backbone(배우가 하는 활동을 순서대로)이고, 사용자 어휘만 쓴다.
4
+
5
+ ## 역할별 마크다운(2.0.0)
6
+
7
+ 사람이 읽고 고치는 정본은 역할 파일이고, 화면 주소·캡처·참조 같은 구현 좌표는 프로젝트 대응표(설정 `journeyScreens`, 기본 `map/journey-screens.json`)에 둔다. 둘은 단계 ID(`<여정>/<단계>`)로 잇는다. 정본에 라우트·검사 이름을 적지 않으므로 구현이 바뀌어도 정본은 그대로다.
8
+
9
+ ```
10
+ docs/product/journeys/
11
+ README.md 역할 표(역할 → 파일)와 상태 어휘 표
12
+ diver.md `- 사용자 확인`·`- 시작 지점`, `## 하위 유형` 표, `## 넘겨받는 일`, `## 여정: <이름>` 절
13
+ ... 그 아래 `### <단계>` 절마다 `- id`·`- 상태`·`- 하는 사람`·`- 목적`, 필요하면 `**넘김**: <역할> \`<단계 ID>\``
14
+ ```
15
+
16
+ 상태 어휘는 동작·목업·미착수·다음이고 각각 `live`·`mock`·`planned`·`next`로 읽는다. 대응표는 이렇게 쓴다.
17
+
18
+ ```json
19
+ {
20
+ "lanes": { "booking": "다이버·강사 여정" },
21
+ "steps": {
22
+ "booking/deposit": { "screens": ["/pay/:id"], "capture": "deposit", "refs": ["20260914-booking#DEC-45"] },
23
+ "notify/delivery": { "apis": ["/api/me/notifications"], "noTest": "발송은 화면이 없다. 근거는 API와 여정 검사다" }
24
+ }
25
+ }
26
+ ```
27
+
28
+ `noTest`는 그 단계에 검사가 없는 이유이고, 적으면 `step.no-test` 오류에서 면제한다. 이유를 정본 본문이 아니라 대응표에 두는 것은 검사 사정이 바뀔 때 정본을 건드리지 않기 위해서다.
29
+
30
+ 정본 자체의 어긋남은 `check`가 본다: 하위 유형 어휘(`journey.subtype-unknown`), 넘김 짝(`journey.handoff-missing-step`·`journey.handoff-unpaired`), 시작 지점(`journey.start-unknown`), 사용자 확인 뒤 변경(`journey.doc-changed-after-review`). 코드 설명은 `issue-codes.md`에 있다.
31
+
32
+ ## 파일 하나(1.x)
33
+
4
34
 
5
35
  사람이 적는 곳은 넷이고 나머지는 생성기가 저장소를 스캔해 만든다.
6
36
 
@@ -10,7 +10,7 @@
10
10
 
11
11
  | 종류 | 출처 | 키 | 뜻 |
12
12
  |---|---|---|---|
13
- | journey | 손(journeys.json) | id | 한 배우가 한 목표를 이루는 흐름. 스토리 맵의 backbone 한 칸 |
13
+ | journey | 손(여정 정본: 역할별 md 디렉터리 또는 journeys.json) | id | 한 배우가 한 목표를 이루는 흐름. 스토리 맵의 backbone 한 칸 |
14
14
  | step | 손 | journey/step | 여정 안의 한 장면. intent(사용자가 원하는 것)·status·capture |
15
15
  | screen | 생성(라우터) | route path | 화면 하나. 페이지 파일, 데이터 출처(live/mock/mixed), 호출 API, 검사, 마지막 변경 |
16
16
  | api | 생성(BFF) | method+path | 서버 진입점. 호출하는 DB 함수·Auth |
@@ -30,7 +30,7 @@
30
30
  ## 엣지
31
31
 
32
32
  - journey → step (순서)
33
- - step → screen (shows): `screens: [route]`
33
+ - step → screen (shows): `screens: [route]`(md 정본은 대응표 `journeyScreens`의 `screens`)
34
34
  - step → api (uses): 명시(`apis`) 또는 screen을 거쳐 유도
35
35
  - screen → api (calls): 페이지와 그 import 닫힘의 `/api/` 문자열 리터럴을 모든 어댑터 뒤 연결 단계가 API 노드에 대응(1.2.0). 설정에 `router.hookApi`가 있으면 hook 이름 대응표로 이은 엣지도 더한다(2.0.0에서 폐기)
36
36
  - api → function (invokes): BFF 핸들러 블록의 `rpc/<name>`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pghoya2956/livemap",
3
- "version": "1.3.0",
3
+ "version": "2.0.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
+ import { readSemantic } from '../cli.mjs';
5
+ // 검사 어댑터: tests/ 파일에서 test 노드를 만들고, 화면·API·DB 함수를 덮는(covers) 엣지를 잇는다.
6
+ // 화면은 두 길로 잇는다. 하나는 파일(로컬 import 닫힘 포함)의 경로 리터럴이고, 둘은 Playwright 단계 태그 `@<여정>/<단계>`다.
7
+ // 리터럴은 작은·큰따옴표와 백틱 템플릿을 같은 규칙으로 읽는다(`/r/${slug}` → `/r/:param`). 헬퍼가 돌려주는 템플릿도 닫힘에 들어와 잡힌다.
8
+ // 태그는 여정 파일의 그 단계가 가리키는 화면으로 푼다. 주소가 변수·환경 변수라 글자에 없는 검사는 이 길로만 이어진다.
9
+ const TAG = /tag\s*:\s*(\[[^\]]*\]|'[^']*'|"[^"]*"|`[^`]*`)/g;
10
+ const TAG_ITEM = /['"`]@([^'"`,\s]+)['"`]/g;
11
+ // 이동 호출: page.goto(…)와, 닫힘 안에서 .goto(를 부르는 헬퍼(진입 헬퍼 등)의 호출
12
+ const DEF = /(?:export\s+)?(?:async\s+)?function\s+(\w+)|(?:export\s+)?const\s+(\w+)\s*=/g;
13
+ // 이름 = 경로 값: 문자열·템플릿 하나, 문자열 배열, 경로를 돌려주는 화살표 함수
14
+ const CONST_PATH = /(?:export\s+)?const\s+(\w+)\s*=\s*(?:\([^)]*\)\s*=>\s*)?(['"`])(\/[^'"`\n]*)\2/g;
15
+ const CONST_LIST = /(?:export\s+)?const\s+(\w+)\s*=\s*\[([^\]]*)\]/g;
16
+
17
+ // 파일에서 이동 헬퍼 이름을 찾는다. 정의마다 본문 범위를 정해 그 안에서 .goto(를 부르면 이동으로 본다.
18
+ // 본문은 중괄호 블록이면 짝을 맞춰 자르고, 식 하나짜리 화살표 함수면 그 줄 끝까지다.
19
+ function bodyOf(text, from) {
20
+ const open = text.indexOf('{', from);
21
+ const nl = text.indexOf('\n', from);
22
+ if (open < 0 || (nl >= 0 && nl < open && !/^[\s)=>]*$/.test(text.slice(from, nl)))) return text.slice(from, nl < 0 ? text.length : nl);
23
+ let depth = 0;
24
+ for (let i = open; i < text.length; i += 1) {
25
+ if (text[i] === '{') depth += 1;
26
+ else if (text[i] === '}') { depth -= 1; if (depth === 0) return text.slice(open, i); }
27
+ }
28
+ return text.slice(open);
29
+ }
30
+
31
+ function navNames(text) {
32
+ const names = new Set(['goto']);
33
+ for (const m of text.matchAll(DEF)) {
34
+ const name = m[1] || m[2];
35
+ if (name && /\.goto\s*\(/.test(bodyOf(text, m.index + m[0].length))) names.add(name);
36
+ }
37
+ return names;
38
+ }
39
+
40
+ // 파일 안 상수 이름 → 경로 목록. 인자를 받아 경로를 돌려주는 헬퍼도 같은 표에 둔다.
41
+ function constPaths(text) {
42
+ const map = new Map();
43
+ for (const m of text.matchAll(CONST_PATH)) map.set(m[1], [m[3]]);
44
+ for (const m of text.matchAll(CONST_LIST)) {
45
+ const paths = [...m[2].matchAll(/(['"`])(\/[^'"`\n]*)\1/g)].map((x) => x[2]);
46
+ if (paths.length) map.set(m[1], paths);
47
+ }
48
+ return map;
49
+ }
50
+
51
+ // 리터럴 경로가 화면 id에 맞나. 조각 수가 같고, 화면의 `:param` 자리는 아무 조각이나 받는다.
52
+ export function matchesScreen(litPath, screenId) {
53
+ if (litPath === screenId) return true;
54
+ const a = litPath.split('/'), b = screenId.split('/');
55
+ if (a.length !== b.length) return false;
56
+ return b.every((seg, i) => seg === a[i] || (seg.startsWith(':') && a[i] !== ''));
57
+ }
58
+
59
+ // 여정 정본에서 단계 id → 화면 목록. JSON 한 파일(1.x)과 md 디렉터리(2.0.0)를 같은 읽개로 받는다.
60
+ function stepScreens(fs, cfg) {
61
+ const map = new Map();
62
+ let sem;
63
+ try { sem = readSemantic(fs, cfg); } 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
@@ -20,9 +20,13 @@ export function checkProblems(d, cfg) {
20
20
  const subj = (kind, id) => ({ subject: { kind, id: String(id) } });
21
21
 
22
22
  for (const a of d.adapters) if (a.status === 'failed') err('adapter.failed', `어댑터 실패 ${a.name}: ${a.error}`, subj('adapter', a.name));
23
- 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 };
23
+ const stepCount = d.semantic.journeys.reduce((n, j) => n + (j.steps?.length || 0), 0);
24
+ 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, journey: d.semantic.journeys.length, step: stepCount };
24
25
  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}`));
25
26
 
27
+ // 정본 경로를 설정했는데 여정이 0건이면 읽기가 조용히 빈 것이다. 빈 자료는 모든 대조를 통과시키므로 오류로 막는다
28
+ if (d.semantic.readEmpty) err('semantic.empty', `여정 정본을 읽지 못함: ${d.semantic.readEmpty}`, subj('config', 'semantic'));
29
+
26
30
  const ids = new Set();
27
31
  for (const j of d.semantic.journeys) {
28
32
  const js = subj('journey', j.id);
@@ -72,6 +76,65 @@ export function checkProblems(d, cfg) {
72
76
  if (j.actor && !(j.actor in actors)) warn('journey.actor-unknown', `${j.title}: 배우 사전에 없는 값 ${j.actor}`, subj('journey', j.id));
73
77
  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}`));
74
78
  }
79
+ // 정본 → 검사: 동작·목업 단계는 지나는 검사가 있어야 한다. 대응표에 `noTest` 이유를 적으면 면제한다.
80
+ // 미착수·다음 단계는 아직 만들지 않은 것이라 검사가 없어도 오류가 아니다.
81
+ // md 정본(2.0.0)을 읽는 프로젝트에서만 돈다. 1.x JSON 여정은 이유를 적을 자리가 정해져 있지 않아 종료 코드가 바뀐다.
82
+ if ((d.semantic.roles || []).length) for (const j of d.semantic.journeys) {
83
+ for (const s of j.steps) {
84
+ if (s.status !== 'live' && s.status !== 'mock') continue;
85
+ if ((s.testFiles || []).length || s.noTest) continue;
86
+ err('step.no-test', `${j.title} › ${s.label}: 지나는 검사 없음(대응표에 이유를 적으면 면제)`, subj('step', `${j.id}/${s.id}`));
87
+ }
88
+ }
89
+ // 검사 → 정본: 검사가 지나는데 어느 단계에도 없는 라우트. 검사가 없는 고아 화면은 기존 orphan.screens가 센다
90
+ if ((d.semantic.roles || []).length) {
91
+ const testedOrphans = (d.orphans.screens || []).filter((path) => (d.screens.find((x) => x.path === path)?.tests || []).length);
92
+ if (testedOrphans.length) warn('tests.route-not-in-journey', `검사가 지나는데 여정에 없는 화면 ${testedOrphans.length}: ${testedOrphans.join(', ')}`);
93
+ }
94
+ // 여정 정본(md 디렉터리) 규칙: 하위 유형 어휘·넘김 짝·시작 지점·사용자 확인 뒤 변경.
95
+ // 역할 정보가 없는 프로젝트(1.x JSON 여정)에서는 건너뛴다.
96
+ const roles = d.semantic.roles || [];
97
+ if (roles.length) {
98
+ const stepIds = new Set(d.semantic.journeys.flatMap((j) => j.steps.map((s) => `${j.id}/${s.id}`)));
99
+ const byRole = new Map(roles.map((r) => [r.slug, r]));
100
+ const roleByLabel = new Map(roles.map((r) => [(actors[r.slug] || r.slug), r]));
101
+ const anchorsOf = (r) => [{ file: r.file, line: 1 }];
102
+ const allSubtypes = new Set(roles.flatMap((r) => (r.subtypes || []).map((x) => x.name)));
103
+ const ownerOf = new Map(d.semantic.journeys.flatMap((j) => j.steps.map((s) => [`${j.id}/${s.id}`, j.actor])));
104
+ for (const r of roles) {
105
+ const declared = allSubtypes;
106
+ const starts = [r.startStep, ...(r.subtypes || []).map((x) => x.start)].filter(Boolean);
107
+ for (const start of new Set(starts)) {
108
+ if (!stepIds.has(start)) err('journey.start-unknown', `${r.file}: 시작 지점이 단계 ID가 아님 ${start}`, { subject: { kind: 'journeyDoc', id: r.slug }, anchors: anchorsOf(r), resolutions: ['source'] });
109
+ }
110
+ if (r.changedAfterReview) {
111
+ warn('journey.doc-changed-after-review', `${r.file}: 사용자 확인(${r.reviewedAt}) 뒤에 바뀜`, { subject: { kind: 'journeyDoc', id: r.slug }, anchors: anchorsOf(r), resolutions: ['source'] });
112
+ }
113
+ // 하위 유형 어휘: 그 역할 파일의 단계가 쓰는 `하는 사람`이 표에 있어야 한다
114
+ if (declared.size) {
115
+ for (const j of d.semantic.journeys.filter((x) => x.actor === r.slug)) {
116
+ for (const s of j.steps) {
117
+ for (const name of s.subtypes || []) {
118
+ if (!declared.has(name)) warn('journey.subtype-unknown', `${j.title} › ${s.label}: 하위 유형 표에 없는 값 ${name}`, { subject: { kind: 'step', id: `${j.id}/${s.id}` }, anchors: anchorsOf(r), resolutions: ['source'] });
119
+ }
120
+ }
121
+ }
122
+ }
123
+ }
124
+ // 넘김 짝: 가리키는 단계가 있어야 하고, 받는 역할 파일의 `넘겨받는 일`에 그 단계가 적혀 있어야 한다
125
+ for (const j of d.semantic.journeys) {
126
+ for (const s of j.steps) {
127
+ for (const h of s.handoffs || []) {
128
+ const ss = { subject: { kind: 'step', id: `${j.id}/${s.id}` }, resolutions: ['source'] };
129
+ if (!stepIds.has(h.step)) { err('journey.handoff-missing-step', `${j.title} › ${s.label}: 넘김 대상 단계가 없음 ${h.step}`, ss); continue; }
130
+ const target = roleByLabel.get(h.role) || byRole.get(h.role);
131
+ if (!target) { warn('journey.handoff-unpaired', `${j.title} › ${s.label}: 넘김 받는 역할을 찾지 못함 ${h.role}`, ss); continue; }
132
+ if (ownerOf.get(h.step) === target.slug) continue; // 받는 역할이 소유한 단계는 자기 파일에 이미 있다
133
+ if (!(target.handoffs || []).includes(h.step)) warn('journey.handoff-unpaired', `${j.title} › ${s.label}: ${target.file}의 넘겨받는 일에 ${h.step}이 없음`, ss);
134
+ }
135
+ }
136
+ }
137
+ }
75
138
  if (d.orphans.screens.length) warn('orphan.screens', `여정에 없는 화면 ${d.orphans.screens.length}: ${d.orphans.screens.join(', ')}`);
76
139
  if (d.orphans.apis.length) warn('orphan.apis', `어느 화면도 부르지 않는 API ${d.orphans.apis.length}: ${d.orphans.apis.join(', ')}`);
77
140
  if (d.orphans.tests.length) warn('orphan.tests', `라우트·API에 붙지 않는 검사 ${d.orphans.tests.length}: ${d.orphans.tests.join(', ')}`);
package/src/cli.mjs CHANGED
@@ -14,6 +14,7 @@ import { resolve, dirname, join } from 'node:path';
14
14
  import { fileURLToPath, pathToFileURL } from 'node:url';
15
15
  import { Graph, runAdapter } from './lib/graph.mjs';
16
16
  import { makeFs } from './lib/util.mjs';
17
+ import { readJourneysDir } from './lib/journeys-md.mjs';
17
18
  import { derive, overviewSlice } from './derive.mjs';
18
19
  import { execFileSync } from 'node:child_process';
19
20
  import { checkProblems, stagedTargets, stagedProblems, stagedText } from './check.mjs';
@@ -45,9 +46,26 @@ async function loadAdapter(root, name) {
45
46
  return adapterCache.get(key);
46
47
  }
47
48
 
48
- export async function buildGraph(root = process.cwd()) {
49
+ // 여정 정본을 읽는다. 경로가 .json이면 그 파일, 아니면 디렉터리로 보고 역할 md를 읽는다.
50
+ // 디렉터리를 읽을 때 화면·캡처·참조는 프로젝트 대응표(설정 journeyScreens, 기본 map/journey-screens.json)에서 온다.
51
+ export function readSemantic(fs, cfg) {
52
+ const path = cfg.semantic;
53
+ if (!path || !fs.has(path)) return { journeys: [] };
54
+ if (path.endsWith('.json')) return JSON.parse(fs.read(path));
55
+ const mapFile = cfg.journeyScreens || 'map/journey-screens.json';
56
+ let map = {};
57
+ if (fs.has(mapFile)) { try { map = JSON.parse(fs.read(mapFile)); } catch (e) { throw new Error(`${mapFile} 읽기 실패: ${e.message}`); } }
58
+ const sem = readJourneysDir(fs, path, { map, project: cfg.project });
59
+ // 읽을 역할 파일이 있는데 여정이 0건이면 읽기가 조용히 빈 것이다(파일 없음·빈 폴더와 구분한다)
60
+ const roleFiles = fs.ls(path).filter((f) => f.endsWith('.md') && f !== 'README.md');
61
+ if (roleFiles.length && !sem.journeys.length) sem.readEmpty = `${path}에 역할 파일 ${roleFiles.length}개가 있는데 읽힌 여정이 0건`;
62
+ return sem;
63
+ }
64
+
65
+ export async function buildGraph(root = process.cwd(), { semantic = null } = {}) {
49
66
  const fs = makeFs(root);
50
67
  const cfg = JSON.parse(fs.read(CONFIG));
68
+ if (semantic) cfg.semantic = semantic; // --semantic: 다른 판의 여정 정본으로 산출을 재현할 때
51
69
  const g = new Graph();
52
70
  const shadowed = [];
53
71
  for (const name of cfg.adapters || DEFAULT_ADAPTERS) {
@@ -58,15 +76,15 @@ export async function buildGraph(root = process.cwd()) {
58
76
  }
59
77
  // 연결 단계: 모든 어댑터 뒤에 화면 리터럴을 API 노드에 잇는다. adapters[]에 들지 않고, 실패하면 오류 이슈로 남긴다
60
78
  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: [] };
79
+ // 여정 정본: 디렉터리면 역할별 md(2.0.0), .json이면 한 파일(1.x 호환). 설정에 semantic 키가 없으면 여정 입력이 없다
80
+ const sem = readSemantic(fs, cfg);
63
81
  const captureExists = (id) => (id && fs.has(`${capturesDir(cfg)}/${id}.jpg`) ? `${id}.jpg` : null);
64
82
  const data = derive(g, sem, cfg, { captureExists });
65
83
  return { g, cfg, sem, data, fs, shadowed };
66
84
  }
67
85
 
68
- export async function build(root = process.cwd(), out = resolve(root, 'map/.out')) {
69
- const r = await buildGraph(root);
86
+ export async function build(root = process.cwd(), out = resolve(root, 'map/.out'), opts = {}) {
87
+ const r = await buildGraph(root, opts);
70
88
  mkdirSync(out, { recursive: true });
71
89
  writeFileSync(join(out, 'graph.json'), JSON.stringify(r.g.toJSON()));
72
90
  writeFileSync(join(out, 'data.json'), JSON.stringify(r.data));
@@ -84,12 +102,14 @@ export function engineMismatch(cfg) {
84
102
  }
85
103
 
86
104
  const USAGE = `usage: livemap <command>
87
- build [--root .] [--out map/.out] 저장소 스캔 → graph.json · data.json · overview.json
88
- check [--root .] [--json] [--strict] 정합 검사, 오류가 있으면 exit 1(--json: 이슈 JSON만)
105
+ build [--root .] [--out map/.out] [--semantic <경로>]
106
+ 저장소 스캔 → graph.json · data.json · overview.json
107
+ check [--root .] [--json] [--strict] [--semantic <경로>] 정합 검사, 오류가 있으면 exit 1(--json: 이슈 JSON만)
89
108
  check --staged 커밋 전 훅: 스테이징된 작업 문서·판정 파일의 문제만 오류로
90
109
  serve [--port 4180] [--static <dir>] 로컬 뷰 http://127.0.0.1:<port>/map/
91
110
  export <dir> [--out map/.out] 화면·서체·캡처·생성물을 한 폴더에(먼저 build)
92
111
  init map/ 초안 파일·.gitignore·npm 스크립트·커밋 전 훅
112
+ affected [--base <ref>] 바뀐 화면을 지나는 브라우저 검사와 실행 명령
93
113
  test-report 단위 검사를 JUnit 리포트와 결과 JSON으로
94
114
  test-report --import <파일> [--sha <커밋>] Playwright JSON·JUnit·livemap 리포터 출력을 결과 JSON에
95
115
  --version 엔진 버전`;
@@ -142,7 +162,7 @@ export async function main(argv = []) {
142
162
 
143
163
  const root = resolve(opt('root', process.cwd()));
144
164
  const out = resolve(root, opt('out', 'map/.out'));
145
- if (!['build', 'check', 'serve', 'export', 'test-report'].includes(cmd)) { console.error(USAGE); return 2; }
165
+ if (!['build', 'check', 'serve', 'export', 'test-report', 'affected'].includes(cmd)) { console.error(USAGE); return 2; }
146
166
 
147
167
  const staticDir = cmd === 'serve' ? opt('static') : undefined;
148
168
  let cfg = null;
@@ -156,7 +176,7 @@ export async function main(argv = []) {
156
176
  const notifyShadow = (names) => { for (const n of names) console.log(`프로젝트 어댑터가 참조 어댑터를 가림: ${n}`); };
157
177
 
158
178
  if (cmd === 'build') {
159
- const { data: d, shadowed } = await build(root, out);
179
+ const { data: d, shadowed } = await build(root, out, { semantic: opt('semantic', null) });
160
180
  notifyShadow(shadowed);
161
181
  const bad = d.adapters.filter((a) => a.status !== 'ok');
162
182
  console.log(`map build → ${out}: 화면 ${d.summary.routes} · API ${d.summary.apis} · 함수 ${d.summary.dbFunctions} · 작업 ${d.tasks.length} · 커밋 ${d.summary.commits} · 경고 ${d.summary.warnings} · 고아 ${d.summary.orphans}`);
@@ -165,12 +185,13 @@ export async function main(argv = []) {
165
185
  }
166
186
  if (cmd === 'check' && argv.includes('--staged')) return checkStaged(root, cfg, argv.includes('--json'));
167
187
  if (cmd === 'check') {
188
+ const semOverride = opt('semantic', null);
168
189
  const json = argv.includes('--json');
169
190
  // --json: stdout에는 JSON만. 빌드 중 어댑터가 찍는 줄과 가림 알림은 stderr로 보낸다
170
191
  const log = console.log;
171
192
  if (json) console.log = (...a) => console.error(...a);
172
193
  let built;
173
- try { built = await buildGraph(root); } finally { console.log = log; }
194
+ try { built = await buildGraph(root, { semantic: semOverride }); } finally { console.log = log; }
174
195
  const { data, cfg: c, shadowed } = built;
175
196
  if (json) for (const n of shadowed) console.error(`프로젝트 어댑터가 참조 어댑터를 가림: ${n}`);
176
197
  else notifyShadow(shadowed);
@@ -202,6 +223,12 @@ export async function main(argv = []) {
202
223
  const { exportSite } = await import('./serve.mjs');
203
224
  return exportSite({ root, out, captures: resolve(root, capturesDir(cfg)), target: resolve(process.cwd(), target) });
204
225
  }
226
+ if (cmd === 'affected') {
227
+ const { affected, affectedText } = await import('./affected.mjs');
228
+ const r = await affected({ root, base: opt('base', null) });
229
+ for (const line of affectedText(r)) console.log(line);
230
+ return 0;
231
+ }
205
232
  if (cmd === 'test-report') {
206
233
  const { testReport, importReport } = await import('./test-report.mjs');
207
234
  if (argv.includes('--import')) return importReport({ root, cfg, file: opt('import'), sha: opt('sha') });
package/src/derive.mjs CHANGED
@@ -171,7 +171,11 @@ export function derive(g, sem, cfg, { captureExists }) {
171
171
  delete r._q; delete r._id; delete r._amb;
172
172
  }
173
173
  // 등급: D 주장 / C 관측 / B 검사 존재 / A 최신 커밋에서 통과
174
- const observed = screenNodes.length > 0 && screenNodes.every((n) => n.source === 'live');
174
+ // 관측 근거: 화면이 있으면 화면이 모두 실데이터일 때. 화면 없이 API로만 도는 단계(알림 발송 등)
175
+ // 선언한 API가 모두 코드에 있을 때 관측으로 본다. 화면도 API도 없으면 주장(D)이다.
176
+ const observed = screenNodes.length > 0
177
+ ? screenNodes.every((n) => n.source === 'live')
178
+ : apiNodes.length > 0 && apiNodes.every((a) => !a.missing);
175
179
  const covered = observed && testFiles.length > 0;
176
180
  const verified = covered && (screenNodes.some((n) => testsPassedFresh('screen', n.path)) || apiNodes.some((a) => !a.missing && testsPassedFresh('api', a.path)) || functionNodes.some((f) => testsPassedFresh('function', f.name)));
177
181
  const grade = st.status !== 'live' ? null : verified ? 'A' : covered ? 'B' : observed ? 'C' : 'D';
@@ -287,7 +291,7 @@ export function derive(g, sem, cfg, { captureExists }) {
287
291
  schemaVersion: 1, generatedAt: new Date().toISOString(), project: sem.project || cfg.project, head, deploy: homelab, testreport: report,
288
292
  // 결과 실행 목록(러너·출처·sha·시각·exit·최신 여부만, dirtyPaths는 싣지 않는다)
289
293
  testRuns: report?.runs || [],
290
- adapters: g.toJSON().adapters, semantic: { actors: sem.actors || {}, statusLegend: sem.statusLegend || {}, journeys },
294
+ adapters: g.toJSON().adapters, badges: g.badges.map((b) => ({ ...b })), semantic: { actors: sem.actors || {}, statusLegend: sem.statusLegend || {}, roles: sem.roles || [], readEmpty: sem.readEmpty || null, journeys },
291
295
  summary, orphans, coverage, tasks: taskView, roadmap, ledger, decisions: decisionView, plans, commits: commitView, areaCounts,
292
296
  screens: screenView, apis: apiView, functions: fnView, migrations: migView, tests: testView,
293
297
  milestones: milestoneView, issues: g.issues.map((i) => ({ ...i })), sources: { semantic: cfg.semantic, roadmap: cfg.roadmap?.file ?? null },
@@ -376,7 +380,7 @@ export function overviewSlice(d, opts = {}) {
376
380
  const lastRun = d.testreport ? { fresh: d.testreport.fresh, failures: d.testreport.failures, total: d.testreport.total, at: d.testreport.at } : null;
377
381
  return {
378
382
  generatedAt: d.generatedAt, project: d.project, headDate: d.head?.date || null,
379
- line: summaryLine(d),
383
+ line: summaryLine(d), badges: (d.badges || []).map((b) => ({ label: b.label, text: b.text })),
380
384
  journeys: d.semantic.journeys.map((j) => {
381
385
  const item = openItems.find((r) => (r.scenes || []).some((s) => !s.missing && s.journey === j.id));
382
386
  const ms = item && milestoneById.get(item.milestone);
@@ -424,5 +428,7 @@ export function summaryLine(d) {
424
428
  const parts = [`장면 ${s.stepsLive}/${s.stepsTotal} 동작`, `화면 ${s.liveRoutes}/${s.routes} 실데이터`, ...(s.fixedRoutes ? [`하드코딩 표시 ${s.fixedRoutes}`] : []), `14일 커밋 ${s.commits}`];
425
429
  if (d.deploy && d.deploy.behindRuntime > 0) parts.push(`미배포 ${d.deploy.behindRuntime}`);
426
430
  if (s.warnings) parts.push(`경고 ${s.warnings}`);
431
+ // 어댑터가 얹은 조각(프로젝트 빚·대장 수치 등). 화면 어휘는 어댑터가 정한다
432
+ for (const b of d.badges || []) parts.push(`${b.label} ${b.text}`);
427
433
  return `${parts.join(' · ')}${next ? ` · 다음: ${next.replace(/\*\*/g, '').replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').slice(0, 40)}` : ''}`;
428
434
  }
package/src/lib/graph.mjs CHANGED
@@ -8,7 +8,7 @@ export const NODE_KINDS = ['journey', 'step', 'screen', 'api', 'function', 'tabl
8
8
  export const EDGE_KINDS = ['has_step', 'shows', 'uses', 'calls', 'invokes', 'touches', 'covers', 'changes', 'refs', 'defines', 'contains', 'tracks'];
9
9
 
10
10
  export class Graph {
11
- constructor() { this.nodes = new Map(); this.edges = []; this.adapters = []; this.issues = []; this.adapter = null; }
11
+ constructor() { this.nodes = new Map(); this.edges = []; this.adapters = []; this.issues = []; this.badges = []; this.adapter = null; }
12
12
  key(kind, id) { return `${kind}:${id}`; }
13
13
  add(kind, id, label, props = {}, src = null) {
14
14
  if (!NODE_KINDS.includes(kind)) throw new Error(`unknown node kind ${kind}`);
@@ -41,9 +41,17 @@ export class Graph {
41
41
  const extra = detail == null ? {} : issueDetail(level, detail);
42
42
  this.issues.push({ level, label, message, adapter: this.adapter, ...extra });
43
43
  }
44
+ // 개요 요약 줄에 얹는 한 조각. 어댑터가 프로젝트 어휘로 적고 엔진은 글자로만 다룬다(길이 60자, 줄바꿈 금지).
45
+ // 값이 아니라 조각을 받는 이유는 엔진이 프로젝트의 빚·대장 어휘를 모르기 때문이다.
46
+ badge(label, text) {
47
+ if (typeof label !== 'string' || typeof text !== 'string') throw new Error('badge label·text는 문자열');
48
+ const one = text.replace(/\s+/g, ' ').trim();
49
+ if (!one) throw new Error('badge text가 비었다');
50
+ this.badges.push({ label, text: one.slice(0, 60), adapter: this.adapter });
51
+ }
44
52
  report(name, status, count, error = null) { this.adapters.push({ name, status, count, error }); }
45
53
  toJSON() {
46
- return { schemaVersion: 1, adapters: this.adapters, nodes: [...this.nodes.values()], edges: this.edges, issues: this.issues };
54
+ return { schemaVersion: 1, adapters: this.adapters, nodes: [...this.nodes.values()], edges: this.edges, issues: this.issues, badges: this.badges };
47
55
  }
48
56
  }
49
57
 
@@ -35,6 +35,16 @@ export const ISSUE_CODES = {
35
35
  'journey.duplicate-id': code('error', '여정', ['source']),
36
36
  'journey.no-steps': code('error', '여정', ['source']),
37
37
  'journey.actor-unknown': code('warn', '여정', ['source']),
38
+ // 2.0.0 여정 정본(md 디렉터리) 규칙
39
+ 'journey.subtype-unknown': code('warn', '단계', ['source']),
40
+ 'semantic.empty': code('error', '설정', ['config', 'source']),
41
+ 'step.no-test': code('error', '단계', ['source']),
42
+ 'tests.route-not-in-journey': code('warn', '검사', ['source']),
43
+ 'journey.handoff-missing-step': code('error', '단계', ['source']),
44
+ 'journey.handoff-unpaired': code('warn', '단계', ['source']),
45
+ 'journey.start-unknown': code('error', '여정 문서', ['source']),
46
+ 'journey.doc-changed-after-review': code('warn', '여정 문서', ['source']),
47
+ 'tests.tag-unknown': code('warn', '검사', ['source']),
38
48
  'step.duplicate-id': code('error', '여정', ['source']),
39
49
  'step.intent-empty': code('warn', '단계', ['source']),
40
50
  'step.route-missing': code('error', '단계', ['source', 'code']),
@@ -0,0 +1,134 @@
1
+ // 여정 정본 읽개(2.0.0): 역할별 마크다운 디렉터리를 읽어 시맨틱 자료를 만든다.
2
+ // 사람이 읽고 고치는 정본은 역할 파일이고, 화면 주소·캡처·참조 같은 구현 좌표는 프로젝트가 가진 대응표에서 온다.
3
+ // 정본에 구현 정보를 적지 않는 대신 단계 ID(`<여정>/<단계>`)로 두 자료를 잇는다.
4
+ //
5
+ // 디렉터리 모양
6
+ // README.md 역할 표(역할 → 파일·이름)와 상태 어휘 표
7
+ // <역할>.md 머리 `- 사용자 확인`·`- 시작 지점`, `## 여정: <이름>` 절, 그 아래 `### <단계>` 절
8
+ // 대응표(JSON) { lanes: { <여정 또는 역할>: <레인> }, steps: { "<여정>/<단계>": { screens, capture, refs, apis, note, noTest } } }
9
+ // noTest는 "이 단계에 검사가 없는 이유"이고 적으면 정본→검사 규칙에서 면제한다(이유는 정본 본문에 두지 않는다)
10
+ import { parseSections } from './md-props.mjs';
11
+
12
+ const STATUS = { 동작: 'live', 목업: 'mock', 미착수: 'planned', 다음: 'next' };
13
+ const JOURNEY = /^여정\s*[::]\s*(.*)$/;
14
+ // 단계의 넘김 줄: `**넘김**: <역할> \`<단계 ID>\`(조건)`
15
+ const HANDOFF = /\*\*넘김\*\*\s*[::]\s*([^`\n(]+?)\s*`([^`]+)`/g;
16
+ const LIST_KEYS = ['하는 사람'];
17
+
18
+ // `20260917 11:00`·`2026-09-17`·`2026.09.17` → `2026-09-17`. 모양이 다르면 원문 그대로 둔다(검사 규칙이 본다)
19
+ export function normalizeDate(raw) {
20
+ const s = String(raw ?? '').trim();
21
+ const m = s.match(/(\d{4})[-.\/ ]?(\d{2})[-.\/ ]?(\d{2})/);
22
+ return m ? `${m[1]}-${m[2]}-${m[3]}` : s;
23
+ }
24
+
25
+ const slugOf = (file) => file.replace(/^.*\//, '').replace(/\.md$/, '');
26
+
27
+ // README.md: 역할 표에서 배우 사전, 상태 어휘 표에서 상태 뜻
28
+ function readIndex(text) {
29
+ const actors = {}, statusLegend = {};
30
+ const doc = parseSections(text, { head: true });
31
+ for (const sec of doc.children) {
32
+ for (const table of sec.tables) {
33
+ const head = table.header || [];
34
+ const col = (name) => head.findIndex((h) => h.includes(name));
35
+ const roleAt = col('역할'), fileAt = col('파일');
36
+ if (roleAt >= 0 && fileAt >= 0) {
37
+ for (const row of table.rows) {
38
+ const link = (row[fileAt] || '').match(/\(([^)]+)\)/);
39
+ const slug = slugOf(link ? link[1] : row[fileAt] || '');
40
+ if (slug) actors[slug] = row[roleAt];
41
+ }
42
+ continue;
43
+ }
44
+ const statusAt = col('상태'), meanAt = col('뜻');
45
+ if (statusAt >= 0 && meanAt >= 0) {
46
+ for (const row of table.rows) {
47
+ const key = STATUS[row[statusAt]];
48
+ if (key) statusLegend[key] = row[meanAt];
49
+ }
50
+ }
51
+ }
52
+ }
53
+ return { actors, statusLegend };
54
+ }
55
+
56
+ // 역할 파일 하나 → { slug, reviewedAt, startStep, subtypes, handoffs, journeys }
57
+ export function readRoleFile(text, slug) {
58
+ const doc = parseSections(text, { head: true, listKeys: LIST_KEYS });
59
+ const reviewedAt = normalizeDate(doc.props['사용자 확인']);
60
+ const startStep = String(doc.props['시작 지점'] ?? '').trim();
61
+ const subtypes = [], handoffs = [];
62
+ const journeys = [];
63
+ for (const sec of doc.children) {
64
+ if (sec.title.includes('하위 유형')) {
65
+ for (const table of sec.tables) for (const row of table.rows) if (row[0]) subtypes.push({ name: row[0], start: row[row.length - 1] === '—' ? startStep : row[row.length - 1] });
66
+ continue;
67
+ }
68
+ if (sec.title.includes('넘겨받는 일')) {
69
+ for (const line of [...sec.items, sec.prose]) for (const m of String(line).matchAll(/([a-z-]+\/[a-z-]+)/g)) if (!handoffs.includes(m[1])) handoffs.push(m[1]);
70
+ continue;
71
+ }
72
+ const jm = sec.title.match(JOURNEY);
73
+ if (!jm) continue;
74
+ const steps = [];
75
+ for (const child of sec.children) {
76
+ const id = String(child.props.id ?? '').trim();
77
+ if (!id) continue;
78
+ const [journeyId, stepId] = id.includes('/') ? [id.slice(0, id.indexOf('/')), id.slice(id.indexOf('/') + 1)] : [null, id];
79
+ const handoffs2 = [];
80
+ for (const line of [...child.items, child.prose]) for (const m of String(line).matchAll(HANDOFF)) handoffs2.push({ role: m[1].trim(), step: m[2].trim() });
81
+ steps.push({
82
+ id: stepId, journeyId, fullId: id, label: child.title, handoffs: handoffs2,
83
+ status: STATUS[child.props['상태']] || child.props['상태'] || '',
84
+ statusWord: child.props['상태'] || '',
85
+ intent: child.props['목적'] || child.prose || '',
86
+ subtypes: child.props['하는 사람'] || [],
87
+ line: child.line,
88
+ });
89
+ }
90
+ if (!steps.length) continue;
91
+ journeys.push({ id: steps[0].journeyId || slugOf(slug), title: jm[1].trim(), actor: slug, steps, line: sec.line });
92
+ }
93
+ return { slug, reviewedAt, startStep, subtypes, handoffs, journeys };
94
+ }
95
+
96
+ // 디렉터리 전체 → 엔진이 쓰는 시맨틱 자료. map은 프로젝트 대응표(없으면 화면·캡처 없이 읽는다)
97
+ export function readJourneysDir(fs, dir, { map = {}, project = null } = {}) {
98
+ const files = fs.ls(dir).filter((f) => f.endsWith('.md')).sort();
99
+ const index = files.includes('README.md') ? readIndex(fs.read(`${dir}/README.md`)) : { actors: {}, statusLegend: {} };
100
+ const roles = [];
101
+ for (const f of files) {
102
+ if (f === 'README.md') continue;
103
+ const file = `${dir}/${f}`;
104
+ const role = readRoleFile(fs.read(file), slugOf(f));
105
+ const last = fs.lastCommit ? fs.lastCommit(file) : null;
106
+ roles.push({ file, last, changedAfterReview: !!(last && role.reviewedAt && last.date > role.reviewedAt), ...role });
107
+ }
108
+ const stepsMap = map.steps || {};
109
+ const journeys = [];
110
+ for (const role of roles) {
111
+ for (const j of role.journeys) {
112
+ journeys.push({
113
+ id: j.id, title: j.title, actor: role.slug, lane: (map.lanes || {})[j.id] || (map.lanes || {})[role.slug] || index.actors[role.slug] || role.slug,
114
+ src: { file: role.file, line: j.line },
115
+ steps: j.steps.map((s) => {
116
+ const extra = stepsMap[s.fullId] || {};
117
+ return {
118
+ id: s.id, label: s.label, intent: s.intent, status: s.status,
119
+ actor: extra.actor, screens: extra.screens || [], capture: extra.capture, apis: extra.apis,
120
+ refs: extra.refs || [], note: extra.note, noTest: extra.noTest, reviewedAt: extra.reviewedAt || role.reviewedAt,
121
+ subtypes: s.subtypes, handoffs: s.handoffs, src: { file: role.file, line: s.line },
122
+ };
123
+ }),
124
+ });
125
+ }
126
+ }
127
+ return {
128
+ project: project || map.project || {},
129
+ actors: index.actors, statusLegend: index.statusLegend,
130
+ journeys, roles: roles.map(({ journeys: _j, ...rest }) => rest), source: { kind: 'md', dir },
131
+ };
132
+ }
133
+
134
+ export default readJourneysDir;
@@ -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,82 @@
1
+ // md 속성 파서: 제목 2단(`## `/`### `)으로 나눈 절마다 `- 키: 값` 속성, 목표 문장, md 표를 읽는다.
2
+ // md 블록 읽개(md-blocks.mjs) 위에 올린다. 코드 펜스 안 줄은 블록이 아니므로 자동으로 빠진다.
3
+ //
4
+ // 절 { level, title, line, props, prose, tables, children }
5
+ // props `- 키: 값` — 값의 백틱은 뗀다. listKeys에 든 키는 쉼표(,·,)로 나누고 대시 한 칸(—·-)은 뺀다.
6
+ // items `키: 값`이 아닌 목록 줄(글자 그대로)
7
+ // prose 제목·목록·표가 아닌 줄을 공백으로 이어 붙인 것(절의 목표 문장)
8
+ // tables { header, rows } — 칸은 원문 그대로(백틱만 뗀다). 구분 행은 들어오지 않는다.
9
+ // children 한 단계 아래 절. 자식의 속성·문장은 부모에 섞이지 않는다.
10
+ //
11
+ // 경계: 키 이름을 필드로 옮기거나 값의 어휘를 판정하는 일은 부르는 어댑터가 한다. 이 모듈은 문서 모양만 읽는다.
12
+ import { readBlocks } from './md-blocks.mjs';
13
+
14
+ const KV = /^([^::]{1,40})[::]\s*(.*)$/;
15
+ const DASH = new Set(['—', '–', '-', '']);
16
+
17
+ // 유니코드 공백(NBSP·엔 스페이스 등)은 일반 공백으로 맞춘다. 편집기가 넣은 NBSP 때문에 키·값을 놓치는 일이 있었다
18
+ const clean = (s) => String(s ?? '').replace(/[\u00a0\u1680\u2000-\u200a\u202f\u205f\u3000]/g, ' ').replace(/`/g, '').trim();
19
+
20
+ const value = (key, raw, listKeys) => {
21
+ const v = clean(raw);
22
+ if (!listKeys.has(key)) return v;
23
+ return v.split(/[,,]/).map((x) => x.trim()).filter((x) => x && !DASH.has(x));
24
+ };
25
+
26
+ const emptySection = (level, title, line) => ({ level, title, line, props: {}, items: [], prose: '', tables: [], children: [] });
27
+
28
+ // 절 하나에 블록을 담는다. 표는 표 번호로 묶는다.
29
+ function put(section, block, listKeys, tables) {
30
+ if (block.type === 'item') {
31
+ const text = clean(block.text);
32
+ const m = text.match(KV);
33
+ if (m) { section.props[m[1].trim()] = value(m[1].trim(), m[2], listKeys); return; }
34
+ section.items.push(text); // `키: 값`이 아닌 목록 줄은 그대로 둔다(넘겨받는 일 목록 등)
35
+ return;
36
+ }
37
+ if (block.type === 'row') {
38
+ let t = tables.get(block.table);
39
+ if (!t) { t = { header: null, rows: [] }; tables.set(block.table, t); section.tables.push(t); }
40
+ const cells = block.cells.map(clean);
41
+ if (block.header && !t.header) t.header = cells; else t.rows.push(cells);
42
+ return;
43
+ }
44
+ if (block.type === 'text') section.prose = section.prose ? `${section.prose} ${block.text.trim()}` : block.text.trim();
45
+ }
46
+
47
+ // text → 절 배열. { head: true }면 문서 하나({ title, props, prose, tables, children })로 돌려준다.
48
+ // title은 첫 `# ` 제목이고 그 앞뒤의 `- 키: 값`이 문서 속성이다.
49
+ export function parseSections(text, { listKeys = [], levels = [2, 3], head = false } = {}) {
50
+ const keys = new Set(listKeys);
51
+ const [top, sub] = levels;
52
+ const doc = emptySection(1, '', 0);
53
+ const sections = [];
54
+ const tablesOf = new Map(); // 절 → (표 번호 → 표)
55
+ let current = doc, parent = null;
56
+ const tables = (s) => { let m = tablesOf.get(s); if (!m) { m = new Map(); tablesOf.set(s, m); } return m; };
57
+
58
+ for (const block of readBlocks(text)) {
59
+ if (block.type === 'heading') {
60
+ if (block.level < top) { if (!doc.title) { doc.title = clean(block.text); doc.line = block.line; } current = doc; parent = null; continue; }
61
+ if (block.level === top) {
62
+ current = emptySection(top, clean(block.text), block.line);
63
+ parent = current;
64
+ sections.push(current);
65
+ continue;
66
+ }
67
+ if (block.level === sub && parent) {
68
+ current = emptySection(sub, clean(block.text), block.line);
69
+ parent.children.push(current);
70
+ continue;
71
+ }
72
+ continue; // 더 깊은 제목은 지금 절에 붙는 글로 본다
73
+ }
74
+ put(current, block, keys, tables(current));
75
+ }
76
+
77
+ if (!head) return sections;
78
+ doc.children = sections;
79
+ return doc;
80
+ }
81
+
82
+ export default parseSections;
@@ -1,5 +1,5 @@
1
1
  {
2
- "engine": 1,
2
+ "engine": 2,
3
3
  "project": { "name": "프로젝트 이름", "host": "https://example.invalid" },
4
4
  "adapters": ["router", "bff", "migrations", "tests", "wiki", "tasks", "roadmap", "git", "deploy", "testreport"],
5
5
  "router": { "app": "web/src/App.tsx", "pagesDir": "web/src/pages", "localDirs": ["web/src/pages", "web/src/components"], "mockPattern": "/mock'", "fixedPattern": "/content/", "livePattern": "lib/queries", "hookApi": { "Session": "/api/session" } },