@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/src/adapters/deploy.mjs
CHANGED
|
@@ -1,24 +1,35 @@
|
|
|
1
1
|
// 배포 어댑터: 배포 매니페스트의 이미지 태그(커밋 sha)를 읽어 main 대비 미배포 커밋 수(전체·런타임)를 센다.
|
|
2
|
+
// 배포 뒤 봇이 매니페스트만 바꾸는 커밋은 뒤처짐이 아니므로 behind는 매니페스트 경로를 뺀 경로 제외 rev-list 한 번으로 세고,
|
|
3
|
+
// 뺀 수는 behindManifestOnly(전체 커밋 수와의 차)로 따로 싣는다. 매니페스트 sha가 이력에 없으면 읽기 상태 behind는 unknown.
|
|
4
|
+
import { setReading } from '../lib/reading.mjs';
|
|
5
|
+
|
|
2
6
|
export default function deploy(g, fs, cfg) {
|
|
3
7
|
const c = cfg.deploy;
|
|
4
8
|
if (!fs.has(c.manifest)) return `매니페스트 없음: ${c.manifest}`;
|
|
5
9
|
const text = fs.read(c.manifest);
|
|
6
10
|
const sha = (text.match(new RegExp(c.imagePattern)) || [])[1] || null;
|
|
7
11
|
if (!sha) return '이미지 태그를 못 읽음';
|
|
8
|
-
let behind = null, behindRuntime = null;
|
|
12
|
+
let behind = null, behindRuntime = null, behindManifestOnly = null;
|
|
9
13
|
const ref = fs.resolveRef(cfg.git?.branch || 'main');
|
|
10
14
|
// 이미지 빌드 중(CI)에는 지금 커밋이 곧 배포본이다. 매니페스트 태그는 배포 뒤에야 바뀌므로 CI가 알려준 sha를 우선한다.
|
|
11
15
|
const assumed = process.env.MAP_ASSUME_DEPLOYED_SHA || null;
|
|
12
16
|
if (assumed && ref && fs.git('rev-parse', ref) === assumed) {
|
|
13
|
-
g.add('deploy', 'homelab', cfg.project.host, { host: cfg.project.host, sha: assumed.slice(0, 7), full: assumed, behind: 0, behindRuntime: 0, assumed: true }, { file: c.manifest, line: null, rule: 'deploy:MAP_ASSUME_DEPLOYED_SHA' });
|
|
17
|
+
const node = g.add('deploy', 'homelab', cfg.project.host, { host: cfg.project.host, sha: assumed.slice(0, 7), full: assumed, behind: 0, behindRuntime: 0, behindManifestOnly: 0, assumed: true }, { file: c.manifest, line: null, rule: 'deploy:MAP_ASSUME_DEPLOYED_SHA' });
|
|
18
|
+
setReading(node, 'behind', 'rule');
|
|
14
19
|
return null;
|
|
15
20
|
}
|
|
16
21
|
if (fs.hasGit() && ref) {
|
|
17
|
-
const
|
|
18
|
-
behind =
|
|
19
|
-
const
|
|
20
|
-
|
|
22
|
+
const count = (...pathspec) => { const n = fs.git('rev-list', '--count', `${sha}..${ref}`, ...pathspec); return n === '' ? null : Number(n); };
|
|
23
|
+
behind = count('--', '.', `:(exclude)${c.manifest}`);
|
|
24
|
+
const all = count();
|
|
25
|
+
behindManifestOnly = behind === null || all === null ? null : all - behind;
|
|
26
|
+
behindRuntime = count('--', ...(cfg.git?.runtimePaths || []));
|
|
21
27
|
}
|
|
22
|
-
g.add('deploy', 'homelab', cfg.project.host, { host: cfg.project.host, sha: sha.slice(0, 7), full: sha, behind, behindRuntime }, { file: c.manifest, line: fs.lineOf(text, sha), rule: 'deploy:image tag' });
|
|
23
|
-
|
|
28
|
+
const node = g.add('deploy', 'homelab', cfg.project.host, { host: cfg.project.host, sha: sha.slice(0, 7), full: sha, behind, behindRuntime, behindManifestOnly }, { file: c.manifest, line: fs.lineOf(text, sha), rule: 'deploy:image tag' });
|
|
29
|
+
if (behind === null) {
|
|
30
|
+
setReading(node, 'behind', 'unknown', fs.hasGit() && ref ? `${c.manifest}의 배포 sha ${sha.slice(0, 7)}가 ${ref} 이력에 없음` : 'git 이력을 읽지 못함');
|
|
31
|
+
return '배포 sha가 main 이력에 없음(뒤처짐 계산 불가)';
|
|
32
|
+
}
|
|
33
|
+
setReading(node, 'behind', 'rule');
|
|
34
|
+
return null;
|
|
24
35
|
}
|
package/src/adapters/router.mjs
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
// 화면 어댑터: React Router의 <Route> 선언에서 screen 노드를 만든다. 페이지와 그 로컬 import 닫힘에서 데이터 출처(live/mock/mixed)
|
|
2
|
-
|
|
1
|
+
// 화면 어댑터: React Router의 <Route> 선언에서 screen 노드를 만든다. 페이지와 그 로컬 import 닫힘에서 데이터 출처(live/mock/mixed)를 읽고,
|
|
2
|
+
// 리터럴 추출 닫힘의 /api/ 문자열 리터럴을 화면 노드 apiLiterals에 적는다. API 노드 대응·calls 엣지는 모든 어댑터 뒤 연결 단계(src/link.mjs)가 한다.
|
|
3
|
+
// router.hookApi가 있으면 1.x 동안 1.1.1처럼 대응표로 노드와 엣지를 만들고(합집합), 쓴 키를 hookApiKeys에 남긴다(연결 단계의 정리 경고용).
|
|
4
|
+
import { relative } from 'node:path';
|
|
5
|
+
import { fileClosure, literalSources, appDirOf } from '../lib/closure.mjs';
|
|
6
|
+
import { extractApiLiterals } from '../lib/literals.mjs';
|
|
3
7
|
|
|
4
8
|
export default function router(g, fs, cfg) {
|
|
5
9
|
const c = cfg.router;
|
|
@@ -8,31 +12,37 @@ export default function router(g, fs, cfg) {
|
|
|
8
12
|
const owner = new Map();
|
|
9
13
|
for (const f of pageFiles) for (const m of fs.read(f).matchAll(/export\s+(?:function|const)\s+([A-Z]\w+)/g)) owner.set(m[1], f);
|
|
10
14
|
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
for (const cand of [base + '.tsx', base + '.ts', join(base, 'index.tsx'), join(base, 'index.ts')]) if (fs.has(cand)) return cand;
|
|
14
|
-
return null;
|
|
15
|
-
};
|
|
16
|
-
const closure = (file, depth = 3, seen = new Set()) => {
|
|
17
|
-
if (seen.has(file) || depth < 0) return seen;
|
|
18
|
-
seen.add(file);
|
|
19
|
-
for (const m of fs.read(file).matchAll(/from\s+'(\.[^']+)'/g)) {
|
|
20
|
-
const t = resolveLocal(file, m[1]);
|
|
21
|
-
if (t && c.localDirs.some((d) => t.startsWith(d + '/'))) closure(t, depth - 1, seen);
|
|
22
|
-
}
|
|
23
|
-
return seen;
|
|
24
|
-
};
|
|
15
|
+
const hookApi = c.hookApi || null;
|
|
16
|
+
const appDir = appDirOf(c.app);
|
|
25
17
|
const classify = (file) => {
|
|
26
|
-
|
|
18
|
+
// 화면 files·source·mockVia·fixedVia는 1.1.1 파일 닫힘 그대로(리터럴 추출 닫힘은 여기에 쓰지 않는다)
|
|
19
|
+
const files = [...fileClosure(fs, file, c.localDirs)];
|
|
27
20
|
const all = files.map(fs.read).join('\n');
|
|
28
21
|
const mockVia = files.filter((f) => fs.read(f).includes(c.mockPattern));
|
|
29
22
|
// 코드에 고정된 표시값(이름·사진·문구·가격 표시)을 읽는 파일. 출처 분류는 바꾸지 않고 따로 센다.
|
|
30
23
|
const fixedVia = c.fixedPattern ? files.filter((f) => fs.read(f).includes(c.fixedPattern)) : [];
|
|
31
24
|
const live = all.includes(c.livePattern);
|
|
32
|
-
const hookNames = Object.keys(
|
|
25
|
+
const hookNames = Object.keys(hookApi || {});
|
|
33
26
|
const hooks = hookNames.length ? [...new Set([...all.matchAll(new RegExp(`use(${hookNames.join('|')})\\b`, 'g'))].map((m) => m[1]))] : [];
|
|
34
27
|
const source = mockVia.length && live ? 'mixed' : mockVia.length ? 'mock' : live ? 'live' : 'static';
|
|
35
|
-
return { files, mockVia: mockVia.map((f) => relative('web/src', f)), fixedVia: fixedVia.map((f) => relative('web/src', f)), source,
|
|
28
|
+
return { files, mockVia: mockVia.map((f) => relative('web/src', f)), fixedVia: fixedVia.map((f) => relative('web/src', f)), source, hooks: hooks.filter((h) => hookApi[h]), apiLiterals: literalsOf(file) };
|
|
29
|
+
};
|
|
30
|
+
// 리터럴: 파일·줄·경로가 같은 것은 한 번. 파일 방문 순서, 줄 순
|
|
31
|
+
const literalsOf = (file) => {
|
|
32
|
+
const out = [], seen = new Set();
|
|
33
|
+
for (const src of literalSources(fs, file, { localDirs: c.localDirs, appDir })) {
|
|
34
|
+
const text = fs.read(src.file);
|
|
35
|
+
const chunks = src.ranges ? src.ranges.map(([s, e]) => [text.split('\n').slice(s, e).join('\n'), s + 1]) : [[text, 1]];
|
|
36
|
+
for (const [chunk, start] of chunks) {
|
|
37
|
+
for (const lit of extractApiLiterals(chunk, start)) {
|
|
38
|
+
const key = `${src.file}:${lit.line}:${lit.path}:${lit.open ? 1 : 0}`;
|
|
39
|
+
if (seen.has(key)) continue;
|
|
40
|
+
seen.add(key);
|
|
41
|
+
out.push({ path: lit.path, ...(lit.open ? { open: true } : {}), file: src.file, line: lit.line, matched: [] });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
36
46
|
};
|
|
37
47
|
|
|
38
48
|
const app = fs.read(c.app);
|
|
@@ -46,9 +56,11 @@ export default function router(g, fs, cfg) {
|
|
|
46
56
|
if (isLayout) { parent = m[1]; return; }
|
|
47
57
|
if (path === '*' || path.endsWith('/*')) return;
|
|
48
58
|
const file = owner.get(m[3]) || null;
|
|
49
|
-
const cls = file ? classify(file) : { files: [], mockVia: [], fixedVia: [], source: 'static',
|
|
50
|
-
|
|
51
|
-
|
|
59
|
+
const cls = file ? classify(file) : { files: [], mockVia: [], fixedVia: [], source: 'static', hooks: [], apiLiterals: [] };
|
|
60
|
+
const props = { component: m[3], file, guarded: /[gG]uard\(/.test(line) || parent !== null, source: cls.source, mockVia: cls.mockVia, fixedVia: cls.fixedVia, files: cls.files, last: file ? fs.lastCommit(file) : null, apiLiterals: cls.apiLiterals };
|
|
61
|
+
if (hookApi) props.hookApiKeys = cls.hooks;
|
|
62
|
+
g.add('screen', path, path, props, { file: c.app, line: i + 1, rule: 'router:<Route path>' });
|
|
63
|
+
for (const h of cls.hooks) { const a = hookApi[h]; g.add('api', a, a); g.link('screen', path, 'calls', 'api', a); }
|
|
52
64
|
n += 1;
|
|
53
65
|
}
|
|
54
66
|
if (/<\/Route>/.test(line)) parent = null;
|
package/src/adapters/tasks.mjs
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
1
|
-
// 작업 어댑터: tasks/<date>-<slug>/ 폴더에서 task 노드를 만든다. 단계는 폴더 안 문서로, 상태는
|
|
2
|
-
//
|
|
1
|
+
// 작업 어댑터: tasks/<date>-<slug>/ 폴더에서 task 노드를 만든다. 단계는 폴더 안 문서로, 상태는 계획 문서 머리와 장부(tasks/index.md) 표 행으로 판정한다.
|
|
2
|
+
// 결정·계획 항목 정의 위치를 defines 엣지와 decision 노드 definers로 남겨 여정 장면의 refs가 작업 문서로 이어지게 한다.
|
|
3
|
+
//
|
|
4
|
+
// 작업 문서 읽기 계약(docs/adapter-contract.md, docs/issue-codes.md):
|
|
5
|
+
// 파일 역할 스펙 final = spec/final.md, 없으면 작업 루트 final.md(specFile). 계획 = task_plan.md → plan.md → 체크박스를 가진 유일한 루트 md(planFile)
|
|
6
|
+
// 식별자 줄 md 블록 읽개(lib/md-blocks.mjs) 위에서 줄 모양으로 읽고, 같은 범위의 후보 줄 중 규칙이 읽지 못한 줄은 근거 줄을 단 이슈로 낸다
|
|
7
|
+
// 센 줄 노드 readLines { 파일: [줄 번호] } — 결정 정의, 계획 파일 체크박스, 잔여 질문 표 행
|
|
8
|
+
// 읽기 상태 plan·openQuestions·stage(lib/reading.mjs)
|
|
9
|
+
// 판정 파일 map/judgments/<작업 폴더>.json(lib/judgments.mjs). 적용 순서: 규칙 → planFile → lines → questions → 읽기 상태.
|
|
10
|
+
// 확인된 판정이 채우거나 고친 값은 judged, 근거가 사라진 판정이 맡던 값은 stale(값은 규칙으로 돌아가고 규칙 이슈는 다시 내지 않는다).
|
|
11
|
+
// 판정은 체크 여부·장부 상태·단계를 바꾸지 못한다
|
|
12
|
+
import { readBlocks } from '../lib/md-blocks.mjs';
|
|
13
|
+
import { setReading } from '../lib/reading.mjs';
|
|
14
|
+
import { readJudgments, JUDGMENT_LABEL } from '../lib/judgments.mjs';
|
|
15
|
+
|
|
16
|
+
const DEC_ID = /^DEC-\d+$/;
|
|
17
|
+
const PLAN_ID = /^[A-Z]{1,4}[0-9]?-[0-9]+$/;
|
|
18
|
+
const OQ_ID = /^OQ-(\d+)$/;
|
|
19
|
+
const LEGACY_PLAN_ID = /^(?:PN|P\d)-\d+$/i;
|
|
20
|
+
// 장부 절 제목 표시어(대소문자 무시). 한 제목에 여럿이면 이 순서로 앞선 것
|
|
21
|
+
const MARKERS = [
|
|
22
|
+
['폐기', ['폐기', 'cancel', 'abandon']],
|
|
23
|
+
['완료', ['완료', 'complete', 'done', 'shipped']],
|
|
24
|
+
['대기', ['대기', '중단', '보류', 'paused', 'blocked']],
|
|
25
|
+
['진행', ['진행', '실행 장부', 'in progress', '현재', 'active']],
|
|
26
|
+
];
|
|
27
|
+
// 같은 작업이 여러 절에 있을 때(1.1.1 우선순위)
|
|
28
|
+
const STATUS_ORDER = ['폐기', '진행', '완료', '대기'];
|
|
29
|
+
const LABEL = '작업 문서';
|
|
30
|
+
|
|
31
|
+
const markerOf = (title) => { const t = title.toLowerCase(); return MARKERS.find(([, words]) => words.some((w) => t.includes(w)))?.[0] || null; };
|
|
32
|
+
const isResidualTitle = (t) => (/잔여/.test(t) && /질문/.test(t)) || (/remaining/i.test(t) && /question/i.test(t));
|
|
33
|
+
const isZeroCell = (c) => c === '' || /^[-—–]+$/.test(c) || /^없음/.test(c);
|
|
34
|
+
// 계획 항목 설명: 머리 번호·slug·실행 주체 표시 뒤 첫 콜론 다음. 콜론이 없으면 머리 번호 뒤 글자
|
|
35
|
+
const descriptionOf = (item) => {
|
|
36
|
+
let t = item.head && PLAN_ID.test(item.head.id) ? item.head.rest : item.text;
|
|
37
|
+
const k = t.search(/[::]/);
|
|
38
|
+
if (k >= 0) t = t.slice(k + 1);
|
|
39
|
+
return t.trim().replace(/^[*_~`"'“]+/, '');
|
|
40
|
+
};
|
|
41
|
+
const cut = (s) => s.trim();
|
|
42
|
+
// 판정 초안에 실을 번호: 칸 글자의 번호 모양(취소선·굵게 표시는 벗긴다, 하이픈이 여럿인 R-OQ-01과 소문자가 붙은 OQ-H2b도 끝까지). 없으면 칸 글자
|
|
43
|
+
const idsIn = (cell) => { const m = cell.replace(/[*_~]/g, '').match(/[A-Z][A-Z0-9]*(?:-[A-Za-z0-9]+)+/g); return m ? [...new Set(m)] : [cell]; };
|
|
44
|
+
|
|
3
45
|
export default function tasks(g, fs, cfg) {
|
|
4
46
|
const c = cfg.tasks;
|
|
5
47
|
const dirs = fs.ls(c.dir).filter((n) => /^\d{8}-/.test(n) && fs.isDir(`${c.dir}/${n}`)).sort();
|
|
@@ -8,11 +50,60 @@ export default function tasks(g, fs, cfg) {
|
|
|
8
50
|
const sections = Object.fromEntries(index.split(/^## /m).slice(1).map((s) => [s.split('\n')[0].trim(), s]));
|
|
9
51
|
const mdIn = (rel) => fs.ls(rel).filter((f) => f.endsWith('.md'));
|
|
10
52
|
const heading = (rel) => { if (!rel || !fs.has(rel) || fs.isDir(rel)) return null; const m = fs.read(rel).match(/^#\s+(.+)$/m); return m ? m[1].trim() : null; };
|
|
11
|
-
// 장부
|
|
53
|
+
// 장부 노드(1.1.1 절 이름 규칙 그대로): 현재 실행 장부·실행 대기·중단
|
|
12
54
|
const rows = (s) => (s || '').split('\n').filter((l) => l.startsWith('|') && !/^\|[-\s|]+\|$/.test(l)).slice(1).map((l) => l.split('|').slice(1, -1).map((x) => x.trim()));
|
|
13
55
|
for (const [i, r] of rows(sections['현재 실행 장부']).entries()) g.add('ledger', `running-${i}`, r[0], { state: 'running', owner: r[1], scope: r[2], done: r[3] }, { file: c.index, line: null, rule: 'tasks:index 현재 실행 장부' });
|
|
14
56
|
for (const [i, r] of rows(sections['실행 대기·중단']).entries()) g.add('ledger', `waiting-${i}`, r[0], { state: /^완료/.test(r[1]) ? 'done' : 'waiting', status: r[1], resume: r[2] }, { file: c.index, line: null, rule: 'tasks:index 실행 대기·중단' });
|
|
15
57
|
|
|
58
|
+
// 파일 읽기는 한 번: 블록과 원문 줄
|
|
59
|
+
const cache = new Map();
|
|
60
|
+
const doc = (rel) => {
|
|
61
|
+
if (!cache.has(rel)) { const text = fs.read(rel); cache.set(rel, { blocks: readBlocks(text), lines: text.split(/\r?\n/) }); }
|
|
62
|
+
return cache.get(rel);
|
|
63
|
+
};
|
|
64
|
+
const anchor = (file, line) => ({ file, line, excerpt: cut(doc(file).lines[line - 1] ?? '') });
|
|
65
|
+
const judgments = readJudgments(fs, new Set(dirs), c.dir);
|
|
66
|
+
|
|
67
|
+
// 장부 상태: 표 행 중 (<폴더>/ 또는 (<폴더>) 링크를 가진 행만, 절 제목 표시어로 분류
|
|
68
|
+
const ledgerOf = new Map(); // 작업 → { statuses: Set, dropped }
|
|
69
|
+
const unknownSections = new Map(); // 절 제목 줄 → { title, anchors }
|
|
70
|
+
if (index) {
|
|
71
|
+
const ib = readBlocks(index);
|
|
72
|
+
const ilines = index.split(/\r?\n/);
|
|
73
|
+
for (const b of ib) {
|
|
74
|
+
if (b.type !== 'row') continue;
|
|
75
|
+
const names = new Set([...b.cells.join(' | ').matchAll(/\((\d{8}-[^/()\s]+)(?:\/|\))/g)].map((m) => m[1]));
|
|
76
|
+
if (!names.size) continue;
|
|
77
|
+
const marked = b.path.slice().reverse().map((p) => ({ p, m: markerOf(p.text) })).find((x) => x.m);
|
|
78
|
+
if (!marked) {
|
|
79
|
+
const sec = b.path[b.path.length - 1];
|
|
80
|
+
const key = sec ? sec.line : 0;
|
|
81
|
+
if (!unknownSections.has(key)) unknownSections.set(key, { title: sec ? sec.text : '(제목 없음)', anchors: [] });
|
|
82
|
+
unknownSections.get(key).anchors.push({ file: c.index, line: b.line, excerpt: cut(ilines[b.line - 1] ?? '') });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const dropped = /\*\*폐기\*\*|폐기\*\*/.test(b.cells.join(' | '));
|
|
86
|
+
for (const n of names) {
|
|
87
|
+
if (!ledgerOf.has(n)) ledgerOf.set(n, { statuses: new Set(), dropped: false });
|
|
88
|
+
const e = ledgerOf.get(n);
|
|
89
|
+
e.statuses.add(marked.m);
|
|
90
|
+
// 1.1.1 규칙: 장부 완료 행 글자의 **폐기**
|
|
91
|
+
if (dropped && marked.m === '완료') e.dropped = true;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 정의 수집: 번호 → [{ task, file, line }]. 노드는 1.1.1처럼 처음 만난 곳에서 만든다
|
|
97
|
+
const definedAt = new Map();
|
|
98
|
+
const struckIds = new Set();
|
|
99
|
+
const define = (id, task, file, line) => { if (!definedAt.has(id)) definedAt.set(id, []); const list = definedAt.get(id); if (!list.some((d) => d.task === task)) list.push({ task, file, line }); };
|
|
100
|
+
// link: 'always' 정의 작업, 'created' 1.1.1 호환 줄(노드를 만든 작업만 잇는다), 'never' 취소선 정의
|
|
101
|
+
const addNode = (task, id, props, file, line, rule, link) => {
|
|
102
|
+
const created = !g.get('decision', id);
|
|
103
|
+
if (created) g.add('decision', id, id, { ...props, file }, { file, line, rule });
|
|
104
|
+
if (link === 'always' || (link === 'created' && created)) g.link('task', task, 'defines', 'decision', id);
|
|
105
|
+
};
|
|
106
|
+
|
|
16
107
|
for (const name of dirs) {
|
|
17
108
|
const base = `${c.dir}/${name}`;
|
|
18
109
|
const spec = { initial: fs.has(`${base}/spec/initial.md`), review: fs.has(`${base}/spec/review-log.md`), final: fs.has(`${base}/spec/final.md`) };
|
|
@@ -21,38 +112,240 @@ export default function tasks(g, fs, cfg) {
|
|
|
21
112
|
const execution = mdIn(`${base}/execution`).length;
|
|
22
113
|
const verification = ['verification.md', 'phase-verification.md'].filter((f) => fs.has(`${base}/${f}`)).map((f) => `${base}/${f}`);
|
|
23
114
|
const stage = verification.length ? '검증' : execution ? '실행' : plan && phases ? 'phase 계획' : plan ? '계획' : spec.final ? '스펙 확정' : spec.initial ? '스펙 초안' : '조사·기록';
|
|
24
|
-
const
|
|
25
|
-
const
|
|
115
|
+
const specFile = spec.final ? `${base}/spec/final.md` : fs.has(`${base}/final.md`) && !fs.isDir(`${base}/final.md`) ? `${base}/final.md` : null;
|
|
116
|
+
const readLines = {};
|
|
117
|
+
const read = (file, line) => { (readLines[file] ||= []).push(line); };
|
|
118
|
+
const notes = {};
|
|
119
|
+
|
|
120
|
+
// 계획 파일: task_plan.md → plan.md → 체크박스를 가진 유일한 루트 md
|
|
121
|
+
const checkboxes = (file) => doc(file).blocks.filter((b) => b.type === 'item' && b.checkbox !== null);
|
|
122
|
+
let planFile = plan, planReading = 'rule';
|
|
123
|
+
let rootBoxes = [];
|
|
124
|
+
if (!plan) {
|
|
125
|
+
rootBoxes = fs.ls(base).filter((f) => f.endsWith('.md') && !fs.isDir(`${base}/${f}`)).sort().map((f) => `${base}/${f}`).filter((f) => checkboxes(f).length);
|
|
126
|
+
if (rootBoxes.length === 1) { planFile = rootBoxes[0]; notes.plan = `대체 규칙: 체크박스를 가진 유일한 루트 문서 ${planFile}`; }
|
|
127
|
+
else if (rootBoxes.length > 1) planReading = 'unknown';
|
|
128
|
+
else planReading = 'none';
|
|
129
|
+
}
|
|
130
|
+
// 판정: 확인된 근거 줄(covered)과 근거가 사라진 판정이 잡고 있는 후보 줄(claimed, 같은 파일에서 그 id를 가진 줄)
|
|
131
|
+
const jd = judgments.byTask.get(name) || null;
|
|
132
|
+
const jLines = jd ? jd.lines : [];
|
|
133
|
+
const jItems = jd ? jd.items : [];
|
|
134
|
+
const key = (file, line) => `${file}:${line}`;
|
|
135
|
+
const covered = new Set(), claimed = new Set();
|
|
136
|
+
for (const e of [...jLines, ...jItems, ...(jd?.none ? [jd.none] : [])]) {
|
|
137
|
+
if (e.loc.state === 'ok') covered.add(key(e.loc.file, e.loc.line));
|
|
138
|
+
else if (e.loc.state === 'stale') for (const x of e.loc.candidates) claimed.add(key(x.file, x.line));
|
|
139
|
+
}
|
|
140
|
+
// 후보 줄 나누기: 남은 줄, 판정이 확인한 줄 수, stale 판정이 잡은 줄 수
|
|
141
|
+
const splitCandidates = (file, lines) => ({
|
|
142
|
+
rest: lines.filter((l) => !covered.has(key(file, l)) && !claimed.has(key(file, l))),
|
|
143
|
+
judged: lines.filter((l) => covered.has(key(file, l))).length,
|
|
144
|
+
stale: lines.filter((l) => !covered.has(key(file, l)) && claimed.has(key(file, l))).length,
|
|
145
|
+
});
|
|
146
|
+
if (jd?.planFile) {
|
|
147
|
+
planFile = `${base}/${jd.planFile}`; planReading = 'judged'; rootBoxes = [];
|
|
148
|
+
notes.plan = `판정 planFile: ${planFile}(${jd.file})`;
|
|
149
|
+
}
|
|
26
150
|
const planText = plan ? fs.read(plan) : '';
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
: doneRow ? '완료'
|
|
31
|
-
: mention('실행 대기·중단') ? '대기'
|
|
32
|
-
: mention('현재 작업') ? '진행' : '기록';
|
|
151
|
+
const led = ledgerOf.get(name);
|
|
152
|
+
// 상태: 계획 문서 머리·장부 완료 행의 **폐기**, 그다음 장부 절 표시어(폐기·진행·완료·대기), 없으면 기록
|
|
153
|
+
const status = /\*\*폐기\*\*/.test(planText.slice(0, 2000)) || led?.dropped ? '폐기' : STATUS_ORDER.find((s) => led?.statuses.has(s)) || '기록';
|
|
33
154
|
const title = heading(plan) || heading(`${base}/spec/final.md`) || heading(`${base}/spec/initial.md`) || heading(`${base}/README.md`) || heading(`${base}/${mdIn(base)[0] || ''}`) || name.slice(9);
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
155
|
+
|
|
156
|
+
const issues = [];
|
|
157
|
+
const issue = (code, message, anchors, extra = {}) => issues.push([code, message, anchors, extra]);
|
|
158
|
+
|
|
159
|
+
// 결정: 스펙 final 목록 머리 번호(굵게 포함, 취소선 제외)만 센다. 표 첫 칸 DEC 번호는 후보 줄
|
|
160
|
+
let dec = 0, oq = 0;
|
|
161
|
+
const decTable = [];
|
|
162
|
+
const ruleDefined = new Set();
|
|
163
|
+
if (specFile) {
|
|
164
|
+
const { blocks } = doc(specFile);
|
|
165
|
+
for (const b of blocks) {
|
|
166
|
+
if (b.type === 'item' && b.head && DEC_ID.test(b.head.id)) {
|
|
167
|
+
if (b.head.struck) { struckIds.add(b.head.id); addNode(name, b.head.id, { kind: 'spec-dec' }, specFile, b.line, 'tasks:- DEC-nn', 'never'); continue; }
|
|
168
|
+
dec += 1;
|
|
169
|
+
ruleDefined.add(b.head.id);
|
|
170
|
+
read(specFile, b.line);
|
|
171
|
+
define(b.head.id, name, specFile, b.line);
|
|
172
|
+
addNode(name, b.head.id, { kind: 'spec-dec' }, specFile, b.line, 'tasks:- DEC-nn', 'always');
|
|
173
|
+
} else if (b.type === 'row' && /DEC-\d+/.test(b.cells[0])) decTable.push(b);
|
|
174
|
+
}
|
|
175
|
+
// oq: 1.1.1 규칙(spec/final.md의 `| OQ-n |` 표 행 수) 그대로
|
|
176
|
+
if (spec.final) oq = (fs.read(specFile).match(/^\| OQ-\d+ \|/gm) || []).length;
|
|
177
|
+
}
|
|
178
|
+
// 판정 lines: 표 첫 칸 결정 줄 등 규칙이 못 읽는 줄을 정의로 더하거나(definition) 후보에서 뺀다(ignore)
|
|
179
|
+
const oddRows = new Set(); // 잔여 질문 절의 번호 하나가 아닌 행(아래 질문 판정이 쓴다)
|
|
180
|
+
const decRest = splitCandidates(specFile, decTable.map((b) => b.line));
|
|
181
|
+
const decRows = decTable.filter((b) => decRest.rest.includes(b.line));
|
|
182
|
+
if (decRows.length) issue('tasks.unread-definition', `스펙 final 표 첫 칸의 결정 번호 ${decRows.length}줄을 규칙으로 읽지 않음`, decRows.map((b) => anchor(specFile, b.line)), {
|
|
183
|
+
judgmentDraft: { task: name, lines: decRows.flatMap((b) => idsIn(b.cells[0]).map((id) => ({ at: null, as: null, id }))) },
|
|
184
|
+
});
|
|
185
|
+
// 1.1.1 호환 노드: 계획 파일의 `- DEC-nn` 목록 줄(옮겨 적기). 결정 수·정의 작업·후보에 들지 않는다
|
|
186
|
+
if (plan) for (const b of doc(plan).blocks) if (b.type === 'item' && b.marker === '-' && b.indent === 0 && b.checkbox === null && b.head && !b.head.bold && !b.head.struck && DEC_ID.test(b.head.id)) addNode(name, b.head.id, { kind: 'spec-dec' }, plan, b.line, 'tasks:- DEC-nn', 'created');
|
|
187
|
+
|
|
188
|
+
// 계획 항목: 계획 파일의 체크박스 전부. 머리 번호가 있으면 정의
|
|
189
|
+
let pnDone = 0, pnOpen = 0;
|
|
190
|
+
const checked = [];
|
|
191
|
+
if (planFile) {
|
|
192
|
+
for (const b of checkboxes(planFile)) {
|
|
193
|
+
if (b.checked) { pnDone += 1; checked.push(b); } else pnOpen += 1;
|
|
194
|
+
read(planFile, b.line);
|
|
195
|
+
if (b.head && !b.head.struck && PLAN_ID.test(b.head.id)) {
|
|
196
|
+
define(b.head.id, name, planFile, b.line);
|
|
197
|
+
addNode(name, b.head.id, { kind: 'plan-item', done: b.checked }, planFile, b.line, 'tasks:- [ ] PN-nn', 'always');
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (planReading === 'rule' && pnDone + pnOpen === 0) planReading = 'none';
|
|
201
|
+
} else if (rootBoxes.length > 1) {
|
|
202
|
+
const parts = rootBoxes.map((f) => [f, splitCandidates(f, checkboxes(f).map((b) => b.line))]);
|
|
203
|
+
const rest = parts.flatMap(([f, x]) => x.rest.map((l) => anchor(f, l)));
|
|
204
|
+
notes.plan = `계획 파일이 없고 체크박스를 가진 루트 문서가 ${rootBoxes.length}개: ${rootBoxes.join(', ')}`;
|
|
205
|
+
if (rest.length) issue('tasks.unread-checklist', `계획 파일 없이 체크박스를 가진 루트 문서 ${rootBoxes.length}개`, rest, { judgmentDraft: { task: name, planFile: null } });
|
|
206
|
+
else planReading = parts.some(([, x]) => x.stale) ? 'stale' : 'judged';
|
|
207
|
+
}
|
|
208
|
+
// 판정 lines definition: 결정 번호는 정의와 결정 수에 더한다(규칙이 이미 정의한 번호는 세지 않는다). 잔여 질문 절의 행은 질문 판정에서 쓴다
|
|
209
|
+
for (const e of jLines) {
|
|
210
|
+
if (e.loc.state !== 'ok' || e.as !== 'definition') continue;
|
|
211
|
+
read(e.loc.file, e.loc.line);
|
|
212
|
+
if (!DEC_ID.test(e.id) || ruleDefined.has(e.id)) continue;
|
|
213
|
+
ruleDefined.add(e.id);
|
|
214
|
+
dec += 1;
|
|
215
|
+
define(e.id, name, e.loc.file, e.loc.line);
|
|
216
|
+
addNode(name, e.id, { kind: 'spec-dec' }, e.loc.file, e.loc.line, 'judgment:lines definition', 'always');
|
|
217
|
+
}
|
|
218
|
+
// 1.1.1 호환 노드: 스펙 final의 계획 초안 체크박스(PN·P<숫자>). 계획 항목 수·정의 작업에 들지 않는다
|
|
219
|
+
if (spec.final) for (const b of checkboxes(`${base}/spec/final.md`)) if (b.head && LEGACY_PLAN_ID.test(b.head.id)) addNode(name, b.head.id.toUpperCase(), { kind: 'plan-item', done: b.checked }, `${base}/spec/final.md`, b.line, 'tasks:- [ ] PN-nn', 'created');
|
|
220
|
+
|
|
221
|
+
// 잔여 질문: 제목에 잔여+질문(remaining+question)이 든 마지막 절의 첫 표. 규칙으로 읽은 뒤 질문 판정(questions.none·items, lines)을 적용한다
|
|
222
|
+
let openQuestions = 0, openQuestionIds = [], qReading = 'none';
|
|
223
|
+
const qNone = jd?.none || null;
|
|
224
|
+
const okItems = jItems.filter((e) => e.loc.state === 'ok');
|
|
225
|
+
const staleItems = jItems.filter((e) => e.loc.state === 'stale');
|
|
226
|
+
const noneOk = qNone?.loc.state === 'ok', noneStale = qNone?.loc.state === 'stale';
|
|
227
|
+
const qJudged = noneOk || okItems.length > 0;
|
|
228
|
+
const qStale = noneStale || staleItems.length > 0;
|
|
229
|
+
const itemOf = new Map(okItems.map((e) => [e.id, e]));
|
|
230
|
+
const staleIds = new Set(staleItems.map((e) => e.id));
|
|
231
|
+
// 절이 없거나 형식 밖인 작업: 판정 items가 열린 질문 목록이 된다
|
|
232
|
+
const fromItems = () => okItems.filter((e) => e.state === 'open').map((e) => e.id);
|
|
233
|
+
const judgedNote = () => `판정 questions(${jd.file})`;
|
|
234
|
+
const orUnjudged = (reading, note, ...issueArgs) => {
|
|
235
|
+
if (noneOk) { openQuestions = 0; openQuestionIds = []; qReading = qStale ? 'stale' : 'judged'; notes.openQuestions = judgedNote(); return; }
|
|
236
|
+
if (qJudged || qStale) { openQuestionIds = fromItems(); openQuestions = qJudged ? openQuestionIds.length : null; qReading = qStale ? 'stale' : 'judged'; notes.openQuestions = judgedNote(); return; }
|
|
237
|
+
qReading = reading; openQuestions = reading === 'unknown' ? null : 0;
|
|
238
|
+
if (note) notes.openQuestions = note;
|
|
239
|
+
if (issueArgs.length) issue(...issueArgs);
|
|
240
|
+
};
|
|
241
|
+
if (status === '폐기') qReading = 'none';
|
|
242
|
+
else if (!specFile) { if (spec.initial || spec.review) orUnjudged('unknown', '검토 전: spec/final.md 없음'); else if (qJudged || qStale) orUnjudged('none'); }
|
|
243
|
+
else {
|
|
244
|
+
const { blocks } = doc(specFile);
|
|
245
|
+
const secs = blocks.filter((b) => b.type === 'heading' && isResidualTitle(b.text));
|
|
246
|
+
const draftNone = { judgmentDraft: { task: name, questions: { none: { at: null } } } };
|
|
247
|
+
if (!secs.length) {
|
|
248
|
+
orUnjudged('unknown', `잔여 질문 절 없음: ${specFile}`, 'tasks.questions-unknown', '스펙 final에 잔여 질문 절 없음', [{ file: specFile, line: null }], draftNone);
|
|
249
|
+
} else {
|
|
250
|
+
const sec = secs[secs.length - 1];
|
|
251
|
+
if (secs.length > 1) notes.openQuestions = `잔여 질문 절 ${secs.length}개 중 마지막(${sec.line}줄)을 읽음`;
|
|
252
|
+
const inSec = blocks.filter((b) => b.type !== 'heading' && b.path.some((p) => p.line === sec.line));
|
|
253
|
+
const tableRows = inSec.filter((b) => b.type === 'row');
|
|
254
|
+
const first = tableRows.length ? tableRows[0].table : null;
|
|
255
|
+
const rowsOf = tableRows.filter((b) => b.table === first && !b.header);
|
|
256
|
+
const none = inSec.find((b) => b.type === 'text' && /^없음/.test(b.text));
|
|
257
|
+
if (first === null && !none) {
|
|
258
|
+
orUnjudged('unknown', `잔여 질문 절에 표도 \`없음:\` 줄도 없음: ${specFile}:${sec.line}`, 'tasks.questions-unknown', '잔여 질문 절에 표도 없음: 줄도 없음', [anchor(specFile, sec.line)], draftNone);
|
|
259
|
+
} else {
|
|
260
|
+
if (none && first === null) read(specFile, none.line);
|
|
261
|
+
const ids = [], odd = [];
|
|
262
|
+
for (const b of rowsOf) {
|
|
263
|
+
const cell = b.cells[0];
|
|
264
|
+
if (b.head && !b.head.struck && OQ_ID.test(b.head.id) && b.head.rest.trim() === '') { ids.push({ id: b.head.id, n: Number(b.head.id.match(OQ_ID)[1]), line: b.line }); read(specFile, b.line); }
|
|
265
|
+
else if (isZeroCell(cell)) read(specFile, b.line);
|
|
266
|
+
else odd.push(b);
|
|
267
|
+
}
|
|
268
|
+
for (const b of odd) oddRows.add(b.line);
|
|
269
|
+
// 판정 lines definition이 번호를 준 행은 질문으로 센다
|
|
270
|
+
for (const e of jLines) if (e.loc.state === 'ok' && e.as === 'definition' && e.loc.file === specFile && oddRows.has(e.loc.line)) {
|
|
271
|
+
const m = e.id.match(OQ_ID);
|
|
272
|
+
ids.push({ id: e.id, n: m ? Number(m[1]) : null, line: e.loc.line });
|
|
273
|
+
}
|
|
274
|
+
// 질문 닫힘: 체크한 계획 항목의 설명이 같은 번호로 시작(숫자 앞 0 무시)
|
|
275
|
+
const closes = (q) => q.n != null && checked.some((b) => { const m = descriptionOf(b).match(/^OQ-0*(\d+)(?!\d)/); return m && Number(m[1]) === q.n; });
|
|
276
|
+
const ruleOpen = ids.filter((q) => !closes(q));
|
|
277
|
+
// 판정 items가 그 번호의 규칙 판정을 덮는다
|
|
278
|
+
let open = ruleOpen.filter((q) => itemOf.get(q.id)?.state !== 'resolved');
|
|
279
|
+
for (const e of okItems) if (e.state === 'open' && !open.some((q) => q.id === e.id)) open.push({ id: e.id, line: e.loc.file === specFile ? e.loc.line : Number.MAX_SAFE_INTEGER });
|
|
280
|
+
open = open.map((q, i) => [q, i]).sort(([a, i], [b, j]) => a.line - b.line || i - j).map(([q]) => q);
|
|
281
|
+
const oddSplit = splitCandidates(specFile, odd.map((b) => b.line));
|
|
282
|
+
const oddRest = odd.filter((b) => oddSplit.rest.includes(b.line));
|
|
283
|
+
const unjudgedOpen = ruleOpen.filter((q) => !itemOf.has(q.id) && !staleIds.has(q.id));
|
|
284
|
+
if (noneOk) { openQuestions = 0; openQuestionIds = []; }
|
|
285
|
+
else { openQuestions = open.length; openQuestionIds = open.map((q) => q.id); }
|
|
286
|
+
const judgedAny = qJudged || oddSplit.judged > 0;
|
|
287
|
+
const staleAny = qStale || oddSplit.stale > 0;
|
|
288
|
+
const claimsAll = noneOk || noneStale;
|
|
289
|
+
const partial = !claimsAll && (oddRest.length > 0 || (status === '완료' && unjudgedOpen.length > 0));
|
|
290
|
+
qReading = staleAny ? 'stale' : partial ? 'partial' : judgedAny ? 'judged' : 'rule';
|
|
291
|
+
if (judgedAny || staleAny) notes.openQuestions = judgedNote();
|
|
292
|
+
if (!claimsAll && oddRest.length) {
|
|
293
|
+
issue('tasks.unread-definition', `잔여 질문 표에서 첫 칸이 번호 하나가 아닌 행 ${oddRest.length}`, oddRest.map((b) => anchor(specFile, b.line)), {
|
|
294
|
+
judgmentDraft: { task: name, questions: { items: oddRest.flatMap((b) => idsIn(b.cells[0]).map((id) => ({ id, state: null, at: null }))) } },
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
if (!claimsAll && status === '완료' && unjudgedOpen.length) {
|
|
298
|
+
issue('tasks.questions-open-done', `완료 작업에 닫히지 않은 잔여 질문 ${unjudgedOpen.length}`, unjudgedOpen.map((q) => anchor(specFile, q.line)), {
|
|
299
|
+
judgmentDraft: { task: name, questions: { items: unjudgedOpen.map((q) => ({ id: q.id, state: null, at: null })) } },
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
50
303
|
}
|
|
51
304
|
}
|
|
305
|
+
// 폐기 작업의 잔여 질문 표는 읽되 세지 않는다
|
|
306
|
+
if (status === '폐기' && specFile) {
|
|
307
|
+
const { blocks } = doc(specFile);
|
|
308
|
+
const secs = blocks.filter((b) => b.type === 'heading' && isResidualTitle(b.text));
|
|
309
|
+
const sec = secs[secs.length - 1];
|
|
310
|
+
if (sec) for (const b of blocks) if (b.type === 'row' && b.path.some((p) => p.line === sec.line)) read(specFile, b.line);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// 단계: 파이프라인 문서가 없으면 1.1.1 기본값 문자열에 읽기 상태 unknown
|
|
314
|
+
const noPipeline = stage === '조사·기록';
|
|
315
|
+
if (noPipeline && (status === '진행' || status === '대기')) issue('tasks.stage-unknown', `${status} 작업에 파이프라인 문서 없음`, []);
|
|
316
|
+
|
|
52
317
|
const ref = fs.hasGit() ? fs.resolveRef(cfg.git?.branch || 'main') : null;
|
|
53
318
|
const recent = ref ? Number(fs.git('rev-list', '--count', ref, `--since=${cfg.git?.sinceDays || 14} days ago`, '--', base) || 0) : 0;
|
|
54
|
-
|
|
55
|
-
|
|
319
|
+
// 위키 출처 수: wiki.sources 설정이 없으면 0
|
|
320
|
+
const wikiSources = cfg.wiki?.sources && fs.has(cfg.wiki.sources) ? (fs.read(cfg.wiki.sources).match(new RegExp(name, 'g')) || []).length : 0;
|
|
321
|
+
for (const f of Object.keys(readLines)) readLines[f] = [...new Set(readLines[f])].sort((a, b) => a - b);
|
|
322
|
+
const node = g.add('task', name, title, {
|
|
323
|
+
date: `${name.slice(0, 4)}-${name.slice(4, 6)}-${name.slice(6, 8)}`, slug: name.slice(9), stage, status, spec, plan, phases, execution, verification, dec, pnDone, pnOpen, oq,
|
|
324
|
+
recentCommits: recent, wikiSources, files: fs.walk(base, (p) => p.endsWith('.md')).length, last: fs.lastCommit(base),
|
|
325
|
+
openQuestions, openQuestionIds, planFile, specFile, readLines,
|
|
326
|
+
judged: jd ? jd.file : null,
|
|
327
|
+
judgment: jd ? { by: jd.by, note: jd.note, ...jd.counts } : null,
|
|
328
|
+
}, { file: plan || base, line: 1, rule: 'tasks:folder' });
|
|
329
|
+
setReading(node, 'plan', planReading, notes.plan);
|
|
330
|
+
setReading(node, 'openQuestions', qReading, notes.openQuestions);
|
|
331
|
+
setReading(node, 'stage', noPipeline ? 'unknown' : 'rule', noPipeline ? '파이프라인 문서 없음(스펙·계획·phase·execution·검증 문서)' : undefined);
|
|
332
|
+
if (specFile && !spec.final) node.props.readingNotes.spec = `스펙 final 대체: ${specFile}`;
|
|
333
|
+
for (const [code, message, anchors, extra] of issues) g.issue('warn', LABEL, message, { code, subject: { kind: 'task', id: name }, anchors, ...extra });
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// 판정 파일 문제: 파일 모양·근거 조각(judgment.invalid error, judgment.stale warn)
|
|
337
|
+
for (const [level, code, message, detail] of judgments.issues) g.issue(level, JUDGMENT_LABEL, message, { code, ...detail });
|
|
338
|
+
|
|
339
|
+
// 장부: 표시어 없는 절의 작업 링크 행
|
|
340
|
+
for (const { title, anchors } of unknownSections.values()) g.issue('warn', '작업 장부', `표시어가 없는 절의 작업 링크 행 ${anchors.length}: ${title}`, { code: 'tasks.index-section-unknown', subject: { kind: 'ledger', id: title }, anchors });
|
|
341
|
+
|
|
342
|
+
// 번호마다 정의한 작업 목록. 취소선 정의만 있는 번호는 struck
|
|
343
|
+
for (const d of g.of('decision')) {
|
|
344
|
+
if (d.props.kind === 'wiki') continue;
|
|
345
|
+
const list = (definedAt.get(d.id) || []).slice().sort((a, b) => a.task.localeCompare(b.task));
|
|
346
|
+
d.props.definers = list.map((x) => x.task);
|
|
347
|
+
d.props.definedAt = list;
|
|
348
|
+
if (!list.length && struckIds.has(d.id)) d.props.struck = true;
|
|
56
349
|
}
|
|
57
350
|
return null;
|
|
58
351
|
}
|