@pghoya2956/livemap 1.1.0 → 1.2.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 +115 -0
- package/README.md +24 -7
- package/docs/adapter-contract.md +41 -4
- package/docs/issue-codes.md +150 -0
- package/docs/migrate.md +15 -0
- package/docs/semantic-authoring.md +107 -3
- package/docs/semantic-schema.md +65 -8
- package/docs/test-results.md +189 -0
- package/package.json +1 -1
- package/site/map.css +25 -0
- package/site/map.js +7 -7
- package/src/adapters/deploy.mjs +19 -8
- package/src/adapters/router.mjs +34 -22
- package/src/adapters/tasks.mjs +322 -22
- package/src/adapters/testreport.mjs +115 -19
- package/src/adapters/tests.mjs +5 -1
- package/src/check.mjs +96 -28
- package/src/cli.mjs +68 -13
- package/src/derive.mjs +76 -14
- 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 +161 -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
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// 판정 파일: 에이전트 세션이 과거 작업 문서의 뜻을 판정해 적는 map/judgments/<작업 폴더>.json을 읽고 원문과 대조한다.
|
|
2
|
+
// 엔진은 읽기만 한다(원문과 판정 파일을 고치지 않고 LLM·네트워크를 부르지 않는다). 줄 번호는 근거로 쓰지 않는다.
|
|
3
|
+
//
|
|
4
|
+
// 필드: schema 1, task(파일 이름과 같은 작업 폴더), by agent·human, planFile(작업 폴더 기준 md), lines[{ at, as definition·ignore, id }],
|
|
5
|
+
// questions.none { at } 또는 questions.items[{ id, state open·resolved, at }], note. 그 밖의 키는 무시한다(체크 여부·장부 상태·단계는 판정 대상이 아니다).
|
|
6
|
+
// 근거 조각 at { file 프로젝트 기준 경로, text 한 줄 안의 20 코드 포인트 이상 조각 }:
|
|
7
|
+
// 조각을 가진 줄이 하나면 확인(ok), 0개면 stale(원문이 바뀜), 둘 이상이면 invalid. 정의·질문 판정은 그 줄에 id가 있어야 한다.
|
|
8
|
+
// 파일 모양이 틀리면(JSON·스키마·폴더·planFile) 파일 전체를 적용하지 않고, 근거 조각이 틀린 항목은 그 항목만 적용하지 않는다.
|
|
9
|
+
export const JUDGMENTS_DIR = 'map/judgments';
|
|
10
|
+
export const TEXT_MIN = 20;
|
|
11
|
+
const BY = ['agent', 'human'];
|
|
12
|
+
const AS = ['definition', 'ignore'];
|
|
13
|
+
const STATES = ['open', 'resolved'];
|
|
14
|
+
const LABEL = '판정 파일';
|
|
15
|
+
|
|
16
|
+
const isObj = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
|
|
17
|
+
const nonEmpty = (s) => typeof s === 'string' && s.trim().length > 0;
|
|
18
|
+
// 프로젝트 안 상대 경로만 받는다
|
|
19
|
+
const safeRel = (p) => nonEmpty(p) && !p.startsWith('/') && !/^[A-Za-z]:/.test(p) && !p.split(/[\\/]/).includes('..');
|
|
20
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
21
|
+
// 줄에 번호가 따로 있는지: 앞뒤가 영숫자(앞은 하이픈 포함)가 아니다. DEC-1은 DEC-12에 맞지 않는다
|
|
22
|
+
export const hasId = (line, id) => new RegExp(`(^|[^A-Za-z0-9-])${escapeRe(id)}(?![A-Za-z0-9])`).test(line);
|
|
23
|
+
|
|
24
|
+
// 근거 조각 대조: { state: 'ok'|'stale'|'invalid', file, line?, lines?, reason? }
|
|
25
|
+
export function locate(fs, at, id, linesOf) {
|
|
26
|
+
if (!isObj(at) || !safeRel(at.file) || typeof at.text !== 'string') return { state: 'invalid', file: isObj(at) && typeof at.file === 'string' ? at.file : null, reason: '근거 조각 at은 { file: 프로젝트 기준 경로, text: 문자열 }' };
|
|
27
|
+
const { file, text } = at;
|
|
28
|
+
if (/[\r\n]/.test(text)) return { state: 'invalid', file, reason: `근거 조각이 여러 줄: ${file}` };
|
|
29
|
+
if ([...text].length < TEXT_MIN) return { state: 'invalid', file, reason: `근거 조각이 ${TEXT_MIN}자 미만: ${file}` };
|
|
30
|
+
if (!fs.has(file) || fs.isDir(file)) return { state: 'invalid', file, reason: `근거 파일 없음: ${file}` };
|
|
31
|
+
const lines = linesOf(file);
|
|
32
|
+
const hits = [];
|
|
33
|
+
lines.forEach((l, i) => { if (l.includes(text)) hits.push(i + 1); });
|
|
34
|
+
if (!hits.length) return { state: 'stale', file, reason: `근거 조각을 가진 줄이 원문에 없음: ${file}` };
|
|
35
|
+
if (hits.length > 1) return { state: 'invalid', file, lines: hits, reason: `근거 조각을 가진 줄이 ${hits.length}개: ${file}` };
|
|
36
|
+
if (id != null && !hasId(lines[hits[0] - 1], id)) return { state: 'invalid', file, lines: hits, reason: `근거 줄에 번호 ${id} 없음: ${file}:${hits[0]}` };
|
|
37
|
+
return { state: 'ok', file, line: hits[0] };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 파일 모양 검사: 문제 문장 목록
|
|
41
|
+
function shapeProblems(j, stem) {
|
|
42
|
+
const out = [];
|
|
43
|
+
if (j.schema !== 1) out.push('schema는 1');
|
|
44
|
+
if (j.task !== stem) out.push(`task는 파일 이름과 같은 작업 폴더 이름(${stem})`);
|
|
45
|
+
if (!BY.includes(j.by)) out.push(`by는 ${BY.join('·')}`);
|
|
46
|
+
if (j.planFile != null && !(safeRel(j.planFile) && j.planFile.endsWith('.md'))) out.push('planFile은 작업 폴더 기준 md 경로');
|
|
47
|
+
if (j.note != null && typeof j.note !== 'string') out.push('note는 문장');
|
|
48
|
+
if (j.lines != null) {
|
|
49
|
+
if (!Array.isArray(j.lines)) out.push('lines는 배열');
|
|
50
|
+
else j.lines.forEach((e, i) => {
|
|
51
|
+
if (!isObj(e)) { out.push(`lines[${i}]는 객체`); return; }
|
|
52
|
+
if (!AS.includes(e.as)) out.push(`lines[${i}].as는 ${AS.join('·')}`);
|
|
53
|
+
if (e.as === 'definition' && !nonEmpty(e.id)) out.push(`lines[${i}]: definition은 id 필수`);
|
|
54
|
+
if (e.id != null && !nonEmpty(e.id)) out.push(`lines[${i}].id는 문자열`);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
if (j.questions != null) {
|
|
58
|
+
const q = j.questions;
|
|
59
|
+
if (!isObj(q)) out.push('questions는 객체');
|
|
60
|
+
else {
|
|
61
|
+
if (q.none != null && q.items != null) out.push('questions.none과 questions.items는 함께 쓰지 않는다');
|
|
62
|
+
if (q.none != null && !isObj(q.none)) out.push('questions.none은 { at }');
|
|
63
|
+
if (q.items != null) {
|
|
64
|
+
if (!Array.isArray(q.items)) out.push('questions.items는 배열');
|
|
65
|
+
else q.items.forEach((e, i) => {
|
|
66
|
+
if (!isObj(e)) { out.push(`questions.items[${i}]는 객체`); return; }
|
|
67
|
+
if (!nonEmpty(e.id)) out.push(`questions.items[${i}].id는 원문 표기 문자열`);
|
|
68
|
+
if (!STATES.includes(e.state)) out.push(`questions.items[${i}].state는 ${STATES.join('·')}`);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 판정 파일을 모두 읽는다. taskNames: 작업 폴더 이름 집합, tasksDir: 설정 tasks.dir.
|
|
77
|
+
// 돌려주는 값: { byTask: Map(작업 → 판정), issues: [[level, code, message, detail]] }
|
|
78
|
+
// 판정 { file, task, by, note, planFile, lines[{ as, id, at, loc }], none { at, loc }|null, items[{ id, state, at, loc }], counts { applied, stale, invalid } }
|
|
79
|
+
export function readJudgments(fs, taskNames, tasksDir) {
|
|
80
|
+
const byTask = new Map();
|
|
81
|
+
const issues = [];
|
|
82
|
+
if (!fs.isDir(JUDGMENTS_DIR)) return { byTask, issues };
|
|
83
|
+
const cache = new Map();
|
|
84
|
+
const linesOf = (rel) => { if (!cache.has(rel)) cache.set(rel, fs.read(rel).split(/\r?\n/)); return cache.get(rel); };
|
|
85
|
+
const excerpt = (file, line) => (linesOf(file)[line - 1] ?? '').trim();
|
|
86
|
+
for (const name of fs.ls(JUDGMENTS_DIR).filter((n) => n.endsWith('.json')).sort()) {
|
|
87
|
+
const file = `${JUDGMENTS_DIR}/${name}`;
|
|
88
|
+
if (fs.isDir(file)) continue;
|
|
89
|
+
const stem = name.slice(0, -'.json'.length);
|
|
90
|
+
const subject = { kind: 'judgment', id: stem };
|
|
91
|
+
const self = { file, line: null };
|
|
92
|
+
const invalid = (message, anchors = []) => issues.push(['error', 'judgment.invalid', message, { subject, anchors: [self, ...anchors] }]);
|
|
93
|
+
let j;
|
|
94
|
+
try { j = JSON.parse(fs.read(file)); } catch (e) { invalid(`JSON으로 읽지 못함(${e.message}): ${file}`); continue; }
|
|
95
|
+
if (!isObj(j)) { invalid(`판정 파일은 JSON 객체: ${file}`); continue; }
|
|
96
|
+
const shape = shapeProblems(j, stem);
|
|
97
|
+
if (shape.length) { for (const s of shape) invalid(`스키마 위반(${s}): ${file}`); continue; }
|
|
98
|
+
if (!taskNames.has(stem)) { invalid(`판정 대상 작업 폴더 없음: ${tasksDir}/${stem}`); continue; }
|
|
99
|
+
const base = `${tasksDir}/${stem}`;
|
|
100
|
+
if (j.planFile != null && (!fs.has(`${base}/${j.planFile}`) || fs.isDir(`${base}/${j.planFile}`))) { invalid(`planFile 없음: ${base}/${j.planFile}`); continue; }
|
|
101
|
+
|
|
102
|
+
const counts = { applied: j.planFile != null ? 1 : 0, stale: 0, invalid: 0 };
|
|
103
|
+
// 근거 조각 대조와 항목별 이슈. draftOf: stale 초안에 실을 판정 항목 모양(at: null)
|
|
104
|
+
const check = (at, id, draftOf) => {
|
|
105
|
+
const loc = locate(fs, at, id, linesOf);
|
|
106
|
+
if (loc.state === 'ok') counts.applied += 1;
|
|
107
|
+
else if (loc.state === 'invalid') {
|
|
108
|
+
counts.invalid += 1;
|
|
109
|
+
const anchors = loc.lines ? loc.lines.map((l) => ({ file: loc.file, line: l, excerpt: excerpt(loc.file, l) })) : loc.file && fs.has(loc.file) && !fs.isDir(loc.file) ? [{ file: loc.file, line: null }] : [];
|
|
110
|
+
invalid(loc.reason, anchors);
|
|
111
|
+
} else {
|
|
112
|
+
counts.stale += 1;
|
|
113
|
+
// 사라진 조각의 판정이 id를 가지면 같은 파일에서 그 id를 가진 줄이 후보
|
|
114
|
+
loc.candidates = id != null ? linesOf(loc.file).map((l, i) => [l, i + 1]).filter(([l]) => hasId(l, id)).map(([, n]) => ({ file: loc.file, line: n, excerpt: excerpt(loc.file, n) })) : [];
|
|
115
|
+
issues.push(['warn', 'judgment.stale', loc.reason, { subject, anchors: [self, { file: loc.file, line: null }], judgmentDraft: { task: stem, ...draftOf, candidates: loc.candidates } }]);
|
|
116
|
+
}
|
|
117
|
+
return loc;
|
|
118
|
+
};
|
|
119
|
+
const lines = (j.lines || []).map((e) => ({ as: e.as, id: e.id ?? null, at: e.at, loc: check(e.at, e.as === 'definition' ? e.id : null, { lines: [{ at: null, as: e.as, id: e.id ?? null }] }) }));
|
|
120
|
+
const q = j.questions || {};
|
|
121
|
+
const none = q.none != null ? { at: q.none.at, loc: check(q.none.at, null, { questions: { none: { at: null } } }) } : null;
|
|
122
|
+
const items = (q.items || []).map((e) => ({ id: e.id, state: e.state, at: e.at, loc: check(e.at, e.id, { questions: { items: [{ id: e.id, state: e.state, at: null }] } }) }));
|
|
123
|
+
byTask.set(stem, { file, task: stem, by: j.by, note: j.note ?? null, planFile: j.planFile ?? null, lines, none, items, counts });
|
|
124
|
+
}
|
|
125
|
+
return { byTask, issues };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export const JUDGMENT_LABEL = LABEL;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// 경로 리터럴: 글자에서 따옴표·백틱 바로 뒤가 /api/인 문자열을 뽑아 정규화하고 API 노드 경로에 대응한다.
|
|
2
|
+
// 접두어는 참조 bff 어댑터와 같이 고정이다. 따옴표 없이 주석에 쓴 경로와 변수로 조립한 경로는 잡히지 않는다.
|
|
3
|
+
// 정규화: ? 뒤를 자른다. 경로 조각 전체가 ${…}면 :param. 조각 중간에서 ${…}가 시작하면 앞 글자까지 남기고 열린 끝(open)
|
|
4
|
+
// 대응: 조각 수가 같고 각 조각이 같거나 한쪽이 :이름이면 맞다. 열린 끝은 앞부분이 맞으면 맞다(마지막 조각은 앞 글자 일치)
|
|
5
|
+
export const API_PREFIX = '/api/';
|
|
6
|
+
const QUOTES = new Set(["'", '"', '`']);
|
|
7
|
+
|
|
8
|
+
// 템플릿 리터럴 본문을 조각으로 나눈다: 글자는 문자열, ${…}는 { expr } (중첩 괄호·안쪽 문자열을 건너뛴다)
|
|
9
|
+
function templateParts(raw) {
|
|
10
|
+
const parts = [];
|
|
11
|
+
let text = '';
|
|
12
|
+
for (let i = 0; i < raw.length; i += 1) {
|
|
13
|
+
if (raw[i] === '\\') { text += raw.slice(i, i + 2); i += 1; continue; }
|
|
14
|
+
if (raw[i] === '$' && raw[i + 1] === '{') {
|
|
15
|
+
let depth = 1, j = i + 2, quote = null;
|
|
16
|
+
for (; j < raw.length && depth > 0; j += 1) {
|
|
17
|
+
const ch = raw[j];
|
|
18
|
+
if (quote) { if (ch === '\\') j += 1; else if (ch === quote) quote = null; continue; }
|
|
19
|
+
if (QUOTES.has(ch)) quote = ch;
|
|
20
|
+
else if (ch === '{') depth += 1;
|
|
21
|
+
else if (ch === '}') depth -= 1;
|
|
22
|
+
}
|
|
23
|
+
if (text) parts.push(text);
|
|
24
|
+
parts.push({ expr: raw.slice(i + 2, j - 1) });
|
|
25
|
+
text = '';
|
|
26
|
+
i = j - 1;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
text += raw[i];
|
|
30
|
+
}
|
|
31
|
+
if (text) parts.push(text);
|
|
32
|
+
return parts;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 따옴표 안 글자(따옴표 제외) → { path, open? }. 백틱이 아니면 ${를 글자로 본다
|
|
36
|
+
export function normalizeApiLiteral(raw, template = true) {
|
|
37
|
+
const parts = template ? templateParts(raw) : [raw];
|
|
38
|
+
const segs = [];
|
|
39
|
+
let cur = [], open = false;
|
|
40
|
+
const flush = () => { segs.push(cur); cur = []; };
|
|
41
|
+
outer: for (const p of parts) {
|
|
42
|
+
if (typeof p !== 'string') { cur.push(p); continue; }
|
|
43
|
+
for (const ch of p) {
|
|
44
|
+
if (ch === '?' || ch === '#') break outer;
|
|
45
|
+
if (ch === '/') flush(); else cur.push(ch);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
flush();
|
|
49
|
+
// segs[0]은 첫 / 앞의 빈 조각
|
|
50
|
+
const out = [];
|
|
51
|
+
for (const seg of segs.slice(1)) {
|
|
52
|
+
const exprs = seg.filter((x) => typeof x !== 'string');
|
|
53
|
+
if (!exprs.length) { out.push(seg.join('')); continue; }
|
|
54
|
+
if (seg.length === 1) { out.push(':param'); continue; }
|
|
55
|
+
const at = seg.findIndex((x) => typeof x !== 'string');
|
|
56
|
+
out.push(seg.slice(0, at).join(''));
|
|
57
|
+
open = true;
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
while (!open && out.length > 1 && out[out.length - 1] === '') out.pop();
|
|
61
|
+
const path = '/' + out.join('/');
|
|
62
|
+
return open ? { path, open: true } : { path };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 글자에서 리터럴을 뽑는다: [{ path, open?, line }]. startLine은 text 첫 줄의 줄 번호
|
|
66
|
+
export function extractApiLiterals(text, startLine = 1) {
|
|
67
|
+
const out = [];
|
|
68
|
+
let line = startLine;
|
|
69
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
70
|
+
const ch = text[i];
|
|
71
|
+
if (ch === '\n') { line += 1; continue; }
|
|
72
|
+
if (!QUOTES.has(ch) || !text.startsWith(API_PREFIX, i + 1)) continue;
|
|
73
|
+
// 닫는 따옴표까지(백틱은 ${…} 안을 건너뛴다). 작은·큰따옴표는 줄을 넘지 않는다
|
|
74
|
+
let j = i + 1, depth = 0;
|
|
75
|
+
for (; j < text.length; j += 1) {
|
|
76
|
+
const c = text[j];
|
|
77
|
+
if (c === '\\') { j += 1; continue; }
|
|
78
|
+
if (ch === '`') {
|
|
79
|
+
if (c === '$' && text[j + 1] === '{') { depth += 1; j += 1; continue; }
|
|
80
|
+
if (depth > 0) { if (c === '{') depth += 1; else if (c === '}') depth -= 1; continue; }
|
|
81
|
+
} else if (c === '\n') break;
|
|
82
|
+
if (c === ch && depth === 0) break;
|
|
83
|
+
}
|
|
84
|
+
if (j >= text.length || text[j] !== ch) continue;
|
|
85
|
+
const raw = text.slice(i + 1, j);
|
|
86
|
+
out.push({ ...normalizeApiLiteral(raw, ch === '`'), line });
|
|
87
|
+
for (let k = i + 1; k < j; k += 1) if (text[k] === '\n') line += 1;
|
|
88
|
+
i = j;
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// API 노드 id("METHOD /api/x" 또는 "/api/x")의 경로
|
|
94
|
+
export const apiPathOf = (id) => String(id).replace(/^[A-Z]+\s+(?=\/)/, '');
|
|
95
|
+
|
|
96
|
+
const segMatch = (a, b) => a === b || a.startsWith(':') || b.startsWith(':');
|
|
97
|
+
export function matchesApi(lit, apiId) {
|
|
98
|
+
const a = lit.path.split('/'), b = apiPathOf(apiId).split('/');
|
|
99
|
+
if (!lit.open) return a.length === b.length && a.every((s, i) => segMatch(s, b[i]));
|
|
100
|
+
if (b.length < a.length) return false;
|
|
101
|
+
const last = a.length - 1;
|
|
102
|
+
return a.every((s, i) => (i < last ? segMatch(s, b[i]) : segMatch(s, b[i]) || b[i].startsWith(s)));
|
|
103
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// md 블록 읽개: 마크다운 파일 하나를 줄 번호와 함께 블록으로 나눈다. 뜻(결정·계획 항목·질문)은 정하지 않는다.
|
|
2
|
+
// 블록 { type, line, path } — path는 감싼 제목 [{ level, text, line }](제목 블록은 자기 자신 포함)
|
|
3
|
+
// heading { level, text }
|
|
4
|
+
// item { indent, marker, checkbox(' '|'x'|'X'|null), checked(true|false|null), text(체크박스 뒤 글자), head }
|
|
5
|
+
// row { cells, header(바로 아래가 구분 행), table(파일 안 표 번호, 1부터), head(첫 칸 머리 번호) }
|
|
6
|
+
// text { text } — 제목·목록·표가 아닌 빈 줄 아닌 줄
|
|
7
|
+
// 백틱·물결 코드 펜스(앞 공백 셋까지, 닫는 펜스는 같은 글자·같거나 긴 길이) 안 줄과 표 구분 행은 블록을 만들지 않는다. CRLF를 받는다.
|
|
8
|
+
// 머리 번호 head { id, bold, struck, rest }: 앞뒤 **·__를 벗기고 ~~ 취소선이면 struck. id 모양이 계획 항목인지 결정인지는 부르는 쪽이 정한다.
|
|
9
|
+
//
|
|
10
|
+
// 경계: 이 모듈은 줄 단위 블록까지만 만든다. 절 이름으로 역할을 정하거나(잔여 질문 절, 결정 절) 항목 속성(`키: 값`)을 해석하는
|
|
11
|
+
// 2단 파서는 이 블록 위에 올린다(작업 어댑터의 식별자 줄 문법, 뒤의 md 속성 파서). 인라인 링크·강조 해석, 들여쓴 코드 블록,
|
|
12
|
+
// HTML 블록, setext 제목은 다루지 않는다.
|
|
13
|
+
|
|
14
|
+
const FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
|
|
15
|
+
const HEADING = /^ {0,3}(#{1,6})[ \t]+(.*?)(?:[ \t]+#+)?[ \t]*$/;
|
|
16
|
+
const EMPTY_HEADING = /^ {0,3}(#{1,6})[ \t]*$/;
|
|
17
|
+
const ITEM = /^([ \t]*)([-*+]|\d{1,9}[.)])(?:[ \t]+(.*))?$/;
|
|
18
|
+
const CHECKBOX = /^\[( |x|X)\](?:[ \t]+(.*)|)$/;
|
|
19
|
+
const DELIM = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/;
|
|
20
|
+
const ID = /^[A-Z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)+/;
|
|
21
|
+
const WRAP = /^(\*\*|__|~~)/;
|
|
22
|
+
|
|
23
|
+
const indentOf = (s) => [...s].reduce((n, ch) => n + (ch === '\t' ? 4 - (n % 4) : 1), 0);
|
|
24
|
+
|
|
25
|
+
// 표 행 한 줄을 칸으로 나눈다. 앞뒤 파이프는 선택, 백슬래시로 이스케이프한 파이프는 칸 안 글자
|
|
26
|
+
export function splitCells(line) {
|
|
27
|
+
let s = line.trim();
|
|
28
|
+
if (s.startsWith('|')) s = s.slice(1);
|
|
29
|
+
if (s.endsWith('|') && !s.endsWith('\\|')) s = s.slice(0, -1);
|
|
30
|
+
const cells = [];
|
|
31
|
+
let cur = '';
|
|
32
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
33
|
+
if (s[i] === '\\' && s[i + 1] === '|') { cur += '|'; i += 1; }
|
|
34
|
+
else if (s[i] === '|') { cells.push(cur.trim()); cur = ''; }
|
|
35
|
+
else cur += s[i];
|
|
36
|
+
}
|
|
37
|
+
cells.push(cur.trim());
|
|
38
|
+
return cells;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 글자 머리의 번호: 앞 **·__·~~를 벗기고 번호 모양 낱말을 찾는다. 번호 바로 뒤의 닫는 표시도 벗겨 rest에 남기지 않는다
|
|
42
|
+
export function headId(text) {
|
|
43
|
+
let s = String(text ?? '').trimStart();
|
|
44
|
+
let bold = false, struck = false;
|
|
45
|
+
const opened = [];
|
|
46
|
+
for (let m = s.match(WRAP); m; m = s.match(WRAP)) {
|
|
47
|
+
if (m[1] === '~~') struck = true; else bold = true;
|
|
48
|
+
opened.push(m[1]);
|
|
49
|
+
s = s.slice(2);
|
|
50
|
+
}
|
|
51
|
+
const m = s.match(ID);
|
|
52
|
+
if (!m) return null;
|
|
53
|
+
let rest = s.slice(m[0].length);
|
|
54
|
+
// 여는 표시의 반대 순서로 닫는 표시를 지운다. 번호 바로 뒤에 없으면(**DEC-10 [확정]**: …) 뒤에서 처음 나오는 것 하나를 지운다
|
|
55
|
+
for (const w of opened.slice().reverse()) {
|
|
56
|
+
if (rest.startsWith(w)) rest = rest.slice(w.length);
|
|
57
|
+
else { const k = rest.indexOf(w); if (k >= 0) rest = rest.slice(0, k) + rest.slice(k + w.length); }
|
|
58
|
+
}
|
|
59
|
+
return { id: m[0], bold, struck, rest };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function readBlocks(text) {
|
|
63
|
+
const lines = String(text ?? '').split(/\r?\n/);
|
|
64
|
+
const blocks = [];
|
|
65
|
+
let path = [];
|
|
66
|
+
let fence = null;
|
|
67
|
+
let table = 0, inTable = false, tableStart = 0;
|
|
68
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
69
|
+
const raw = lines[i];
|
|
70
|
+
const line = i + 1;
|
|
71
|
+
const f = raw.match(FENCE);
|
|
72
|
+
if (fence) {
|
|
73
|
+
if (f && f[1][0] === fence.ch && f[1].length >= fence.len && f[2].trim() === '') fence = null;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (f && !(f[1][0] === '`' && f[2].includes('`'))) { fence = { ch: f[1][0], len: f[1].length }; inTable = false; continue; }
|
|
77
|
+
if (!raw.trim()) { inTable = false; continue; }
|
|
78
|
+
|
|
79
|
+
const h = raw.match(HEADING) || raw.match(EMPTY_HEADING);
|
|
80
|
+
if (h) {
|
|
81
|
+
const level = h[1].length;
|
|
82
|
+
const heading = { level, text: (h[2] ?? '').trim(), line };
|
|
83
|
+
path = [...path.filter((p) => p.level < level), heading];
|
|
84
|
+
blocks.push({ type: 'heading', line, level, text: heading.text, path });
|
|
85
|
+
inTable = false;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (raw.trimStart().startsWith('|')) {
|
|
89
|
+
// 구분 행: 표 첫 행 바로 아래일 때만(그 행이 머리 행). 표 중간의 `| - | - |`는 대시 칸을 가진 행이다
|
|
90
|
+
if (DELIM.test(raw) && (!inTable || tableStart === line - 1)) {
|
|
91
|
+
const prev = blocks[blocks.length - 1];
|
|
92
|
+
if (inTable && prev?.type === 'row' && prev.line === line - 1) prev.header = true;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (!inTable) { table += 1; inTable = true; tableStart = line; }
|
|
96
|
+
const cells = splitCells(raw);
|
|
97
|
+
blocks.push({ type: 'row', line, path, cells, header: false, table, head: headId(cells[0]) });
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
inTable = false;
|
|
101
|
+
const it = raw.match(ITEM);
|
|
102
|
+
if (it) {
|
|
103
|
+
const body = it[3] ?? '';
|
|
104
|
+
const cb = body.match(CHECKBOX);
|
|
105
|
+
const checkbox = cb ? cb[1] : null;
|
|
106
|
+
const itemText = cb ? (cb[2] ?? '') : body;
|
|
107
|
+
blocks.push({ type: 'item', line, path, indent: indentOf(it[1]), marker: it[2], checkbox, checked: checkbox === null ? null : checkbox !== ' ', text: itemText, head: headId(itemText) });
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
blocks.push({ type: 'text', line, path, text: raw.trim() });
|
|
111
|
+
}
|
|
112
|
+
return blocks;
|
|
113
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// 읽기 상태: 수치마다 "어떻게 읽었나"를 싣는다. 못 읽은 값과 0을 가르고, 화면은 부분·낡음·모름을 "?"로 보인다.
|
|
2
|
+
// 어댑터는 노드(g.add·g.get이 돌려준 것)에 setReading으로 필드별 상태를 적는다. 적지 않은 필드는 rule로 본다.
|
|
3
|
+
// props.reading { 필드: 값 }
|
|
4
|
+
// props.readingNotes { 필드: 이유 문장 } 파일·줄이 들어갈 수 있어 개요 조각에는 싣지 않는다
|
|
5
|
+
// 값: observed 생산자 구조화 출력·livemap 소유 형식, rule 규칙으로 읽고 후보 줄이 모두 읽힘, judged 판정 파일로 채움·고침,
|
|
6
|
+
// partial 안 읽힌 후보 줄·뜻 확인 필요, stale 판정 근거가 사라졌거나 검사 결과 뒤 코드 변경, unknown 소스 없음·형식 밖, none 셀 대상 없음
|
|
7
|
+
export const READING_VALUES = ['observed', 'rule', 'judged', 'partial', 'stale', 'unknown', 'none'];
|
|
8
|
+
const BAD = new Set(['partial', 'stale', 'unknown']);
|
|
9
|
+
export const DEFAULT_READING = 'rule';
|
|
10
|
+
|
|
11
|
+
export function setReading(node, field, value, note) {
|
|
12
|
+
if (!node || typeof node !== 'object' || !node.props) throw new Error('reading 대상은 그래프 노드');
|
|
13
|
+
if (typeof field !== 'string' || !field) throw new Error(`reading 필드는 빈 문자열이 아닌 이름: ${field}`);
|
|
14
|
+
if (!READING_VALUES.includes(value)) throw new Error(`reading 값은 ${READING_VALUES.join('·')}: ${value}`);
|
|
15
|
+
if (note !== undefined && typeof note !== 'string') throw new Error('reading 이유는 문자열');
|
|
16
|
+
node.props.reading = { ...(node.props.reading || {}), [field]: value };
|
|
17
|
+
const notes = { ...(node.props.readingNotes || {}) };
|
|
18
|
+
if (note) notes[field] = note; else delete notes[field];
|
|
19
|
+
node.props.readingNotes = notes;
|
|
20
|
+
return node;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// 노드(또는 props·파생 뷰처럼 reading을 가진 객체)의 필드 상태. 적지 않았으면 fallback
|
|
24
|
+
export function readingOf(x, field, fallback = DEFAULT_READING) {
|
|
25
|
+
const r = x?.props ? x.props.reading : x?.reading;
|
|
26
|
+
return r?.[field] ?? fallback;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const norm = (v) => (READING_VALUES.includes(v) ? v : 'unknown');
|
|
30
|
+
|
|
31
|
+
// 합계: none은 빼고, 하나라도 partial·stale·unknown이면 partial. 나머지는 judged > rule > observed 순으로 가장 약한 근거를 쓴다.
|
|
32
|
+
// 구성 요소가 없으면 empty(기본 rule: 셀 대상이 없어 0을 규칙으로 읽은 것과 같다)
|
|
33
|
+
export function combineReading(values, empty = DEFAULT_READING) {
|
|
34
|
+
const vs = values.map(norm).filter((v) => v !== 'none');
|
|
35
|
+
if (!vs.length) return empty;
|
|
36
|
+
if (vs.some((v) => BAD.has(v))) return 'partial';
|
|
37
|
+
if (vs.includes('judged')) return 'judged';
|
|
38
|
+
if (vs.includes('rule')) return 'rule';
|
|
39
|
+
return 'observed';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 노드에 적힌 상태만 센다: values는 값별 건수(모든 값 키), fields는 '<종류>.<필드>'별 값 건수
|
|
43
|
+
export function countReadings(nodes) {
|
|
44
|
+
const values = Object.fromEntries(READING_VALUES.map((v) => [v, 0]));
|
|
45
|
+
const fields = {};
|
|
46
|
+
for (const n of nodes) {
|
|
47
|
+
for (const [field, raw] of Object.entries(n.props?.reading || {})) {
|
|
48
|
+
const v = norm(raw);
|
|
49
|
+
values[v] += 1;
|
|
50
|
+
const f = (fields[`${n.kind}.${field}`] ||= {});
|
|
51
|
+
f[v] = (f[v] || 0) + 1;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { values, fields };
|
|
55
|
+
}
|
package/src/link.mjs
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// 연결 단계: 모든 어댑터가 끝난 뒤 화면 노드 apiLiterals를 API 노드에 대응해 calls 엣지와 matched를 채운다.
|
|
2
|
+
// router는 bff보다 먼저 돌아 어댑터 안에서는 대응할 API 노드가 없다. 연결 단계는 adapters[]에 들지 않는다.
|
|
3
|
+
// router.unknown-api 리터럴 위치(파일·줄·경로)마다 한 건
|
|
4
|
+
// router.hookapi-redundant hookApi 키가 쓰인 화면의 연결이 모두 리터럴로도 나옴(쓰는 화면이 없는 키 포함), 키마다 한 건
|
|
5
|
+
// router.hookapi-only hookApi 키로만 나오는 연결이 있음, 키마다 한 건
|
|
6
|
+
// 화면 apis 읽기 상태: 모든 리터럴이 API 노드에 맞고 hookApi로만 붙은 연결이 없으면 rule, 아니면 partial.
|
|
7
|
+
import { matchesApi } from './lib/literals.mjs';
|
|
8
|
+
import { setReading } from './lib/reading.mjs';
|
|
9
|
+
|
|
10
|
+
const CONFIG = 'map/config.json';
|
|
11
|
+
const LIST_MAX = 3;
|
|
12
|
+
const list = (xs) => xs.slice(0, LIST_MAX).join(', ') + (xs.length > LIST_MAX ? ` 외 ${xs.length - LIST_MAX}` : '');
|
|
13
|
+
|
|
14
|
+
export function linkScreenApis(g, fs, cfg) {
|
|
15
|
+
const apis = g.of('api');
|
|
16
|
+
const screens = g.of('screen').filter((s) => s.props.apiLiterals !== undefined || s.props.hookApiKeys !== undefined);
|
|
17
|
+
const literalPairs = new Set();
|
|
18
|
+
const unknown = new Map(); // 파일:줄:경로 → { lit, screens }
|
|
19
|
+
const lineText = new Map();
|
|
20
|
+
const excerptOf = (file, line) => {
|
|
21
|
+
if (!lineText.has(file)) lineText.set(file, fs.has(file) ? fs.read(file).split('\n') : []);
|
|
22
|
+
return (lineText.get(file)[line - 1] ?? '').trim();
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
for (const s of screens) {
|
|
26
|
+
const lits = s.props.apiLiterals ?? [];
|
|
27
|
+
if (!Array.isArray(lits)) throw new Error(`화면 ${s.id}의 apiLiterals가 배열이 아님`);
|
|
28
|
+
for (const lit of lits) {
|
|
29
|
+
if (!lit || typeof lit.path !== 'string' || typeof lit.file !== 'string') throw new Error(`화면 ${s.id}의 apiLiterals 항목에 path·file 문자열이 없음`);
|
|
30
|
+
lit.matched = apis.filter((a) => matchesApi(lit, a.id)).map((a) => a.id);
|
|
31
|
+
for (const id of lit.matched) { g.link('screen', s.id, 'calls', 'api', id); literalPairs.add(`${s.id}\t${id}`); }
|
|
32
|
+
if (!lit.matched.length) {
|
|
33
|
+
const key = `${lit.file}:${lit.line}:${lit.path}${lit.open ? '…' : ''}`;
|
|
34
|
+
if (!unknown.has(key)) unknown.set(key, { lit, screens: [] });
|
|
35
|
+
unknown.get(key).screens.push(s.id);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
for (const { lit, screens: ss } of unknown.values()) {
|
|
40
|
+
const shown = `${lit.path}${lit.open ? '…' : ''}`;
|
|
41
|
+
g.issue('warn', '화면→API', `${lit.file}:${lit.line} 리터럴 ${shown}에 맞는 API 노드 없음(화면 ${ss.length}: ${list(ss)})`, {
|
|
42
|
+
code: 'router.unknown-api',
|
|
43
|
+
subject: { kind: 'screen', id: ss[0] },
|
|
44
|
+
anchors: [{ file: lit.file, line: Number.isInteger(lit.line) && lit.line >= 1 ? lit.line : null, excerpt: excerptOf(lit.file, lit.line) }],
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// hookApi 정리 경고: 키마다 그 키를 쓴 화면의 (화면, API) 연결이 리터럴로도 나왔는지 본다
|
|
49
|
+
const hookApi = cfg.router?.hookApi;
|
|
50
|
+
const hookOnly = new Map(); // 화면 → [API]
|
|
51
|
+
if (hookApi && typeof hookApi === 'object') {
|
|
52
|
+
const cfgText = fs.has(CONFIG) ? fs.read(CONFIG) : '';
|
|
53
|
+
for (const [key, api] of Object.entries(hookApi)) {
|
|
54
|
+
const users = screens.filter((s) => (s.props.hookApiKeys || []).includes(key)).map((s) => s.id);
|
|
55
|
+
const missing = users.filter((sid) => !literalPairs.has(`${sid}\t${api}`));
|
|
56
|
+
for (const sid of missing) hookOnly.set(sid, [...new Set([...(hookOnly.get(sid) || []), api])]);
|
|
57
|
+
const line = fs.lineOf(cfgText, `"${key}"`);
|
|
58
|
+
const detail = { subject: { kind: 'config', id: `router.hookApi.${key}` }, anchors: [{ file: CONFIG, line }] };
|
|
59
|
+
if (missing.length) {
|
|
60
|
+
g.issue('warn', 'hookApi', `${key}(${api}) 리터럴 없이 대응표로만 연결되는 화면 ${missing.length}: ${list(missing)}`, { code: 'router.hookapi-only', ...detail });
|
|
61
|
+
} else {
|
|
62
|
+
const why = users.length ? `리터럴로도 연결됨(화면 ${users.length})` : '쓰는 화면 없음';
|
|
63
|
+
g.issue('warn', 'hookApi', `${key}(${api}) ${why}, 설정에서 지워도 됨`, { code: 'router.hookapi-redundant', ...detail });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const s of screens) {
|
|
69
|
+
const bad = (s.props.apiLiterals || []).filter((l) => !l.matched.length).map((l) => `${l.path}${l.open ? '…' : ''}(${l.file}:${l.line})`);
|
|
70
|
+
const only = hookOnly.get(s.id) || [];
|
|
71
|
+
const notes = [];
|
|
72
|
+
if (bad.length) notes.push(`API 노드에 맞지 않는 리터럴 ${bad.length}: ${list(bad)}`);
|
|
73
|
+
if (only.length) notes.push(`hookApi로만 연결 ${only.length}: ${list(only)}`);
|
|
74
|
+
setReading(s, 'apis', notes.length ? 'partial' : 'rule', notes.length ? notes.join('; ') : undefined);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Node 사용자 리포터(패키지 공개 경로): node --test의 test:pass·test:fail 이벤트를 파일별로 세어 결과 JSON 한 실행으로 낸다.
|
|
2
|
+
// node --test --test-reporter=@pghoya2956/livemap/src/reporters/node-results.mjs --test-reporter-destination=map/.out/node-results.json
|
|
3
|
+
// Node v22.0.0 문서에 있는 이벤트 필드(file·details.type·skip·todo)만 쓴다. test:summary는 v22.0.0 문서에 없어 쓰지 않는다.
|
|
4
|
+
// describe(details.type === 'suite')는 빼고, skip이 있으면 skipped, todo가 있으면 pending이다. 불러오기에 실패한 파일은
|
|
5
|
+
// 파일 이름으로 test:fail 하나가 와서 실패 1이 된다. 루트는 러너의 작업 폴더이고, 루트 밖 파일은 빼고 그 수를 outsideRoot에 남긴다.
|
|
6
|
+
// 러너 종료 코드는 리포터가 알 수 없어 exit는 null이다(livemap test-report가 채운다).
|
|
7
|
+
import { emptyFile, gitStamp, RESULTS_SCHEMA, sortFiles, toRootPath } from '../results.mjs';
|
|
8
|
+
|
|
9
|
+
const flag = (v) => v !== undefined && v !== null && v !== false;
|
|
10
|
+
|
|
11
|
+
export default async function* nodeResults(source) {
|
|
12
|
+
const root = process.cwd();
|
|
13
|
+
const at = new Date().toISOString();
|
|
14
|
+
const { sha, dirtyPaths } = gitStamp(root);
|
|
15
|
+
const files = new Map();
|
|
16
|
+
const outside = new Set();
|
|
17
|
+
for await (const event of source) {
|
|
18
|
+
if (event.type !== 'test:pass' && event.type !== 'test:fail') continue;
|
|
19
|
+
const d = event.data || {};
|
|
20
|
+
if (d.details?.type === 'suite' || !d.file) continue;
|
|
21
|
+
const filePath = toRootPath(root, d.file);
|
|
22
|
+
if (!filePath) { outside.add(d.file); continue; }
|
|
23
|
+
if (!files.has(filePath)) files.set(filePath, emptyFile(filePath));
|
|
24
|
+
const f = files.get(filePath);
|
|
25
|
+
f.tests += 1;
|
|
26
|
+
if (flag(d.todo)) f.pending += 1;
|
|
27
|
+
else if (flag(d.skip)) f.skipped += 1;
|
|
28
|
+
else if (event.type === 'test:fail') f.failed += 1;
|
|
29
|
+
else f.passed += 1;
|
|
30
|
+
}
|
|
31
|
+
const run = { runner: 'node', source: null, sha, dirtyPaths, at, exit: null, outsideRoot: outside.size, files: sortFiles([...files.values()]) };
|
|
32
|
+
yield JSON.stringify({ schema: RESULTS_SCHEMA, runs: [run] }, null, 2) + '\n';
|
|
33
|
+
}
|