@pghoya2956/livemap 1.1.1 → 1.3.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 +143 -0
- package/README.md +24 -7
- package/budget/view-budget.spec.mjs +198 -0
- package/docs/adapter-contract.md +52 -4
- package/docs/issue-codes.md +159 -0
- package/docs/migrate.md +15 -0
- package/docs/semantic-authoring.md +158 -3
- package/docs/semantic-schema.md +65 -8
- package/docs/test-results.md +189 -0
- package/docs/view-budget.md +19 -1
- package/package.json +1 -1
- package/site/map.css +80 -0
- package/site/map.js +9 -9
- package/src/adapters/deploy.mjs +19 -8
- package/src/adapters/router.mjs +34 -22
- package/src/adapters/tasks.mjs +322 -29
- package/src/adapters/testreport.mjs +115 -19
- package/src/adapters/tests.mjs +5 -1
- package/src/check.mjs +102 -28
- package/src/cli.mjs +68 -13
- package/src/derive.mjs +124 -16
- package/src/init.mjs +52 -3
- package/src/lib/closure.mjs +143 -0
- package/src/lib/graph.mjs +7 -3
- package/src/lib/issues.mjs +164 -0
- package/src/lib/judgments.mjs +128 -0
- package/src/lib/literals.mjs +103 -0
- package/src/lib/md-blocks.mjs +113 -0
- package/src/lib/reading.mjs +55 -0
- package/src/link.mjs +76 -0
- package/src/reporters/node-results.mjs +33 -0
- package/src/results.mjs +187 -0
- package/src/test-report.mjs +47 -11
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,149 @@
|
|
|
2
2
|
|
|
3
3
|
버전마다 `## [X.Y.Z] - YYYY-MM-DD` 절을 둔다. 릴리스 워크플로가 태그 버전의 절이 있는지 확인한다.
|
|
4
4
|
|
|
5
|
+
## [1.3.0] - 2026-09-19
|
|
6
|
+
|
|
7
|
+
로드맵 화면이 항목 목록에서 선행 관계가 보이는 기술 트리로 바뀐다. 열은 자료가 정한다 — 로드맵 파일에 `## 마일스톤:` 절이 있으면 열이 마일스톤이고, 없으면 열이 선행 깊이다. 개요에서 기능을 고르면 화면 캡처 패널이 그 기능의 캡처만 돌린다. 고르기 전 첫 화면은 1.2.0과 같다. 1.2.0 생성물의 필드는 지우거나 이름을 바꾸지 않았고 새 설정 키도 없다. 값이 바뀌는 것은 `overview.json`의 `captures` 항목 수 하나다.
|
|
8
|
+
|
|
9
|
+
### 추가
|
|
10
|
+
|
|
11
|
+
로드맵 기술 트리
|
|
12
|
+
|
|
13
|
+
- 로드맵 화면에 트리 패널이 선다. 노드가 로드맵 항목, 선이 선행, 열 머리가 그 열의 상태별 인원이다. 노드를 누르면 그 항목의 조상·자손 사이 선만 밝아지고 나머지는 흐려진다. 고른 항목의 카드는 트리 아래 상세 슬롯에 선다.
|
|
14
|
+
- 열 모드가 둘이다. 마일스톤이 한 건이라도 있으면 열 하나가 마일스톤 하나이고 열 순서는 로드맵 파일의 절 순서다(화면이 재정렬하지 않는다). 마일스톤이 없으면 열 하나가 선행 깊이 한 단계다.
|
|
15
|
+
- 어느 마일스톤에도 안 묶인 항목과 없는 마일스톤 id를 가리키는 항목은 맨 오른쪽 "마일스톤 없음" 열에 모인다. 0건이면 그 열을 만들지 않는다.
|
|
16
|
+
- 선행이 끝나지 않은 항목에 잠김 표시가 붙고 무엇이 막는지 이름으로 보인다. 카드에도 층 칩과 잠김 칩이 붙어 좁은 화면에서 트리가 감춰져도 정보가 남는다.
|
|
17
|
+
- 키보드로 트리를 돈다. Tab으로 첫 노드에 닿고 화살표 왼쪽·오른쪽이 첫 선행·첫 후속, 위·아래가 같은 열 이웃이다. 모션 줄임에서 애니메이션이 없다.
|
|
18
|
+
- 폭 720px 미만에서는 트리를 감추고 카드 목록만 보인다. 노드 폭이 150px 바닥에 닿으면 트리 상자에 가로 스크롤이 생긴다.
|
|
19
|
+
|
|
20
|
+
기능별 캡처 연결
|
|
21
|
+
|
|
22
|
+
- 개요에서 기능 지도·기능 추세·기능 표 어디를 눌러도 화면 캡처 패널이 그 기능의 캡처만 돌린다. 캡처가 없는 기능을 고르면 빈 상태를 보인다. 범위가 바뀌면 장 번호와 멈춤이 되돌아간다.
|
|
23
|
+
- `CapturePanelProps`에 `selected`·`userPicked`·`previewCount` 셋을 더했다. 셋 다 선택이라 기존 호출은 그대로 돈다.
|
|
24
|
+
|
|
25
|
+
엔진
|
|
26
|
+
|
|
27
|
+
- 로드맵 항목의 `problems`에 문장 둘을 더한다 — 선행 순환과 마일스톤 순서 역행이다. 둘 다 경고이고 화면은 멈추지 않는다. 코드는 `docs/issue-codes.md`에 있다.
|
|
28
|
+
- 예산 검사에 로드맵 화면 절과 캡처 패널 절이 생겼다. 로드맵 절은 개요의 개수 임계값을 물려받지 않고 성질만 잰다.
|
|
29
|
+
- 클릭 대상 크롤러가 개요 밖 화면의 대화형 요소도 표와 대조한다. 그전에는 개요에서만 셌다.
|
|
30
|
+
|
|
31
|
+
### 값이 바뀌는 것
|
|
32
|
+
|
|
33
|
+
- `overview.json`의 `captures`가 늘어난다. 1.2.0은 기능마다 한 장씩 전체 5장이 상한이었는데, 1.3.0은 기능마다 최대 다섯 장이고 전체 상한이 없다. 여정 9개·단계 35개 규모의 실제 프로젝트에서 5개가 15개가 되고 파일이 666바이트 늘었다. 배열 항목이 느는 것이고 항목의 필드 모양은 그대로다.
|
|
34
|
+
- 고르기 전 개요 첫 화면에 보이는 썸네일은 그대로 5장이다. 미리보기가 1.2.0과 같은 알고리즘(기능마다 첫 장)으로 앞 다섯을 고르기 때문이다.
|
|
35
|
+
|
|
36
|
+
### 알려진 한계
|
|
37
|
+
|
|
38
|
+
- 열 머리 고정이 가로 스크롤이 없을 때만 걸린다. 가로로 스크롤하는 상자 안에서는 `position: sticky`가 페이지가 아니라 그 상자를 기준으로 붙기 때문이다. 열이 많아 가로 스크롤이 켜지는 자료는 열마다 인원이 적어 머리가 화면 밖으로 나갈 일이 드물다.
|
|
39
|
+
- 마일스톤 모드에서 열을 건너뛰는 선행은 가운데 열 노드 뒤를 지난다. 그 모드는 더미 꺾임점을 만들지 않는다.
|
|
40
|
+
|
|
41
|
+
## [1.2.0] - 2026-09-18
|
|
42
|
+
|
|
43
|
+
엔진이 못 읽은 곳을 0으로 세지 않고 "?"와 근거 줄이 달린 이슈로 드러내고, 에이전트나 사람이 적은 판정 파일을 원문과 대조해 값으로 받는다. 검사 결과는 러너가 낸 파일별 결과 JSON으로, 화면→API 호출은 화면 코드의 경로 리터럴로 관측한다. 엔진은 계속 LLM·네트워크를 부르지 않는다. README 「버전」이 major로 정한 항목(config 키, 여정 형식, 어댑터 계약, 명령·종료 코드, 생성물 파일 이름, export 배치, 예산 설정 경로)은 더하기만 했다. 새 설정 키는 없다. 1.1.1 생성물의 필드는 지우거나 이름을 바꾸지 않았고, 규칙이 바로잡히며 값이 바뀌는 필드는 아래 「값이 바뀌는 것」에 모았다.
|
|
44
|
+
|
|
45
|
+
### 추가
|
|
46
|
+
|
|
47
|
+
check와 이슈 계약
|
|
48
|
+
|
|
49
|
+
- `livemap check --json`: stdout에 `{ schema, engine, errors, warnings, problems[] }` JSON만 낸다. 문제마다 안정 코드, 대상(`subject`), 근거 줄(`anchors`, 원문 조각 120 코드 포인트까지), 허용 처리(`resolutions`: source·judge·config·code·engine), 판정 초안(`judgmentDraft`)이 붙는다. 텍스트 출력의 1.1.1 줄 문구·순서는 그대로다.
|
|
50
|
+
- 새 코드 13개(`tasks.*` 7, `judgment.*` 2, `router.*` 3, `journey.api-not-observed`)와 1.1.1 check 줄에 붙인 코드. 코드 표의 정본은 새 문서 `docs/issue-codes.md`다. 같은 새 코드가 6건 이상이면 텍스트 출력에서 한 줄로 묶는다.
|
|
51
|
+
- `livemap check --strict`: `tasks.*`·`judgment.*` 경고를 오류로 센다(옵트인, 배포 게이트에는 두지 않는다).
|
|
52
|
+
- `livemap check --staged`와 `livemap init`의 커밋 전 훅: `.githooks/pre-commit`(`npx --no livemap check --staged`)과 `git config core.hooksPath .githooks`. 스테이징한 작업 폴더 문서·장부·판정 파일에 걸린 `tasks.*`·`judgment.*` 문제가 있으면 종료 코드 1로 커밋을 멈추고 근거 줄과 판정 초안을 출력한다. 대상 파일이 없거나 git 저장소가 아니면 0이다. 기존 `core.hooksPath`나 훅 관리자(husky·lefthook·pre-commit), `.git/hooks/pre-commit`이 있으면 덮지 않고 넣을 한 줄을 출력한다.
|
|
53
|
+
- 어댑터 계약 `g.issue(level, label, message, detail?)`: 넷째 인자로 코드·대상·근거 줄·처리·판정 초안을 붙인다. 세 인자 호출은 1.1.0 그대로다.
|
|
54
|
+
|
|
55
|
+
작업 문서 읽기
|
|
56
|
+
|
|
57
|
+
- md 블록 읽개(`src/lib/md-blocks.mjs`): 제목·목록 항목·표 행을 줄 번호와 함께 나누고 코드 펜스 안 줄은 건너뛴다.
|
|
58
|
+
- 식별자 줄 문법: 굵은 결정 줄(`- **DEC-7**`), 임의 접두어 계획 항목(`[A-Z]{1,4}[0-9]?-[0-9]+`), 번호 없는 체크박스, 취소선 정의. 규칙이 못 읽는 줄(표로 적은 결정, 첫 칸이 번호 하나가 아닌 잔여 질문 행, 계획 파일이 불분명한 체크박스)은 `tasks.unread-*` 이슈와 `partial`로 드러낸다.
|
|
59
|
+
- 파일 대체 규칙: 계획 파일이 없으면 체크박스를 가진 루트 md 하나(단일 `spec.md` 등), `spec/final.md`가 없으면 작업 폴더 루트 `final.md`.
|
|
60
|
+
- 잔여 질문: 제목에 "잔여"·"질문"이 든 절의 표만 세고, 체크한 계획 항목 설명이 같은 번호로 시작하면 닫힌 질문으로 본다. 새 필드 `openQuestions`·`openQuestionIds`, 개요 `counts.openQuestions`.
|
|
61
|
+
- 장부 상태: 절 제목 표시어(완료·complete·done, 진행·in progress, 대기·paused, 폐기·cancel 등)로 분류한다. spec-kit 장부 절(`In Progress`·`Paused`·`Completed`)을 읽는다.
|
|
62
|
+
- 번호 참조: 같은 번호를 정의한 작업을 결정 노드 `definers`·`definedAt`에 모으고, 여럿이면 `tasks.ambiguous-ref`. 여정 `refs`에 `<작업 폴더>#<번호>` 한정 참조를 받는다.
|
|
63
|
+
- 판정 파일 `map/judgments/<작업 폴더>.json`: `planFile`, `lines[]`(definition·ignore), `questions.none`·`questions.items[]`. 근거는 줄 번호 대신 원문 한 줄 안의 20자 이상 조각으로 대조해, 한 줄이면 적용(`judged`), 0줄이면 `judgment.stale`, 여러 줄이면 `judgment.invalid`다. 판정은 체크 여부·장부 상태·단계를 바꾸지 못한다. `data.json` `judgments[]`.
|
|
64
|
+
|
|
65
|
+
읽기 상태
|
|
66
|
+
|
|
67
|
+
- 노드 `props.reading`·`props.readingNotes`: `observed`·`rule`·`judged`·`partial`·`stale`·`unknown`·`none`. 작업 `plan`·`openQuestions`·`stage`, 검사 `count`·`lastRun`, 화면 `apis`, 배포 `behind`에 붙는다. `data.json` `readings`(값별·필드별 건수), `overview.json` `counts.reading`(개요 수치의 상태만, 경로 없음).
|
|
68
|
+
- 화면: 값 뒤 "?"와 이유 분류(개요), 이유 문장·근거 줄·판정 파일(작업 상세, 더보기 > 이 상황판의 읽기 상태·판정 카드). 작업 화면에서 결정 열을 뺐다. 검사 탭은 낡은 결과의 이유 문장을 보인다(`data.json` `tests[].readingNotes`).
|
|
69
|
+
|
|
70
|
+
검사 결과
|
|
71
|
+
|
|
72
|
+
- Node 사용자 리포터 `src/reporters/node-results.mjs`(패키지 공개 경로)와 결과 JSON(`tests.report` 폴더의 `test-results.json`, 필드 이름은 CTRF). `livemap test-report`가 JUnit과 이 리포터를 함께 붙여 돌린다.
|
|
73
|
+
- `livemap test-report --import <파일> [--sha <커밋>]`: livemap 리포터 출력, Playwright JSON 리포터 출력, JUnit XML을 결과 JSON에 넣는다. 가릴 수 없으면 종료 코드 2.
|
|
74
|
+
- 최신 판정: 결과 커밋이 HEAD의 조상이고, 그 뒤 커밋과 실행 때 변경이 설정의 문서 경로(`tasks.dir`, 위키 인덱스 폴더, `semantic`, `roadmap.file`, `captures.site`, `deploy.manifest`, `map/judgments`) 밖을 건드리지 않으면 최신이다.
|
|
75
|
+
- 검사 노드 `lastRun`의 `failed`·`skipped`·`pending`·`flaky`·`runner`·`sha`·`at`·`tags`, `runCount`, `data.json` `testRuns[]`, `testreport:last`의 `signal`·`runs`. 결과 파일 경로와 검사 파일 경로를 글자 그대로 맞춘다.
|
|
76
|
+
- 문서 `docs/test-results.md`(형식, 최신 판정, 리포터·import 명령, CI 배선 예).
|
|
77
|
+
|
|
78
|
+
화면→API 관측
|
|
79
|
+
|
|
80
|
+
- router가 페이지의 import 닫힘에서 `/api/` 문자열 리터럴을 모아 화면 노드 `apiLiterals[]`에 적고, 모든 어댑터 뒤 연결 단계(`src/link.mjs`)가 API 노드에 대응해 `calls` 엣지와 `matched`를 채운다. 확장 닫힘은 리터럴 추출에만 쓰고 화면 `files`·`source`와 변경 연결은 1.1.1 그대로다.
|
|
81
|
+
- 맞는 API가 없으면 `router.unknown-api`, 여정 `apis`에만 있고 화면에서 관측되지 않은 API는 `journey.api-not-observed`. 고아 API는 관측한 화면 호출만 센다.
|
|
82
|
+
- `router.hookApi`가 있으면 1.1.1처럼 읽고(합집합) 항목마다 `router.hookapi-redundant`·`router.hookapi-only`를 알린다. 화면 노드 `hookApiKeys`.
|
|
83
|
+
|
|
84
|
+
기타
|
|
85
|
+
|
|
86
|
+
- 배포 노드 `behindManifestOnly`(뺀 매니페스트 전용 커밋 수).
|
|
87
|
+
- 문서: `docs/adapter-contract.md`(넷째 인자, 읽기 상태, 리터럴과 연결 단계), `docs/semantic-authoring.md`(작업 문서 규칙, 판정 파일, 한정 참조), `docs/semantic-schema.md`(읽기 상태, 새 필드), `docs/issue-codes.md`, `docs/test-results.md`, `docs/migrate.md` 2.0.0 예고.
|
|
88
|
+
|
|
89
|
+
### 고친 것(1.1.1 결함)
|
|
90
|
+
|
|
91
|
+
- 설정에 `semantic` 키가 없으면 `build`·`check`가 `path` TypeError로 멈췄다. 이제 여정 입력 없음으로 본다.
|
|
92
|
+
- `livemap test-report`를 `node --test` 안에서(검사·CI 래퍼) 부르면 자식 러너가 `NODE_TEST_CONTEXT`를 물려받아 검사 파일을 하나도 돌리지 않고 종료 코드 0을 냈다. 이제 그 변수를 빼고 러너를 띄운다.
|
|
93
|
+
- Node 22 JUnit 리포터가 최상위 `<testcase>`를 `<testsuite>` 밖에 써서 1.1.1 testreport가 통과한 실행을 검사 0건으로 읽었다(결과가 늘 최신 아님). 최상위·중첩 `<testcase>`를 모두 센다.
|
|
94
|
+
- 배포 뒤처짐(`behind`)이 배포 봇이 매니페스트만 바꾼 커밋까지 세어, 배포 직후에도 1로 보였다. 매니페스트 경로만 바꾼 커밋을 빼고 센다.
|
|
95
|
+
- router의 로컬 import 닫힘이 작은따옴표 import만 따라가, 큰따옴표로 import한 페이지의 데이터 출처(live·mock)와 변경 연결이 빠졌다.
|
|
96
|
+
|
|
97
|
+
### 값이 바뀌는 것
|
|
98
|
+
|
|
99
|
+
같은 입력을 1.1.1과 1.2.0으로 build·check한 값이다. "실사용 프로젝트 사본"은 화면 29·API 55·작업 24개인 제품 저장소, "스펙 작업 코퍼스"는 작업 폴더 36개뿐인 문서 저장소다. 두 사본 모두 판정 파일이 없고, 원문은 고치지 않았다.
|
|
100
|
+
|
|
101
|
+
| 값 | 대상 | 1.1.1 | 1.2.0 | 까닭 |
|
|
102
|
+
|---|---|---|---|---|
|
|
103
|
+
| 조용히 잘못 읽은 작업 문서 줄(후보인데 세지도 알리지도 않은 줄) | 실사용 프로젝트 사본 | 67줄(작업 8/24) | 0 | 굵은 결정·임의 접두어 체크박스·단일 `spec.md`를 규칙으로 읽고, 못 읽는 줄은 이슈로 알림 |
|
|
104
|
+
| | 스펙 작업 코퍼스 | 177줄(작업 16/36) | 0 | 같은 까닭과 루트 `final.md` 대체, 잔여 질문 표의 번호 하나가 아닌 행 |
|
|
105
|
+
| 개요 열린 질문 | 실사용 프로젝트 사본 | 29(`counts.oq`) | 1?(`counts.openQuestions`, 읽기 상태 partial) | 답한 `## 열린 질문` 표를 세지 않고 잔여 질문 절만 셈. 남은 1은 완료 작업의 닫히지 않은 질문(판정 파일로 0) |
|
|
106
|
+
| | 스펙 작업 코퍼스 | 92 | 28?(partial) | 같은 까닭. 체크한 계획 항목 설명이 번호로 시작하면 닫힘 |
|
|
107
|
+
| 계획 항목 완료/전체(작업 합) | 실사용 프로젝트 사본 | 149/181 | 189/221 | 계획 파일 없는 작업의 체크박스 40줄(단일 `spec.md` 세 작업 포함)과 임의 접두어 항목을 셈. 네 작업의 0/0이 4/4·6/6·5/5·6/6 |
|
|
108
|
+
| | 스펙 작업 코퍼스 | 768/897 | 771/901 | 번호 없는 체크박스와 루트 `final.md` 작업 |
|
|
109
|
+
| 결정 수 `dec`(작업 합, 화면에서는 뺌) | 실사용 프로젝트 사본 | 149 | 176 | 굵은 결정 줄 |
|
|
110
|
+
| | 스펙 작업 코퍼스 | 206 | 360 | 굵은 결정 줄, 루트 `final.md` |
|
|
111
|
+
| 작업 상태 분포 | 실사용 프로젝트 사본 | 완료 20·진행 2·폐기 1·기록 1 | 완료 21·진행 2·폐기 1 | `완료 실행 이력` 절을 표시어로 완료로 읽음 |
|
|
112
|
+
| | 스펙 작업 코퍼스 | 기록 36 | 완료 28·진행 7·폐기 1 | spec-kit 장부 절 `In Progress`·`Completed`와 `폐기` 절을 읽음 |
|
|
113
|
+
| 화면→API 쌍(`calls` 엣지) | 실사용 프로젝트 사본, hookApi 설정 그대로 | 215 | 257 | 1.1.1 쌍 215개 모두 유지, 리터럴로 42쌍 추가(2단계 hook이 부르는 세션 확인, 같은 경로의 메서드 노드, 대응표에 없던 견적 화면 호출) |
|
|
114
|
+
| | 같은 사본, hookApi를 지운 설정 | 0 | 257 | 대응표 없이 리터럴로 관측 |
|
|
115
|
+
| 고아 API | 실사용 프로젝트 사본 | 2 | 1 | 관측한 화면 호출로 셈 |
|
|
116
|
+
| 배포 뒤처짐(`signals.deployBehindAll`) | 실사용 프로젝트 사본, 매니페스트 전용 커밋 직후 두 시점 | 1 | 0 | 매니페스트만 바꾼 커밋을 뺌(`behindManifestOnly` 1) |
|
|
117
|
+
| 검사 신호·등급 A | 엔진 결과 픽스처, 통과한 실행 뒤 build | stale·A 0 | ok·A 1 | JUnit 최상위 testcase 합산과 결과 JSON |
|
|
118
|
+
| | 같은 픽스처, 통과 뒤 작업 문서만 바꾼 커밋 | stale·A 0 | ok·A 1 | 최신 판정이 커밋 일치에서 "결과 커밋 뒤 문서 경로 밖 변경 없음"으로 바뀜 |
|
|
119
|
+
| | 같은 픽스처, 통과 뒤 코드를 바꾼 커밋 | stale·A 0 | stale·A 0 | 그대로 |
|
|
120
|
+
| 검사 개수 읽기 상태 | 실사용 프로젝트 사본 | 없음 | `counts.reading.tests` partial | 제목이 템플릿 문자열인 검사 호출 1파일 |
|
|
121
|
+
| check 경고 수(오류 수·종료 코드는 그대로 0) | 실사용 프로젝트 사본 | 2 | 87 | `router.hookapi-redundant` 54(묶음 줄 한 줄), `tasks.ambiguous-ref` 29, 완료 작업 열린 질문 1, 번호 참조 대상 없음 1, 고아 줄 2(API 1·검사 1) |
|
|
122
|
+
| | 스펙 작업 코퍼스 | build 실패(`semantic` 키 없음) | 17(오류 0) | `tasks.questions-open-done` 9, `tasks.unread-definition` 6, `tasks.questions-unknown` 1, `tasks.stage-unknown` 1 |
|
|
123
|
+
| build 요약 경고(`signals.warnings`) | 실사용 프로젝트 사본 | 0 | 1 | 여정 refs의 잔여 질문 번호가 정의로 풀리지 않음(아래 알려진 한계) |
|
|
124
|
+
|
|
125
|
+
등급 A의 뜻이 바뀐다. 1.1.1은 JUnit 결과의 커밋이 main HEAD와 같을 때만 A였다. 1.2.0은 결과 커밋이 HEAD의 조상이고 그 뒤 설정의 문서 경로 밖을 바꾼 커밋과 실행 때 변경이 없으면 A다. 그래서 작업 문서·판정 파일·여정만 바꾼 커밋 뒤에도 A가 남고, 코드·CI·설정 파일을 바꾼 커밋 뒤에는 다시 검사를 돌려야 A다. 오래된 결과를 지금 HEAD로 `--import`하면 최신으로 보이므로 러너 바로 뒤에 가져온다.
|
|
126
|
+
|
|
127
|
+
### 업그레이드하면 check가 실패할 수 있는 항목
|
|
128
|
+
|
|
129
|
+
새 경고는 종료 코드를 바꾸지 않는다. 다음 경우에만 1.1.1에서 통과하던 `check`가 1이 될 수 있다.
|
|
130
|
+
|
|
131
|
+
- 판정 파일을 둔 프로젝트: `map/judgments/*.json`의 JSON·필드 형식 위반, 없는 작업 폴더·`planFile`·근거 파일, 20자 미만이거나 여러 줄에 걸린 근거 조각, 번호가 없는 근거 줄은 `judgment.invalid`(error)다. 판정 파일이 없는 프로젝트는 해당 없다.
|
|
132
|
+
- 프로젝트 어댑터가 `g.issue`에 넷째 인자를 넘기던 경우: 1.1.1은 무시했지만 1.2.0은 형식을 검사해, 모르는 키·틀린 코드 모양·코드 표와 다른 수준이면 `throw`하고 그 어댑터가 failed(오류)가 된다.
|
|
133
|
+
- 여정 `refs`의 한정 참조(`<폴더>#<번호>`): 그 폴더가 번호를 정의하지 않았으면 "참조 미해결" 오류다. 1.1.1에는 이 모양이 없어 새로 쓴 참조에만 해당한다.
|
|
134
|
+
- `check --strict`를 CI에 배선하면 `tasks.*`·`judgment.*` 경고가 오류가 된다(옵트인).
|
|
135
|
+
- `livemap init`을 다시 돌려 커밋 전 훅을 설치하면 `check` 자체는 그대로지만, 스테이징한 작업 문서에 걸린 `tasks.*`·`judgment.*` 문제가 있는 커밋이 멈춘다.
|
|
136
|
+
|
|
137
|
+
### 알려진 한계
|
|
138
|
+
|
|
139
|
+
- 검사 어댑터(`tests`)가 검사 파일의 로컬 import를 따라갈 때 작은따옴표 import만 읽는다(router는 두 따옴표 모두 읽음).
|
|
140
|
+
- 화면 import 닫힘 밖 모듈의 경로 리터럴(예: 공용 요청 클라이언트의 세션 확인)은 어느 화면에도 붙지 않고 수도 남기지 않는다.
|
|
141
|
+
- 닫힘이 파일 단위라 공유 컴포넌트가 가진 호출은 그 컴포넌트를 쓰는 모든 화면에 붙는다. 변수로 조립한 경로는 잡히지 않고 그 API는 고아 경고로 드러난다.
|
|
142
|
+
- 라우트 페이지가 아닌 틀(레이아웃) 컴포넌트의 호출은 화면 호출로 세지 않아, 그 API가 고아로 남을 수 있다.
|
|
143
|
+
- `execution/`에 헤더만 있는 실행 기록 파일이 있어도 작업 단계가 "실행"이다(1.1.1 단계 규칙 그대로). 계획 단계에서 실행 기록 틀을 만드는 워크플로는 실행 전에도 "실행"으로 보인다.
|
|
144
|
+
- 잔여 질문 번호(`OQ-28`)는 정의로 보지 않아 여정 `refs`의 대상으로 풀리지 않고 "번호 참조 대상 없음" 경고가 된다.
|
|
145
|
+
- 템플릿 문자열 검사 제목 판정은 한 줄 단위라, `test(` 다음 줄에서 제목이 시작하는 호출은 `partial`로 잡지 못한다.
|
|
146
|
+
- 설정 루트가 git 저장소의 하위 폴더이면 배포 `behind`는 그 폴더를 건드린 커밋만 세고 `behindManifestOnly`에 폴더 밖 커밋이 섞인다.
|
|
147
|
+
|
|
5
148
|
## [1.1.1] - 2026-09-17
|
|
6
149
|
|
|
7
150
|
작업 어댑터가 계획 항목·결정·열린 질문을 파일 하나에서만 센다. 생성물의 키와 형식은 그대로고 값만 바뀐다.
|
package/README.md
CHANGED
|
@@ -9,21 +9,23 @@ Project status board engine. It scans a repository (routes, API handlers, migrat
|
|
|
9
9
|
```bash
|
|
10
10
|
npm i -D -E @pghoya2956/livemap
|
|
11
11
|
npm i -D -E @playwright/test@1.63.0 # 화면 예산 검사를 쓸 때만
|
|
12
|
-
npx --no livemap init # map/ 초안·.gitignore·npm
|
|
12
|
+
npx --no livemap init # map/ 초안·.gitignore·npm 스크립트·커밋 전 훅
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
`init`이 만드는 것: `map/config.json`, `map/semantic/journeys.json`, `map/README.md`, `map/captures/README.md`, `.gitignore`의 `map/.out/`, npm 스크립트 `map`·`map:check`·`map:serve`·`map:export`·`test:report`(Playwright가 있으면 `map:budget`). 다시 실행하면 아무것도 바꾸지 않는다.
|
|
15
|
+
`init`이 만드는 것: `map/config.json`, `map/semantic/journeys.json`, `map/README.md`, `map/captures/README.md`, `.gitignore`의 `map/.out/`, npm 스크립트 `map`·`map:check`·`map:serve`·`map:export`·`test:report`(Playwright가 있으면 `map:budget`), 커밋 전 훅 `.githooks/pre-commit`과 `git config core.hooksPath .githooks`(1.2.0, 아래 「커밋 전 훅」). 다시 실행하면 아무것도 바꾸지 않는다.
|
|
16
16
|
|
|
17
17
|
## 명령
|
|
18
18
|
|
|
19
19
|
| 명령 | 하는 일 |
|
|
20
20
|
|---|---|
|
|
21
21
|
| `livemap build [--out map/.out]` | 스캔 → `graph.json`·`data.json`·`overview.json` |
|
|
22
|
-
| `livemap check` | 정합 검사. 오류가 있으면 exit 1 |
|
|
22
|
+
| `livemap check [--json] [--strict]` | 정합 검사. 오류가 있으면 exit 1. `--json`은 stdout에 이슈 JSON만, `--strict`는 `tasks.*`·`judgment.*` 경고도 오류로 센다([docs/issue-codes.md](docs/issue-codes.md)) |
|
|
23
|
+
| `livemap check --staged` | 커밋 전 훅용. 스테이징한 작업 문서·판정 파일에 걸린 문제만 오류로 센다 |
|
|
23
24
|
| `livemap serve [--port 4180]` | `http://127.0.0.1:4180/map/`, 요청마다 재빌드(5초 캐시) |
|
|
24
25
|
| `livemap serve --static <dir>` | export 폴더를 재빌드 없이 같은 배치로 |
|
|
25
26
|
| `livemap export <dir>` | 화면·서체·캡처·생성물을 `/map/` 배치 그대로 한 폴더에 |
|
|
26
|
-
| `livemap test-report` | 단위 검사를 JUnit으로(`config.tests`) |
|
|
27
|
+
| `livemap test-report` | 단위 검사를 JUnit과 결과 JSON으로(`config.tests`) |
|
|
28
|
+
| `livemap test-report --import <파일> [--sha <커밋>]` | livemap 리포터·Playwright JSON·JUnit 출력을 결과 JSON에 넣는다([docs/test-results.md](docs/test-results.md)) |
|
|
27
29
|
| `livemap --version` | 버전 |
|
|
28
30
|
| 화면 예산 | `npx --no playwright test --config node_modules/@pghoya2956/livemap/budget/playwright.config.mjs` |
|
|
29
31
|
|
|
@@ -44,7 +46,7 @@ npx --no livemap init # map/ 초안·.gitignore·npm 스크립
|
|
|
44
46
|
"engine": 1,
|
|
45
47
|
"project": { "name": "ReefDesk", "host": "https://reefdesk.example.invalid" },
|
|
46
48
|
"adapters": ["router", "bff", "migrations", "tests", "wiki", "tasks", "roadmap", "git", "deploy", "testreport"],
|
|
47
|
-
"router": { "app": "web/src/App.tsx", "pagesDir": "web/src/pages", "localDirs": ["web/src/pages"], "mockPattern": "/mock'", "livePattern": "lib/queries"
|
|
49
|
+
"router": { "app": "web/src/App.tsx", "pagesDir": "web/src/pages", "localDirs": ["web/src/pages"], "mockPattern": "/mock'", "livePattern": "lib/queries" },
|
|
48
50
|
"bff": { "server": "app/server.mjs" },
|
|
49
51
|
"migrations": { "dir": "db/migrations" },
|
|
50
52
|
"tests": { "dir": "tests", "gatePattern": "ALLOW_DESTRUCTIVE", "report": "map/.out/junit.xml" },
|
|
@@ -56,13 +58,28 @@ npx --no livemap init # map/ 초안·.gitignore·npm 스크립
|
|
|
56
58
|
}
|
|
57
59
|
```
|
|
58
60
|
|
|
61
|
+
화면→API 호출은 1.2.0부터 화면 코드의 `/api/` 문자열 리터럴로 관측하므로 hook 이름 대응표 `router.hookApi`는 폐기 예정이다.
|
|
62
|
+
1.x 동안은 설정에 있으면 계속 읽고, `livemap check`가 항목마다 지워도 되는지(`router.hookapi-redundant`) 리터럴로 안 잡히는지(`router.hookapi-only`) 알린다.
|
|
63
|
+
2.0.0에서 키를 지운다([docs/migrate.md](docs/migrate.md)).
|
|
64
|
+
|
|
65
|
+
## 커밋 전 훅
|
|
66
|
+
|
|
67
|
+
`livemap init`은 git 저장소 루트에서 `.githooks/pre-commit`(`npx --no livemap check --staged`)을 만들고 `git config core.hooksPath .githooks`를 둔다. 작업 폴더 문서나 `map/judgments/` 판정 파일을 스테이징한 커밋에서, 그 파일에 걸린 `tasks.*`·`judgment.*` 문제가 있으면 종료 코드 1로 커밋을 멈춘다. 출력에는 문제 코드, 근거 줄, 판정 초안이 나온다. 에이전트 세션이 원문을 규칙대로 고치거나 판정 파일을 써서 스테이징하고 다시 커밋한다. `--no-verify`로 넘기지 않는다.
|
|
68
|
+
|
|
69
|
+
- 대상 파일이 없는 커밋(코드만 바꾼 커밋)은 빌드하지 않고 통과한다.
|
|
70
|
+
- `core.hooksPath`는 git 설정이라 클론마다 `livemap init`을 한 번 돌린다.
|
|
71
|
+
- 이미 다른 `core.hooksPath`나 훅 관리자(husky·lefthook·pre-commit), `.git/hooks/pre-commit`이 있으면 덮지 않고 그 설정에 넣을 한 줄을 출력한다.
|
|
72
|
+
- 규칙과 판정 파일은 [docs/semantic-authoring.md](docs/semantic-authoring.md) 「작업 문서」.
|
|
73
|
+
|
|
59
74
|
## 문서
|
|
60
75
|
|
|
61
76
|
| 문서 | 내용 |
|
|
62
77
|
|---|---|
|
|
63
78
|
| [docs/adapter-contract.md](docs/adapter-contract.md) | 어댑터 시그니처·노드·엣지·프로젝트 어댑터 |
|
|
64
|
-
| [docs/semantic-authoring.md](docs/semantic-authoring.md) | 여정 파일 작성 |
|
|
65
|
-
| [docs/semantic-schema.md](docs/semantic-schema.md) | 시맨틱 레이어
|
|
79
|
+
| [docs/semantic-authoring.md](docs/semantic-authoring.md) | 여정 파일·작업 문서·판정 파일 작성 |
|
|
80
|
+
| [docs/semantic-schema.md](docs/semantic-schema.md) | 시맨틱 레이어 모델, 읽기 상태, 생성물 필드 |
|
|
81
|
+
| [docs/issue-codes.md](docs/issue-codes.md) | check 이슈 코드·JSON 출력·커밋 전 훅 |
|
|
82
|
+
| [docs/test-results.md](docs/test-results.md) | 검사 결과 JSON·최신 판정·리포터·CI 배선 |
|
|
66
83
|
| [docs/hosting-and-csp.md](docs/hosting-and-csp.md) | export·정적 서빙·CSP·CI |
|
|
67
84
|
| [docs/view-budget.md](docs/view-budget.md) | 화면 예산 규칙 |
|
|
68
85
|
| [docs/migrate.md](docs/migrate.md) | major 이행 |
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// · 내비 5가 모두 보임 · #root 글자에 시스템 식별자 0 · 기능 지도 선택 → a.open → 단계 카드 → 단계 상세 3번 안
|
|
4
4
|
// · 패널 숨은 스크롤 0 · 모션 줄임에서 애니메이션 0 · 범례 상태 모양 4종 구분 · 모션 줄임 스크린샷
|
|
5
5
|
// 임계값은 map/config.json의 budget(viewport·maxPanels·maxRowsPerPanel·navItems)이다.
|
|
6
|
+
// 1.3.0: 로드맵 화면 절(-g 로드맵)과 캡처 패널 절(-g 캡처)을 더한다. 로드맵은 개요 임계값을 물려받지 않고 개수 상한 없이 성질만 잰다.
|
|
6
7
|
import { test, expect } from '@playwright/test';
|
|
7
8
|
import { readFileSync } from 'node:fs';
|
|
8
9
|
import { resolve } from 'node:path';
|
|
@@ -120,3 +121,200 @@ test.describe('모션 줄임', () => {
|
|
|
120
121
|
await page.screenshot({ path: resolve(process.cwd(), 'map/.out/overview-1440.png') });
|
|
121
122
|
});
|
|
122
123
|
});
|
|
124
|
+
|
|
125
|
+
// ---- 로드맵 화면(1.3.0, DEC-26·DEC-44): 개요 임계값을 물려받지 않고 개수 상한 없이 성질만 잰다. data.roadmap이 비면 건너뛴다 ----
|
|
126
|
+
// 기대값은 data.json에서 이 파일 안에서 계산한다(패키지에 ui/가 없어 화면 모듈을 부르지 못한다). 규칙은 ui/lib/tree.js와 같다:
|
|
127
|
+
// 있는 선행만 선, 잠김 = 완료·진행이 아니고 선행 중 미완, 고른 길 = 고른 항목과 조상끼리·고른 항목과 자손끼리의 선.
|
|
128
|
+
const loadData = (page) => page.evaluate(() => fetch('data/data.json', { cache: 'no-store' }).then((r) => r.json()));
|
|
129
|
+
const openRoadmap = async (page, hash = '#/roadmap') => {
|
|
130
|
+
await page.goto(`/map/${hash}`);
|
|
131
|
+
await page.waitForSelector('[data-screen="roadmap"]', { timeout: 10_000 });
|
|
132
|
+
await page.evaluate(() => document.fonts.ready);
|
|
133
|
+
return loadData(page);
|
|
134
|
+
};
|
|
135
|
+
function roadmapModel(d) {
|
|
136
|
+
const items = [...d.roadmap].sort((a, b) => a.order - b.order);
|
|
137
|
+
const byId = new Map(items.map((m) => [m.id, m]));
|
|
138
|
+
const deps = new Map(items.map((m) => [m.id, [...new Set((m.deps || []).map((x) => (typeof x === 'string' ? x : x.id)))].filter((x) => byId.has(x))]));
|
|
139
|
+
const children = new Map(items.map((m) => [m.id, []]));
|
|
140
|
+
for (const m of items) for (const x of deps.get(m.id)) children.get(x).push(m.id);
|
|
141
|
+
const edges = items.flatMap((m) => deps.get(m.id).map((x) => `${x}>${m.id}`));
|
|
142
|
+
const locked = items.filter((m) => !['완료', '진행'].includes(m.status) && deps.get(m.id).some((x) => byId.get(x).status !== '완료'));
|
|
143
|
+
const reach = (id, next) => { const seen = new Set(); const st = [...next.get(id)]; while (st.length) { const x = st.pop(); if (x === id || seen.has(x)) continue; seen.add(x); st.push(...next.get(x)); } return seen; };
|
|
144
|
+
const pathEdges = (id) => {
|
|
145
|
+
const up = new Set([id, ...reach(id, deps)]), down = new Set([id, ...reach(id, children)]);
|
|
146
|
+
return edges.filter((e) => { const [a, b] = e.split('>'); return (up.has(a) && up.has(b)) || (down.has(a) && down.has(b)); });
|
|
147
|
+
};
|
|
148
|
+
return { items, byId, deps, children, edges, locked, pathEdges };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
test.describe('로드맵 화면', () => {
|
|
152
|
+
test('SC-1·SC-2 로드맵: CSP 아래 오류 없이 노드 수·선 수가 자료와 같고 같은 열은 같은 x에 선다', async ({ page }) => {
|
|
153
|
+
const errors = [];
|
|
154
|
+
page.on('pageerror', (e) => errors.push(String(e.message)));
|
|
155
|
+
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
|
|
156
|
+
await page.addInitScript(() => document.addEventListener('securitypolicyviolation', (e) => console.error(`CSP ${e.violatedDirective} ${e.blockedURI}`)));
|
|
157
|
+
const d = await openRoadmap(page);
|
|
158
|
+
test.skip(!(d.roadmap || []).length, '로드맵 항목이 없다');
|
|
159
|
+
await page.waitForSelector('.rt-node');
|
|
160
|
+
const M = roadmapModel(d);
|
|
161
|
+
expect(await page.locator('.rt-node').count(), '노드 수').toBe(M.items.length);
|
|
162
|
+
expect((await page.$$eval('.rt-edge', (es) => es.map((e) => e.dataset.edge))).sort(), '선').toEqual([...M.edges].sort());
|
|
163
|
+
// 열(.rt-col)마다 노드의 왼쪽 x가 하나이고 열끼리는 다르다
|
|
164
|
+
const cols = await page.$$eval('.rt-col', (cs) => cs.map((c) => [...new Set([...c.querySelectorAll('.rt-node')].map((n) => Math.round(n.getBoundingClientRect().left)))]));
|
|
165
|
+
for (const [i, xs] of cols.entries()) expect(xs.length, `열 ${i} 노드 x ${xs}`).toBeLessThanOrEqual(1);
|
|
166
|
+
const xs = cols.filter((c) => c.length).map((c) => c[0]);
|
|
167
|
+
expect(new Set(xs).size, '열끼리 x가 겹친다').toBe(xs.length);
|
|
168
|
+
const left = Object.fromEntries(await page.$$eval('.rt-node', (ns) => ns.map((n) => [n.dataset.id, n.getBoundingClientRect().left])));
|
|
169
|
+
if (!(d.milestones || []).length) {
|
|
170
|
+
// 층 모드: 선행은 항목보다 왼쪽 열이다(순환 선은 뺀다)
|
|
171
|
+
const cyc = new Set(await page.$$eval('.rt-edge.is-cyc', (es) => es.map((e) => e.dataset.edge)));
|
|
172
|
+
for (const e of M.edges.filter((x) => !cyc.has(x))) { const [a, b] = e.split('>'); expect(left[a], `${e} 선행이 오른쪽`).toBeLessThan(left[b]); }
|
|
173
|
+
} else {
|
|
174
|
+
// 마일스톤 모드: 같은 마일스톤(목록에 없는 id는 미배정 하나로) 항목은 같은 열이다
|
|
175
|
+
const known = new Set(d.milestones.map((m) => m.id));
|
|
176
|
+
const group = new Map();
|
|
177
|
+
for (const m of M.items) { const k = known.has(m.milestone) ? m.milestone : ''; group.set(k, [...(group.get(k) || []), Math.round(left[m.id])]); }
|
|
178
|
+
for (const [k, ls] of group) expect(new Set(ls).size, `마일스톤 ${k || '없음'} 열이 갈림`).toBe(1);
|
|
179
|
+
}
|
|
180
|
+
expect(errors, errors.join('\n')).toEqual([]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('SC-3 로드맵: 잠긴 노드 이름에 "잠김"과 막는 선행 제목이 있다', async ({ page }) => {
|
|
184
|
+
const d = await openRoadmap(page);
|
|
185
|
+
test.skip(!(d.roadmap || []).length, '로드맵 항목이 없다');
|
|
186
|
+
await page.waitForSelector('.rt-node');
|
|
187
|
+
const M = roadmapModel(d);
|
|
188
|
+
const names = Object.fromEntries(await page.$$eval('.rt-node', (ns) => ns.map((n) => [n.dataset.id, n.getAttribute('aria-label') || ''])));
|
|
189
|
+
expect(Object.values(names).filter((n) => n.includes('잠김')).length, '잠김 수').toBe(M.locked.length);
|
|
190
|
+
for (const m of M.locked) {
|
|
191
|
+
const blocking = M.deps.get(m.id).filter((x) => M.byId.get(x).status !== '완료');
|
|
192
|
+
for (const x of blocking) expect(names[m.id], `${m.id} 이름에 선행 ${x}`).toContain(M.byId.get(x).title);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('SC-4 로드맵: 노드를 누르면 그 항목의 조상·자손 사이 선만 밝아지고 카드가 문서에 하나다', async ({ page }) => {
|
|
197
|
+
const d = await openRoadmap(page);
|
|
198
|
+
test.skip(!(d.roadmap || []).length, '로드맵 항목이 없다');
|
|
199
|
+
await page.waitForSelector('.rt-node');
|
|
200
|
+
const M = roadmapModel(d);
|
|
201
|
+
// 선이 가장 많이 켜지는 항목을 고른다(선이 없으면 첫 항목)
|
|
202
|
+
const pick = [...M.items].sort((a, b) => M.pathEdges(b.id).length - M.pathEdges(a.id).length)[0];
|
|
203
|
+
await page.locator(`.rt-node[data-id="${pick.id}"]`).click();
|
|
204
|
+
await page.waitForFunction((id) => location.hash === `#/roadmap/${id}`, pick.id);
|
|
205
|
+
// 해시가 바뀐 뒤 화면이 다시 그려질 때까지 기다린다(고른 노드에 .sel)
|
|
206
|
+
await expect(page.locator(`.rt-node.sel[data-id="${pick.id}"]`)).toHaveCount(1);
|
|
207
|
+
const on = await page.$$eval('.rt-edge.on', (es) => es.map((e) => e.dataset.edge));
|
|
208
|
+
expect(on.sort()).toEqual([...M.pathEdges(pick.id)].sort());
|
|
209
|
+
expect(await page.locator(`[id="rm-${pick.id}"]`).count(), 'rm-<id> 유일').toBe(1);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('로드맵: 노드가 뷰포트나 트리 상자 가로 스크롤 안에 있고 트리 상자에 세로 숨은 스크롤이 없다', async ({ page }) => {
|
|
213
|
+
const d = await openRoadmap(page);
|
|
214
|
+
test.skip(!(d.roadmap || []).length, '로드맵 항목이 없다');
|
|
215
|
+
await page.waitForSelector('.rt-node');
|
|
216
|
+
const outside = await page.evaluate(() => {
|
|
217
|
+
const box = document.querySelector('.rt-box'), b = box.getBoundingClientRect();
|
|
218
|
+
const lo = b.left - box.scrollLeft - 1, hi = b.left - box.scrollLeft + box.scrollWidth + 1;
|
|
219
|
+
return [...document.querySelectorAll('.rt-node')].filter((n) => { const r = n.getBoundingClientRect(); return !r.width || r.left < lo || r.right > hi; }).map((n) => n.dataset.id);
|
|
220
|
+
});
|
|
221
|
+
expect(outside, outside.join(', ')).toEqual([]);
|
|
222
|
+
// 개요와 같은 식: 계산값이 auto·scroll이어도 실제 넘침이 1px 이하면 숨은 스크롤이 아니다(가로 스크롤 상자는 브라우저가 overflow-y를 auto로 올린다)
|
|
223
|
+
const hidden = await page.evaluate(() => {
|
|
224
|
+
const set = new Set(document.querySelectorAll('[data-screen="roadmap"] .panel'));
|
|
225
|
+
document.querySelectorAll('[data-screen="roadmap"] .panel *').forEach((e) => { const o = getComputedStyle(e).overflowY; if (o === 'auto' || o === 'scroll') set.add(e); });
|
|
226
|
+
return [...set].filter((e) => { const c = getComputedStyle(e).webkitLineClamp; return !c || c === 'none'; })
|
|
227
|
+
.filter((e) => e.scrollHeight - e.clientHeight > 1).map((e) => `${e.className} +${e.scrollHeight - e.clientHeight}px`);
|
|
228
|
+
});
|
|
229
|
+
expect(hidden, hidden.join('\n')).toEqual([]);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('SC-12 로드맵: Tab으로 첫 노드에 닿고 화살표 ←첫 선행 →첫 후속 ↑↓같은 열 이웃으로 옮긴다', async ({ page }) => {
|
|
233
|
+
const d = await openRoadmap(page);
|
|
234
|
+
test.skip(!(d.roadmap || []).length, '로드맵 항목이 없다');
|
|
235
|
+
await page.waitForSelector('.rt-node');
|
|
236
|
+
const M = roadmapModel(d);
|
|
237
|
+
const first = await page.locator('.rt-node').first().getAttribute('data-id');
|
|
238
|
+
let reached = null;
|
|
239
|
+
for (let i = 0; i < 80 && !reached; i += 1) { await page.keyboard.press('Tab'); reached = await page.evaluate(() => document.activeElement?.closest('.rt-node')?.dataset.id || null); }
|
|
240
|
+
expect(reached, 'Tab으로 노드에 닿지 않는다').toBe(first);
|
|
241
|
+
const column = Object.fromEntries((await page.$$eval('.rt-col', (cs) => cs.map((c) => [...c.querySelectorAll('.rt-node')].map((n) => n.dataset.id)))).flatMap((ids) => ids.map((id, i) => [id, [ids[i - 1], ids[i + 1]]])));
|
|
242
|
+
const expectMove = (id, key) => ({ ArrowLeft: M.deps.get(id)[0], ArrowRight: M.children.get(id)[0], ArrowUp: column[id][0], ArrowDown: column[id][1] }[key] ?? id);
|
|
243
|
+
// 네 방향이 모두 움직이는 항목이 있으면 그것, 없으면 가장 많이 움직이는 항목
|
|
244
|
+
const score = (id) => ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].filter((k) => expectMove(id, k) !== id).length;
|
|
245
|
+
const from = [...M.items].map((m) => m.id).sort((a, b) => score(b) - score(a))[0];
|
|
246
|
+
const moved = [];
|
|
247
|
+
for (const key of ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']) {
|
|
248
|
+
await page.locator(`.rt-node[data-id="${from}"]`).focus();
|
|
249
|
+
await page.keyboard.press(key);
|
|
250
|
+
const now = await page.evaluate(() => document.activeElement?.dataset.id);
|
|
251
|
+
expect(now, `${from} ${key}`).toBe(expectMove(from, key));
|
|
252
|
+
if (now !== from) moved.push(key);
|
|
253
|
+
}
|
|
254
|
+
if (M.items.length > 1) expect(moved.length, '화살표로 움직인 방향이 없다').toBeGreaterThan(0);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test.describe('모션 줄임', () => {
|
|
258
|
+
test.use({ reducedMotion: 'reduce' });
|
|
259
|
+
test('로드맵: 모션 줄임에서 애니메이션이 없다', async ({ page }) => {
|
|
260
|
+
const d = await openRoadmap(page);
|
|
261
|
+
test.skip(!(d.roadmap || []).length, '로드맵 항목이 없다');
|
|
262
|
+
await page.waitForSelector('.rt-node');
|
|
263
|
+
await page.locator('.rt-node').first().click();
|
|
264
|
+
await page.waitForTimeout(300);
|
|
265
|
+
const names = await page.evaluate(() => document.getAnimations().map((a) => a.animationName || a.constructor.name));
|
|
266
|
+
expect(names, names.join(', ')).toEqual([]);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// ---- 캡처 패널(1.3.0, DEC-24·DEC-38~DEC-40·DEC-52): 누르기 전 5장 이하, 누른 기능 캡처만, 없으면 빈 상태, 범위를 바꾸면 1장째 ----
|
|
272
|
+
test.describe('캡처 패널', () => {
|
|
273
|
+
test.use({ reducedMotion: 'reduce' });
|
|
274
|
+
const overviewData = (page) => page.evaluate(() => fetch('data/overview.json', { cache: 'no-store' }).then((r) => r.json()));
|
|
275
|
+
const count = (o, id) => o.captures.filter((c) => c.journey === id).length;
|
|
276
|
+
const row = (page, o, id) => page.locator(`.jrow[aria-label^=${JSON.stringify(o.journeys.find((j) => j.id === id).title)}]`).first();
|
|
277
|
+
|
|
278
|
+
test('SC-14 캡처: 누르기 전 썸네일은 5장 이하다', async ({ page }) => {
|
|
279
|
+
await openOverview(page);
|
|
280
|
+
const o = await overviewData(page);
|
|
281
|
+
test.skip(!o.captures.length, '캡처가 없다');
|
|
282
|
+
expect(await page.locator('.capture .thumbs button').count()).toBeLessThanOrEqual(5);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test('SC-7·SC-8 캡처: 기능을 누르면 그 기능 캡처만 돌고, 캡처가 없는 기능은 "캡처 없음"', async ({ page }) => {
|
|
286
|
+
await openOverview(page);
|
|
287
|
+
const o = await overviewData(page);
|
|
288
|
+
const onMap = new Set(await page.$$eval('.jrow', (rs) => rs.map((r) => r.getAttribute('aria-label') || '')));
|
|
289
|
+
const shown = o.journeys.filter((j) => [...onMap].some((l) => l.startsWith(j.title)));
|
|
290
|
+
const withCaps = shown.filter((j) => count(o, j.id) > 0).sort((a, b) => count(o, b.id) - count(o, a.id))[0];
|
|
291
|
+
const without = shown.find((j) => count(o, j.id) === 0);
|
|
292
|
+
test.skip(!withCaps && !without, '지도에 누를 기능이 없다');
|
|
293
|
+
if (withCaps) {
|
|
294
|
+
await row(page, o, withCaps.id).click();
|
|
295
|
+
await expect(page.locator('.capture .thumbs button')).toHaveCount(count(o, withCaps.id));
|
|
296
|
+
await expect(page.locator('.capture .cnt')).toHaveText(new RegExp(`^1/${count(o, withCaps.id)}$`));
|
|
297
|
+
}
|
|
298
|
+
if (without) {
|
|
299
|
+
await row(page, o, without.id).click();
|
|
300
|
+
await expect(page.locator('.capture .live')).toContainText('캡처 없음');
|
|
301
|
+
await expect(page.locator('.capture .thumbs button')).toHaveCount(0);
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test('DEC-39 캡처: 범위를 바꾸면 장 번호가 1로 돌아간다', async ({ page }) => {
|
|
306
|
+
await openOverview(page);
|
|
307
|
+
const o = await overviewData(page);
|
|
308
|
+
const onMap = new Set(await page.$$eval('.jrow', (rs) => rs.map((r) => r.getAttribute('aria-label') || '')));
|
|
309
|
+
const many = o.journeys.filter((j) => [...onMap].some((l) => l.startsWith(j.title)) && count(o, j.id) >= 2);
|
|
310
|
+
test.skip(many.length < 1 || o.journeys.filter((j) => count(o, j.id) > 0).length < 2, '캡처 2장 이상 기능과 다른 캡처 기능이 필요하다');
|
|
311
|
+
const a = many[0];
|
|
312
|
+
const b = o.journeys.find((j) => j.id !== a.id && count(o, j.id) > 0 && [...onMap].some((l) => l.startsWith(j.title)));
|
|
313
|
+
test.skip(!b, '두 번째로 누를 캡처 기능이 지도에 없다');
|
|
314
|
+
await row(page, o, a.id).click();
|
|
315
|
+
await page.locator('.capture .thumbs button').nth(1).click();
|
|
316
|
+
await expect(page.locator('.capture .cnt')).toHaveText(/^2\//);
|
|
317
|
+
await row(page, o, b.id).click();
|
|
318
|
+
await expect(page.locator('.capture .cnt')).toHaveText(new RegExp(`^1/${count(o, b.id)}$`));
|
|
319
|
+
});
|
|
320
|
+
});
|
package/docs/adapter-contract.md
CHANGED
|
@@ -14,17 +14,23 @@ export default function name(g, fs, cfg) {
|
|
|
14
14
|
}
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
- `g` — 그래프. `g.add(kind, id, label, props, src)`, `g.link(fromKind, fromId, edgeKind, toKind, toId)`, `g.get`, `g.of(kind)`, `g.in`, `g.out`, `g.issue(level, label, message)`(1.1.0부터).
|
|
17
|
+
- `g` — 그래프. `g.add(kind, id, label, props, src)`, `g.link(fromKind, fromId, edgeKind, toKind, toId)`, `g.get`, `g.of(kind)`, `g.in`, `g.out`, `g.issue(level, label, message, detail?)`(1.1.0부터, 넷째 인자는 1.2.0부터).
|
|
18
18
|
- `fs` — 저장소 접근. `read(rel)`, `has(rel)`, `isDir(rel)`, `walk(dir, pred)`, `ls(dir)`, `git(...args)`(실패 시 빈 문자열), `hasGit()`, `resolveRef(name)`(main → origin/main → HEAD), `lastCommit(rel)`, `lineOf(text, needle)`.
|
|
19
19
|
- `cfg` — `map/config.json` 전체. 자기 키(`cfg.<name>`)만 읽고, 다른 어댑터의 키는 `?.`로 방어한다.
|
|
20
20
|
|
|
21
21
|
## 오류·경고 보고(g.issue)
|
|
22
22
|
|
|
23
|
-
어댑터가 읽은 사실에서 프로젝트 규칙 위반을 찾았으면 `g.issue(level, label, message)`로 낸다. 반환값 partial·throw는 "어댑터가 제대로 읽었나"를, `g.issue`는 "읽은 내용에 문제가 있나"를 알린다. 1.1.0부터 쓸 수 있다.
|
|
23
|
+
어댑터가 읽은 사실에서 프로젝트 규칙 위반을 찾았으면 `g.issue(level, label, message, detail?)`로 낸다. 반환값 partial·throw는 "어댑터가 제대로 읽었나"를, `g.issue`는 "읽은 내용에 문제가 있나"를 알린다. 세 인자 호출은 1.1.0부터, 넷째 인자는 1.2.0부터 쓸 수 있다.
|
|
24
24
|
|
|
25
25
|
```js
|
|
26
26
|
g.issue('warn', '로드맵', '결정 대기 30일 넘음: 결제 흐름');
|
|
27
27
|
g.issue('error', '여정 파일', '필수 키 없음: owner');
|
|
28
|
+
g.issue('warn', '작업 문서', '완료 작업에 닫히지 않은 잔여 질문 1', {
|
|
29
|
+
code: 'tasks.questions-open-done',
|
|
30
|
+
subject: { kind: 'task', id: '20260101-sample' },
|
|
31
|
+
anchors: [{ file: 'tasks/20260101-sample/spec/final.md', line: 12, excerpt: '| OQ-02 | 남은 질문 |' }],
|
|
32
|
+
resolutions: ['judge'],
|
|
33
|
+
});
|
|
28
34
|
```
|
|
29
35
|
|
|
30
36
|
- `level`은 `'error'` 또는 `'warn'`이다. 그 밖의 값이거나 `label`·`message`가 문자열이 아니면 `throw`하고, 그 어댑터는 failed가 된다. 다른 어댑터는 계속 돈다.
|
|
@@ -33,6 +39,37 @@ g.issue('error', '여정 파일', '필수 키 없음: owner');
|
|
|
33
39
|
- `livemap check`는 기존 검사 뒤에 error를 `✗ {label}: {message}`로, warn을 `△ {label}: {message}`로 출력한다. error는 종료 코드 1에 센다.
|
|
34
40
|
- 개요에는 나오지 않고 더보기 > 이 상황판(`#/more/about`)에 목록으로 나온다.
|
|
35
41
|
|
|
42
|
+
넷째 인자(1.2.0):
|
|
43
|
+
|
|
44
|
+
- 키는 `code`·`subject`·`anchors`·`resolutions`·`judgmentDraft`만 받는다. 모르는 키가 있거나 형식이 틀리면 `throw`하고 그 어댑터는 failed가 된다.
|
|
45
|
+
- `code`는 `<영역>.<이름>`(소문자·숫자·하이픈)이다. 엔진 코드 표(`issue-codes.md`)에 있는 코드는 표의 수준과 `level`이 같아야 한다. 프로젝트 코드는 표 밖 이름을 쓴다.
|
|
46
|
+
- `subject`는 `{ kind, id }` 또는 생략(`null`). `anchors`는 `{ file, line?, excerpt? }` 배열이고 `line`은 1 이상 정수이거나 생략(`null`, 파일 단위 근거)이다. `excerpt`는 엔진이 120 코드 포인트에서 자른다(`data.json`은 서빙되므로 긴 원문을 싣지 않는다).
|
|
47
|
+
- `resolutions`는 `source`·`judge`·`config`·`code`·`engine` 중에서 고른다. 생략하면 표의 처리 값, 표 밖 코드는 `[]`다.
|
|
48
|
+
- `judgmentDraft`는 판정 파일 초안 객체다. `check --json`과 `check --staged` 출력에 그대로 실린다.
|
|
49
|
+
- 넷째 인자를 준 문제만 `issues[]` 항목에 `code`·`subject`·`anchors`·`resolutions`(있으면 `judgmentDraft`)가 더해진다. 세 인자 호출의 항목 모양은 1.1.0 그대로이고, check에서는 코드 `adapter.issue`로 나온다.
|
|
50
|
+
|
|
51
|
+
## 읽기 상태(props.reading)
|
|
52
|
+
|
|
53
|
+
1.2.0부터 노드 값마다 어떻게 읽었는지를 적는다. 화면은 이 상태로 값 뒤에 "?"와 이유를 붙인다.
|
|
54
|
+
|
|
55
|
+
- `props.reading`은 `{ 필드: 상태 }`, `props.readingNotes`는 `{ 필드: 이유 문장 }`이다. 이유 문장에는 파일·줄이 들어갈 수 있어 개요(`overview.json`)에는 싣지 않는다.
|
|
56
|
+
- 상태 값은 일곱이다: `observed`(구조화된 출력이나 livemap 소유 형식), `rule`(규칙으로 읽었고 같은 범위의 후보 줄이 모두 읽힘), `judged`(판정 파일이 값을 채움), `partial`(안 읽힌 후보 줄이나 뜻 확인이 필요한 행이 있음), `stale`(판정 근거나 검사 결과가 낡음), `unknown`(소스가 없거나 형식 밖), `none`(대상 없음).
|
|
57
|
+
- 적지 않은 필드는 `rule`로 본다. 합계는 구성 요소 중 하나라도 `partial`·`stale`·`unknown`이면 `partial`이다.
|
|
58
|
+
- 프로젝트 어댑터는 `props.reading`에 직접 적어도 된다. 참조 어댑터는 `src/lib/reading.mjs`의 `setReading(node, field, value, note?)`를 쓴다.
|
|
59
|
+
- 참조 어댑터가 상태를 적는 필드: 작업 `plan`·`openQuestions`·`stage`(tasks), 검사 `count`(tests·testreport)·`lastRun`(testreport), 화면 `apis`(연결 단계), 배포 `behind`(deploy). 뜻은 `semantic-schema.md`.
|
|
60
|
+
|
|
61
|
+
## 화면 리터럴과 연결 단계(apiLiterals)
|
|
62
|
+
|
|
63
|
+
1.2.0 router 어댑터는 화면에서 API로 가는 호출을 대응표(`router.hookApi`) 대신 코드의 문자열 리터럴로 관측한다.
|
|
64
|
+
|
|
65
|
+
1. router는 페이지 파일에서 로컬 import를 따라가며(작은따옴표·큰따옴표, `import type` 제외, `export … from` 재수출 포함) 리터럴을 모은다. `router.localDirs` 안 파일은 파일 전체, `router.app` 폴더 안이지만 `localDirs` 밖인 모듈은 가져온 이름의 최상위 선언만(그 선언이 같은 모듈의 다른 최상위 선언을 쓰면 깊이 2까지) 넣는다. 이 확장 닫힘은 리터럴 추출에만 쓰고, 화면 `files`·`source`·`mockVia`·`fixedVia`와 git 변경 연결은 1.1.1 파일 닫힘 그대로다.
|
|
66
|
+
2. 따옴표·백틱 바로 뒤가 `/api/`인 문자열을 뽑아 `?`·`#` 뒤와 끝 `/`를 떼고, 조각 전체가 `${…}`면 `:param`, 조각 중간의 `${…}`는 앞 글자까지 남기고 열린 끝으로 둔다.
|
|
67
|
+
3. router는 화면 노드 `apiLiterals[]`에 `{ path, open?, file, line, matched }`를 적기만 한다. 어댑터 순서상 router가 bff보다 먼저 돌아 대응할 API 노드가 아직 없기 때문이다.
|
|
68
|
+
4. 모든 어댑터가 끝난 뒤 엔진 연결 단계(`src/link.mjs`)가 리터럴을 API 노드에 대응해 `calls` 엣지와 `matched`(맞은 API 노드 id 배열)를 채운다. 경로 조각 수가 같고 조각마다 같거나 한쪽이 `:이름`이면 맞고, 같은 경로의 메서드 노드는 모두 잇는다. 맞는 노드가 없으면 `router.unknown-api`다. 연결 단계는 `adapters[]`에 들지 않고, 실패하면 오류 이슈(`연결 단계: …`)로 남는다.
|
|
69
|
+
5. 설정에 `router.hookApi`가 있으면 router가 1.1.1처럼 노드와 엣지도 만들고(합집합), 화면 노드 `hookApiKeys`에 쓴 키를 적는다. 연결 단계가 키마다 `router.hookapi-redundant`(리터럴로도 나옴, 지워도 됨) 또는 `router.hookapi-only`(hookApi로만 나옴)를 한 건 낸다. `router.hookApi`는 2.0.0에서 지울 예정이다.
|
|
70
|
+
|
|
71
|
+
프로젝트 어댑터가 다른 방식으로 화면 호출을 찾으면 `calls` 엣지를 직접 이어도 된다. `apiLiterals`를 적으면 연결 단계가 같은 규칙으로 대응한다.
|
|
72
|
+
|
|
36
73
|
## 어디에 두나
|
|
37
74
|
|
|
38
75
|
엔진은 `config.adapters`의 이름마다 프로젝트 `map/adapters/<name>.mjs`를 먼저 찾고, 없으면 패키지에 딸린 참조 어댑터(`src/adapters/<name>.mjs`)를 쓴다. 프로젝트 파일이 참조 어댑터와 이름이 같으면 `build`·`check`가 "프로젝트 어댑터가 참조 어댑터를 가림: <name>" 한 줄을 알린다. 참조 어댑터의 결함은 엔진 저장소에서 고치고, 프로젝트만의 스택은 다른 이름의 프로젝트 어댑터로 둔다.
|
|
@@ -64,11 +101,22 @@ g.issue('error', '여정 파일', '필수 키 없음: owner');
|
|
|
64
101
|
|
|
65
102
|
로드맵 항목과 마일스톤의 노드 종류 이름은 1.x 동안 `milestone`·`release`이고, 2.0.0에서 `roadmapItem`·`milestone`으로 바꾼다(어댑터 계약 변경이라 major).
|
|
66
103
|
|
|
104
|
+
로드맵 화면의 트리(1.3.0)는 이 두 종류의 속성만으로 열을 정한다. 로드맵을 직접 만드는 어댑터는 아래를 지킨다.
|
|
105
|
+
|
|
106
|
+
| 화면이 읽는 것 | 노드·속성 | 규칙 |
|
|
107
|
+
|---|---|---|
|
|
108
|
+
| 열 모드 | `release` 노드 수 | 한 건이라도 있으면 열 하나가 마일스톤 하나, 없으면 열 하나가 선행 깊이 한 단계(`semantic-authoring.md` 「로드맵 화면의 열」 문안 1) |
|
|
109
|
+
| 열 순서 | `release`의 `props.order` | 마일스톤 절이 나온 차례. 화면은 선행에 맞춰 다시 정렬하지 않는다(문안 2) |
|
|
110
|
+
| 항목의 열 | `milestone`의 `props.milestone` | 마일스톤 id. 비었거나 `release`에 없는 id면 맨 오른쪽 "마일스톤 없음" 열(문안 3) |
|
|
111
|
+
| 선 | `milestone`의 `props.deps` | 로드맵 항목 id 목록. 없는 id는 선을 그리지 않고 엔진이 `선행 항목 없음` 문장을 낸다 |
|
|
112
|
+
|
|
113
|
+
역행·순환 문장은 어댑터가 아니라 엔진(`src/derive.mjs`)이 `roadmap[].problems`에 싣고 `check`가 경고로 낸다. 역행 문장 틀은 `마일스톤 순서 역행: {선행 id}({마일스톤}) → {항목 id}({마일스톤})`, 순환은 `선행 순환: {id} → {id} → …`다(코드는 `issue-codes.md` 「1.3.0 새 코드」).
|
|
114
|
+
|
|
67
115
|
엣지: `shows`(step→screen, 파생이 만든다), `calls`(screen→api), `invokes`(api→function), `touches`(function→table), `covers`(test→screen|api|function), `changes`(commit→screen|api|migration), `defines`(task→decision), `contains`(migration→table|function, release→milestone), `tracks`(milestone→task). 새 종류가 필요하면 엔진 저장소의 `src/lib/graph.mjs` 목록에 더한다(minor 릴리스). 화면이 그 종류를 그리려면 `src/derive.mjs`와 화면 원본 `ui/`도 손봐야 하므로, 먼저 기존 종류로 표현할 수 없는지 본다.
|
|
68
116
|
|
|
69
117
|
## 순서
|
|
70
118
|
|
|
71
|
-
`config.adapters` 순서로 돈다. `tests`·`git`은 `screen`·`api`가 있어야 covers·changes를 잇고, `testreport`는 `git`이 만든 `deploy:head`로
|
|
119
|
+
`config.adapters` 순서로 돈다. `tests`·`git`은 `screen`·`api`가 있어야 covers·changes를 잇고, `testreport`는 `git`이 만든 `deploy:head`로 검사 결과가 최신인지 판정한다(`test-results.md`). 새 어댑터가 다른 어댑터의 노드에 기대면 그 뒤에 둔다. 모든 어댑터가 끝나면 엔진 연결 단계가 화면 리터럴을 API 노드에 잇는다(위 「화면 리터럴과 연결 단계」). 어댑터 순서를 바꾸지 않아도 되므로 기존 설정의 `adapters` 순서는 그대로 둔다.
|
|
72
120
|
|
|
73
121
|
## 골격
|
|
74
122
|
|
|
@@ -109,7 +157,7 @@ test('openapi: paths → api 노드', () => {
|
|
|
109
157
|
| Express·Fastify·Hono | OpenAPI 문서(있으면) | 라우터 등록 호출 `app.get('/x'` 정규식 |
|
|
110
158
|
| Rails·Django·Spring | `rails routes`/`manage.py show_urls`/Actuator 덤프를 CI에서 파일로 저장 → 파일 어댑터 | 소스 파싱은 tree-sitter |
|
|
111
159
|
| Prisma·Django ORM·Alembic | migration 폴더 관례 | 스키마 파일(`schema.prisma`) |
|
|
112
|
-
| Jest·pytest·Go test | JUnit XML(거의 모든 러너가 낸다) → testreport | 소스에서 라우트 문자열 grep |
|
|
160
|
+
| Jest·pytest·Go test | JUnit XML(거의 모든 러너가 낸다)을 `livemap test-report --import`로 결과 JSON에 넣음 → testreport | 소스에서 라우트 문자열 grep |
|
|
113
161
|
| 작업 문서가 tasks/가 아님 | tasks 어댑터의 `dir`·절 이름만 바꿈 | Linear·GitHub Issues는 CI에서 JSON 덤프 → 파일 어댑터 |
|
|
114
162
|
|
|
115
163
|
원칙은 "스택이 이미 내놓는 산출물을 읽는다"이다. 산출물은 형식이라 언어를 넘어 재사용되고, 소스 정규식은 그 프로젝트에서만 산다.
|