@pghoya2956/livemap 1.4.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 +22 -0
- package/docs/adapter-contract.md +12 -0
- package/docs/issue-codes.md +9 -0
- package/docs/migrate.md +1 -1
- package/docs/semantic-authoring.md +31 -1
- package/docs/semantic-schema.md +2 -2
- package/package.json +1 -1
- package/src/adapters/tests.mjs +3 -3
- package/src/check.mjs +64 -1
- package/src/cli.mjs +29 -9
- package/src/derive.mjs +9 -3
- package/src/lib/graph.mjs +10 -2
- package/src/lib/issues.mjs +10 -0
- package/src/lib/journeys-md.mjs +134 -0
- package/src/lib/md-props.mjs +7 -3
- package/templates/config.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
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
|
+
|
|
5
27
|
## [1.4.0] - 2026-09-19
|
|
6
28
|
|
|
7
29
|
작업 문서·검사 파일을 읽는 규칙을 넓히고 바뀐 화면의 검사를 고르는 명령을 더한다. 더하기만 하고 지우는 것은 없다.
|
package/docs/adapter-contract.md
CHANGED
|
@@ -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
|
package/docs/issue-codes.md
CHANGED
|
@@ -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
|
-
`
|
|
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
|
|
package/docs/semantic-schema.md
CHANGED
|
@@ -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": "
|
|
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",
|
package/src/adapters/tests.mjs
CHANGED
|
@@ -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
|
-
// 여정
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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]
|
|
88
|
-
|
|
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)
|
|
@@ -157,7 +176,7 @@ export async function main(argv = []) {
|
|
|
157
176
|
const notifyShadow = (names) => { for (const n of names) console.log(`프로젝트 어댑터가 참조 어댑터를 가림: ${n}`); };
|
|
158
177
|
|
|
159
178
|
if (cmd === 'build') {
|
|
160
|
-
const { data: d, shadowed } = await build(root, out);
|
|
179
|
+
const { data: d, shadowed } = await build(root, out, { semantic: opt('semantic', null) });
|
|
161
180
|
notifyShadow(shadowed);
|
|
162
181
|
const bad = d.adapters.filter((a) => a.status !== 'ok');
|
|
163
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}`);
|
|
@@ -166,12 +185,13 @@ export async function main(argv = []) {
|
|
|
166
185
|
}
|
|
167
186
|
if (cmd === 'check' && argv.includes('--staged')) return checkStaged(root, cfg, argv.includes('--json'));
|
|
168
187
|
if (cmd === 'check') {
|
|
188
|
+
const semOverride = opt('semantic', null);
|
|
169
189
|
const json = argv.includes('--json');
|
|
170
190
|
// --json: stdout에는 JSON만. 빌드 중 어댑터가 찍는 줄과 가림 알림은 stderr로 보낸다
|
|
171
191
|
const log = console.log;
|
|
172
192
|
if (json) console.log = (...a) => console.error(...a);
|
|
173
193
|
let built;
|
|
174
|
-
try { built = await buildGraph(root); } finally { console.log = log; }
|
|
194
|
+
try { built = await buildGraph(root, { semantic: semOverride }); } finally { console.log = log; }
|
|
175
195
|
const { data, cfg: c, shadowed } = built;
|
|
176
196
|
if (json) for (const n of shadowed) console.error(`프로젝트 어댑터가 참조 어댑터를 가림: ${n}`);
|
|
177
197
|
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
|
-
|
|
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
|
|
package/src/lib/issues.mjs
CHANGED
|
@@ -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;
|
package/src/lib/md-props.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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') {
|
package/templates/config.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"engine":
|
|
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" } },
|