@pghoya2956/livemap 1.4.0 → 2.0.1

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,47 @@
2
2
 
3
3
  버전마다 `## [X.Y.Z] - YYYY-MM-DD` 절을 둔다. 릴리스 워크플로가 태그 버전의 절이 있는지 확인한다.
4
4
 
5
+ ## [2.0.1] - 2026-09-19
6
+
7
+ ### 고친 것
8
+
9
+ - 레이아웃 라우트로 얹힌 셸이 부르는 API가 자식 화면에 잡힌다. 그전에는 `<Route>`가 자식을 감싸면 그 셸 컴포넌트를 아예 분류하지 않아서, 셸에만 있는 호출(로그아웃 같은 것)이 어느 화면에도 붙지 않고 「어느 화면도 부르지 않는 API」 경고로 남았다. 셸은 자식 화면마다 함께 그려지므로 그 호출은 그 화면에서 실제로 일어난다. 실측으로 소비 프로젝트의 경고가 3에서 2로 줄었다. 합치는 것은 API 리터럴과 hook뿐이고, 자료 출처(`source`·`mockVia`·`files`)는 그 화면이 제 자료를 어디서 받는지를 말하는 값이라 합치지 않는다. 레이아웃 셸은 `pagesDir` 밖에 있는 경우가 많아 `localDirs`까지 찾는다(이름이 겹치면 `pagesDir` 쪽이 이긴다).
10
+ - 하위 명령에 붙인 `--help`·`-h`가 도움말을 내고 명령을 실행하지 않는다. 그전에는 `--help`를 첫 인자일 때만 도움말로 읽어서 `livemap init --help`가 도움말 대신 `init`을 실행했다. 빈 폴더에서 물으면 `map/` 뼈대와 `.gitignore` 줄을 만들고 종료 코드 0으로 끝났다. 값을 뒤에 받는 플래그의 값 자리(`--root --help` 등)는 그대로 값으로 읽는다.
11
+
12
+ ### 추가
13
+
14
+ - 팩 내용 계약 검사(`test/pack-contents.test.mjs`): 소비 프로젝트가 쓰는 것(실행기·엔진·화면·예산 설정·템플릿·문서·공개 리포터 경로)이 팩에 있고 저장소 개발 도구(`scripts/`)가 새어 들어가지 않는지 본다. 문서가 소비자용이라고 적은 것과 실제 팩이 어긋나던 자리를 검사로 막는다.
15
+
16
+ ### 값이 바뀌는 것
17
+
18
+ - 레이아웃 라우트를 쓰는 프로젝트에서 셸이 부르는 API가 이제 자식 화면의 `calls` 엣지로 잡힌다. 그만큼 「어느 화면도 부르지 않는 API」 경고가 줄고, 화면의 `apis` 읽기 상태가 `partial`에서 `rule`로 바뀔 수 있다. 실측으로 소비 프로젝트의 `check` 경고가 3에서 2로 줄었다. **경고 수를 값으로 고정해 둔 검사가 있으면 그 기대값을 낮춰야 한다.** 필드 모양은 그대로다.
19
+
20
+ ### 고침(문서)
21
+
22
+ - 1.3.0 절의 클릭 대상 크롤러 항목은 소비 프로젝트용 변경이 아니다. 크롤러(`scripts/route-crawl.mjs`)는 npm 팩에 들어가지 않는 엔진 저장소 개발 도구이고 `scripts/smoke.sh`·`scripts/leak-scan.sh`와 같은 부류다. 팩에 들어가는 것은 `package.json`의 `files`가 정한 일곱(`bin/`·`src/`·`site/`·`budget/`·`templates/`·`docs/`·`CHANGELOG.md`)뿐이다. 소비 프로젝트가 돌리는 화면 검사는 팩에 든 `budget/`이다.
23
+
24
+ ## [2.0.0] - 2026-09-19
25
+
26
+ 여정 정본을 역할별 마크다운 디렉터리에서 읽고, 정본 자체의 어긋남과 정본↔검사 양방향을 검사한다.
27
+
28
+ ### 깨지는 변경
29
+
30
+ - 설정 `semantic`이 디렉터리를 가리키면 역할별 md를 읽는다. `.json` 한 파일도 그대로 읽으므로 1.x 프로젝트는 설정을 바꾸지 않으면 동작이 같다.
31
+ - 화면 주소·캡처·참조는 정본에 두지 않고 프로젝트 대응표(설정 `journeyScreens`, 기본 `map/journey-screens.json`)에서 읽는다.
32
+ - md 정본을 읽는 프로젝트에서 `step.no-test`가 오류다. 검사가 없는 단계는 대응표 `noTest`에 이유를 적어 면제한다.
33
+
34
+ ### 추가
35
+
36
+ - `src/lib/journeys-md.mjs`: README 표에서 배우 사전·상태 어휘, 역할 파일에서 여정·단계·상태·사용자 확인·하위 유형 시작 지점·넘겨받는 일·단계 넘김을 읽는다.
37
+ - 정본 검사 코드 다섯: `journey.subtype-unknown`·`journey.handoff-missing-step`·`journey.handoff-unpaired`·`journey.start-unknown`·`journey.doc-changed-after-review`.
38
+ - 양방향 코드 둘: `step.no-test`(정본 → 검사), `tests.route-not-in-journey`(검사 → 정본).
39
+ - `build`·`check`에 `--semantic <경로>`. 다른 판의 정본으로 산출을 재현할 때 쓴다.
40
+
41
+ ### 고침
42
+
43
+ - 화면 없이 API로만 도는 단계(알림 발송 등)는 선언한 API가 모두 코드에 있으면 관측으로 본다. 전에는 화면이 없으면 무조건 등급 D라 정본의 `동작` 주장이 오류가 됐다.
44
+ - 검사 어댑터가 단계 태그를 풀 때 여정 정본을 같은 읽개로 읽는다. `JSON.parse`만 하던 탓에 md 정본에서 태그 연결이 조용히 사라져 검사→화면 엣지가 줄던 자리다.
45
+
5
46
  ## [1.4.0] - 2026-09-19
