@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 +38 -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/roadmap.mjs +14 -14
- package/src/adapters/tests.mjs +96 -6
- package/src/affected.mjs +74 -0
- package/src/check.mjs +64 -1
- package/src/cli.mjs +37 -10
- 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/literals.mjs +7 -2
- package/src/lib/md-props.mjs +82 -0
- package/templates/config.json +1 -1
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` 항목 수 하나다.
|
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/roadmap.mjs
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
16
|
-
const rel =
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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 =
|
|
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:
|
|
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
|
}
|
package/src/adapters/tests.mjs
CHANGED
|
@@ -1,11 +1,76 @@
|
|
|
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';
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
38
|
-
if (new RegExp(`rpc\\(\\s*['\"\`]${
|
|
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;
|
package/src/affected.mjs
ADDED
|
@@ -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
|
|
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,12 +102,14 @@ 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)
|
|
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
|
-
|
|
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/literals.mjs
CHANGED
|
@@ -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(
|
|
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;
|
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" } },
|