6
47
 
7
48
  작업 문서·검사 파일을 읽는 규칙을 넓히고 바뀐 화면의 검사를 고르는 명령을 더한다. 더하기만 하고 지우는 것은 없다.
@@ -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.4.0",
3
+ "version": "2.0.1",
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",
@@ -12,6 +12,17 @@ export default function router(g, fs, cfg) {
12
12
  const owner = new Map();
13
13
  for (const f of pageFiles) for (const m of fs.read(f).matchAll(/export\s+(?:function|const)\s+([A-Z]\w+)/g)) owner.set(m[1], f);
14
14
 
15
+ // 레이아웃 셸은 pagesDir 밖(components 등)에 있는 경우가 많다. 화면 소유자 표는 그대로 두고 셸을 찾을 때만 localDirs까지 본다.
16
+ // 이름이 겹치면 pagesDir 쪽이 이긴다 — 화면 파일 결정은 바꾸지 않는다
17
+ const shellOwner = new Map();
18
+ for (const dir of c.localDirs || []) {
19
+ if (dir === c.pagesDir) continue;
20
+ for (const f of fs.walk(dir, (p) => /\.tsx?$/.test(p))) {
21
+ for (const m of fs.read(f).matchAll(/export\s+(?:function|const)\s+([A-Z]\w+)/g)) if (!shellOwner.has(m[1])) shellOwner.set(m[1], f);
22
+ }
23
+ }
24
+ const shellFile = (name) => owner.get(name) || shellOwner.get(name) || null;
25
+
15
26
  const hookApi = c.hookApi || null;
16
27
  const appDir = appDirOf(c.app);
17
28
  const classify = (file) => {
@@ -46,24 +57,36 @@ export default function router(g, fs, cfg) {
46
57
  };
47
58
 
48
59
  const app = fs.read(c.app);
49
- let parent = null, n = 0;
60
+ let parent = null, shell = null, n = 0;
50
61
  app.split('\n').forEach((line, i) => {
51
62
  const m = line.match(/<Route\s+(?:path="([^"]+)"|(index))[^>]*element=\{(?:\w*[gG]uard\()?<(\w+)/);
52
63
  if (m) {
53
64
  const own = m[2] ? '' : m[1];
54
65
  const path = parent ? (own ? `${parent}/${own}` : parent) : own;
55
66
  const isLayout = !/\/>\s*$/.test(line.trim()) && !/<\/Route>/.test(line);
56
- if (isLayout) { parent = m[1]; return; }
67
+ // 레이아웃 라우트는 사람이 가는 화면이 아니라 자식 화면마다 함께 그려지는 셸이다.
68
+ // 그래서 화면 노드를 만들지 않되, 셸이 부르는 API는 자식 화면에서 실제로 일어나므로 리터럴과 hook을 자식에 합친다.
69
+ if (isLayout) {
70
+ parent = m[1];
71
+ const sf = shellFile(m[3]);
72
+ shell = sf ? classify(sf) : null;
73
+ return;
74
+ }
57
75
  if (path === '*' || path.endsWith('/*')) return;
58
76
  const file = owner.get(m[3]) || null;
59
77
  const cls = file ? classify(file) : { files: [], mockVia: [], fixedVia: [], source: 'static', hooks: [], apiLiterals: [] };
60
- const props = { component: m[3], file, guarded: /[gG]uard\(/.test(line) || parent !== null, source: cls.source, mockVia: cls.mockVia, fixedVia: cls.fixedVia, files: cls.files, last: file ? fs.lastCommit(file) : null, apiLiterals: cls.apiLiterals };
61
- if (hookApi) props.hookApiKeys = cls.hooks;
78
+ // 셸의 리터럴·hook을 자식 화면에 더한다. 같은 파일·줄·경로는 번만 넣는다.
79
+ // source·mockVia·files는 합치지 않는다 — 그건 그 화면이 제 자료를 어디서 받는지를 말하는 값이고 셸은 자료를 주지 않는다
80
+ const seenLit = new Set(cls.apiLiterals.map((l) => `${l.file}:${l.line}:${l.path}`));
81
+ const apiLiterals = [...cls.apiLiterals, ...(shell?.apiLiterals ?? []).filter((l) => !seenLit.has(`${l.file}:${l.line}:${l.path}`))];
82
+ const hooks = [...new Set([...cls.hooks, ...(shell?.hooks ?? [])])];
83
+ const props = { component: m[3], file, guarded: /[gG]uard\(/.test(line) || parent !== null, source: cls.source, mockVia: cls.mockVia, fixedVia: cls.fixedVia, files: cls.files, last: file ? fs.lastCommit(file) : null, apiLiterals };
84
+ if (hookApi) props.hookApiKeys = hooks;
62
85
  g.add('screen', path, path, props, { file: c.app, line: i + 1, rule: 'router:<Route path>' });
63
- for (const h of cls.hooks) { const a = hookApi[h]; g.add('api', a, a); g.link('screen', path, 'calls', 'api', a); }
86
+ for (const h of hooks) { const a = hookApi[h]; g.add('api', a, a); g.link('screen', path, 'calls', 'api', a); }
64
87
  n += 1;
65
88
  }
66
- if (/<\/Route>/.test(line)) parent = null;
89
+ if (/<\/Route>/.test(line)) { parent = null; shell = null; }
67
90
  });
68
91
  return n === 0 ? '라우트 0건' : null;
69
92
  }
@@ -1,6 +1,7 @@
1
1
  import { join } from 'node:path';
2
2
  import { setReading } from '../lib/reading.mjs';
3
3
  import { extractPathLiterals } from '../lib/literals.mjs';
4
+ import { readSemantic } from '../cli.mjs';
4
5
  // 검사 어댑터: tests/ 파일에서 test 노드를 만들고, 화면·API·DB 함수를 덮는(covers) 엣지를 잇는다.
5
6
  // 화면은 두 길로 잇는다. 하나는 파일(로컬 import 닫힘 포함)의 경로 리터럴이고, 둘은 Playwright 단계 태그 `@<여정>/<단계>`다.
6
7
  // 리터럴은 작은·큰따옴표와 백틱 템플릿을 같은 규칙으로 읽는다(`/r/${slug}` → `/r/:param`). 헬퍼가 돌려주는 템플릿도 닫힘에 들어와 잡힌다.
@@ -55,12 +56,11 @@ export function matchesScreen(litPath, screenId) {
55
56
  return b.every((seg, i) => seg === a[i] || (seg.startsWith(':') && a[i] !== ''));
56
57
  }
57
58
 
58
- // 여정 파일에서 단계 id → 화면 목록. 1.x JSON 한 파일이다(2.0.0에서 md 디렉터리로 바뀐다).
59
+ // 여정 정본에서 단계 id → 화면 목록. JSON 한 파일(1.x)과 md 디렉터리(2.0.0)를 같은 읽개로 받는다.
59
60
  function stepScreens(fs, cfg) {
60
61
  const map = new Map();
61
- if (!cfg.semantic || !fs.has(cfg.semantic)) return map;
62
62
  let sem;
63
- try { sem = JSON.parse(fs.read(cfg.semantic)); } catch { return map; }
63
+ try { sem = readSemantic(fs, cfg); } catch { return map; }
64
64
  for (const j of sem.journeys || []) for (const s of j.steps || []) map.set(`${j.id}/${s.id}`, s.screens || []);
65
65
  return map;
66
66
  }
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,8 +102,9 @@ 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)
@@ -95,6 +114,9 @@ const USAGE = `usage: livemap <command>
95
114
  test-report --import <파일> [--sha <커밋>] Playwright JSON·JUnit·livemap 리포터 출력을 결과 JSON에
96
115
  --version 엔진 버전`;
97
116
 
117
+ // 값을 뒤에 받는 플래그. 그 값 자리에 온 --help 는 도움말 요청이 아니다
118
+ const VALUE_FLAGS = new Set(['--root', '--out', '--semantic', '--port', '--static', '--base', '--import', '--sha']);
119
+
98
120
  function readConfig(root) {
99
121
  const file = resolve(root, CONFIG);
100
122
  if (!existsSync(file)) return { error: `${CONFIG} 없음: 프로젝트 루트에서 실행하거나 먼저 livemap init` };
@@ -138,7 +160,9 @@ export async function main(argv = []) {
138
160
  const cmd = argv[0] || 'build';
139
161
  const opt = (k, d) => { const i = argv.indexOf(`--${k}`); return i >= 0 ? argv[i + 1] : d; };
140
162
  if (cmd === '--version' || cmd === '-v' || cmd === 'version') { console.log(VERSION); return 0; }
141
- if (cmd === 'help' || cmd === '--help' || cmd === '-h') { console.log(USAGE); return 0; }
163
+ // 하위 명령 뒤에 붙인 --help·-h 도움말이다. 값이 필요한 플래그의 자리는 빼서 --out --help 같은 경우를 건드리지 않는다.
164
+ // 이 줄이 없으면 도움말을 물은 사람이 명령을 실행당한다. init 은 파일을 쓰므로 그 피해가 실제였다.
165
+ if (cmd === 'help' || argv.some((a, i) => (a === '--help' || a === '-h') && !VALUE_FLAGS.has(argv[i - 1]))) { console.log(USAGE); return 0; }
142
166
  if (cmd === 'init') { const { init } = await import('./init.mjs'); return init({ root: process.cwd(), pkgRoot: PKG_ROOT }); }
143
167
 
144
168
  const root = resolve(opt('root', process.cwd()));
@@ -157,7 +181,7 @@ export async function main(argv = []) {
157
181
  const notifyShadow = (names) => { for (const n of names) console.log(`프로젝트 어댑터가 참조 어댑터를 가림: ${n}`); };
158
182
 
159
183
  if (cmd === 'build') {
160
- const { data: d, shadowed } = await build(root, out);
184
+ const { data: d, shadowed } = await build(root, out, { semantic: opt('semantic', null) });
161
185
  notifyShadow(shadowed);
162
186
  const bad = d.adapters.filter((a) => a.status !== 'ok');
163
187
  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}`);
@@ -166,12 +190,13 @@ export async function main(argv = []) {
166
190
  }
167
191
  if (cmd === 'check' && argv.includes('--staged')) return checkStaged(root, cfg, argv.includes('--json'));
168
192
  if (cmd === 'check') {
193
+ const semOverride = opt('semantic', null);
169
194
  const json = argv.includes('--json');
170
195
  // --json: stdout에는 JSON만. 빌드 중 어댑터가 찍는 줄과 가림 알림은 stderr로 보낸다
171
196
  const log = console.log;
172
197
  if (json) console.log = (...a) => console.error(...a);
173
198
  let built;
174
- try { built = await buildGraph(root); } finally { console.log = log; }
199
+ try { built = await buildGraph(root, { semantic: semOverride }); } finally { console.log = log; }
175
200
  const { data, cfg: c, shadowed } = built;
176
201
  if (json) for (const n of shadowed) console.error(`프로젝트 어댑터가 참조 어댑터를 가림: ${n}`);
177
202
  else notifyShadow(shadowed);
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;
@@ -3,6 +3,7 @@
3
3
  //
4
4
  // 절 { level, title, line, props, prose, tables, children }
5
5
  // props `- 키: 값` — 값의 백틱은 뗀다. listKeys에 든 키는 쉼표(,·,)로 나누고 대시 한 칸(—·-)은 뺀다.
6
+ // items `키: 값`이 아닌 목록 줄(글자 그대로)
6
7
  // prose 제목·목록·표가 아닌 줄을 공백으로 이어 붙인 것(절의 목표 문장)
7
8
  // tables { header, rows } — 칸은 원문 그대로(백틱만 뗀다). 구분 행은 들어오지 않는다.
8
9
  // children 한 단계 아래 절. 자식의 속성·문장은 부모에 섞이지 않는다.
@@ -13,7 +14,8 @@ import { readBlocks } from './md-blocks.mjs';
13
14
  const KV = /^([^::]{1,40})[::]\s*(.*)$/;
14
15
  const DASH = new Set(['—', '–', '-', '']);
15
16
 
16
- const clean = (s) => String(s ?? '').replace(/`/g, '').trim();
17
+ // 유니코드 공백(NBSP·엔 스페이스 등) 일반 공백으로 맞춘다. 편집기가 넣은 NBSP 때문에 키·값을 놓치는 일이 있었다
18
+ const clean = (s) => String(s ?? '').replace(/[\u00a0\u1680\u2000-\u200a\u202f\u205f\u3000]/g, ' ').replace(/`/g, '').trim();
17
19
 
18
20
  const value = (key, raw, listKeys) => {
19
21
  const v = clean(raw);
@@ -21,13 +23,15 @@ const value = (key, raw, listKeys) => {
21
23
  return v.split(/[,,]/).map((x) => x.trim()).filter((x) => x && !DASH.has(x));
22
24
  };
23
25
 
24
- const emptySection = (level, title, line) => ({ level, title, line, props: {}, prose: '', tables: [], children: [] });
26
+ const emptySection = (level, title, line) => ({ level, title, line, props: {}, items: [], prose: '', tables: [], children: [] });
25
27
 
26
28
  // 절 하나에 블록을 담는다. 표는 표 번호로 묶는다.
27
29
  function put(section, block, listKeys, tables) {
28
30
  if (block.type === 'item') {
29
- const m = clean(block.text).match(KV);
31
+ const text = clean(block.text);
32
+ const m = text.match(KV);
30
33
  if (m) { section.props[m[1].trim()] = value(m[1].trim(), m[2], listKeys); return; }
34
+ section.items.push(text); // `키: 값`이 아닌 목록 줄은 그대로 둔다(넘겨받는 일 목록 등)
31
35
  return;
32
36
  }
33
37
  if (block.type === 'row') {
@@ -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" } },