@kaydash9999/banker-plugins 0.22.0 → 0.23.2
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +68 -0
- package/PRIVACY.md +1 -0
- package/README.md +39 -13
- package/bin/banker.js +118 -22
- package/bin/lib/codex-hooks.mjs +116 -18
- package/bin/lib/codex-names.mjs +4 -0
- package/bin/lib/codex-routes.mjs +93 -1
- package/bin/lib/command-skill.cjs +37 -6
- package/codex/command-skills/front-qa/SKILL.md +1 -1
- package/codex/command-skills/setup/SKILL.md +4 -4
- package/codex/hooks.json +5 -5
- package/codex/manifest.json +18 -15
- package/codex/skills/progress/scripts/progress.mjs +44 -14
- package/codex/skills/teamwork/SKILL.md +143 -0
- package/codex/skills/teamwork/scripts/teamwork.mjs +1007 -0
- package/codex/transform-matrix.md +5 -3
- package/hooks/obsidize-hook.mjs +75 -32
- package/hooks/telemetry-count-codex.mjs +238 -57
- package/hooks/update-fetch.mjs +2 -1
- package/hooks/update-notify.mjs +82 -20
- package/package.json +3 -1
- package/skills/3d-intro-build/SKILL.md +101 -20
- package/skills/3d-intro-build/references/NOTICE.md +9 -2
- package/skills/3d-intro-build/references/assemble.mjs +113 -22
- package/skills/3d-intro-build/references/azure-adapter.mjs +110 -24
- package/skills/3d-intro-build/references/clip-post.mjs +443 -0
- package/skills/3d-intro-build/references/dive-mode.mjs +299 -0
- package/skills/3d-intro-build/references/intro-fixes.css +11 -0
- package/skills/3d-intro-build/references/prompts.md +51 -4
- package/skills/3d-intro-build/references/scrub-engine.js +1 -0
- package/skills/3d-intro-build/references/step-nav.css +29 -0
- package/skills/3d-intro-build/references/step-nav.js +284 -106
- package/skills/3d-intro-build/references/verify-intro.mjs +373 -32
- package/skills/3d-intro-build/references/verify.md +6 -0
- package/skills/3d-intro-build/references/video-pool.mjs +142 -12
- package/skills/3d-intro-setup/SKILL.md +20 -7
- package/skills/3d-intro-setup/references/azure-adapter.mjs +110 -24
- package/skills/3d-intro-setup/references/video-pool.mjs +142 -12
- package/skills/all-in-one/SKILL.md +125 -86
- package/skills/omc-patch/SKILL.md +1 -1
- package/skills/payload-mon/SKILL.md +1 -1
- package/skills/play-qa/SKILL.md +6 -4
- package/skills/progress-mon/README.md +164 -0
- package/skills/progress-mon/SKILL.md +165 -0
- package/skills/progress-mon/assets/fixture/README.md +1 -0
- package/skills/progress-mon/assets/fixture/arch.json +33 -0
- package/skills/progress-mon/assets/fixture/commits.json +7 -0
- package/skills/progress-mon/assets/fixture/config.json +30 -0
- package/skills/progress-mon/assets/fixture/events.jsonl +7 -0
- package/skills/progress-mon/assets/fixture/flow.json +44 -0
- package/skills/progress-mon/assets/fixture/items.json +14 -0
- package/skills/progress-mon/assets/fixture/req-spec.json +31 -0
- package/skills/progress-mon/assets/fixture/reqs.json +11 -0
- package/skills/progress-mon/assets/fixture/variants/arch-auto/arch.json +58 -0
- package/skills/progress-mon/assets/fixture/variants/check-auto/arch.json +15 -0
- package/skills/progress-mon/assets/fixture/variants/check-overlap/arch.json +14 -0
- package/skills/progress-mon/assets/fixture/variants/history-manual/config.json +13 -0
- package/skills/progress-mon/assets/fixture/variants/history-manual/items.json +7 -0
- package/skills/progress-mon/assets/fixture/variants/workflow-wide/flow.json +35 -0
- package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a01.jsonl +1 -0
- package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a02.jsonl +1 -0
- package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a03.jsonl +1 -0
- package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a04.jsonl +1 -0
- package/skills/progress-mon/assets/fixture/workflows/wf_ex1/journal.jsonl +7 -0
- package/skills/progress-mon/references/arch.md +280 -0
- package/skills/progress-mon/references/authoring.md +199 -0
- package/skills/progress-mon/references/config.md +115 -0
- package/skills/progress-mon/references/flow.md +75 -0
- package/skills/progress-mon/references/inventory.md +58 -0
- package/skills/progress-mon/references/items.md +74 -0
- package/skills/progress-mon/references/req-spec.md +74 -0
- package/skills/progress-mon/references/reqs.md +30 -0
- package/skills/progress-mon/scripts/index.js +31 -0
- package/skills/progress-mon/scripts/pm-check.mjs +549 -0
- package/skills/progress-mon/scripts/pm-config.mjs +343 -0
- package/skills/progress-mon/scripts/pm-data.mjs +394 -0
- package/skills/progress-mon/scripts/pm-html.mjs +77 -0
- package/skills/progress-mon/scripts/pm-memory.mjs +333 -0
- package/skills/progress-mon/scripts/pm-render.mjs +1644 -0
- package/skills/progress-mon/scripts/pm-server.mjs +781 -0
- package/skills/progress-mon/scripts/pm-sources.mjs +501 -0
- package/skills/progress-mon/scripts/progress-mon.mjs +167 -0
- package/skills/ralph-qa/SKILL.md +107 -17
- package/skills/ralph-qa/references/gate-evidence.mjs +457 -0
- package/skills/ralph-qa/references/payload-scan.mjs +19 -2
- package/skills/ralph-qa/references/run-folder.mjs +299 -0
- package/skills/ralph-qa/references/seat-payload.mjs +60 -13
- package/skills/ralph-qa/references/seat-run.mjs +200 -159
- package/skills/remains/SKILL.md +2 -0
- package/skills/remains/scripts/boxes.mjs +6 -2
- package/skills/setup-bypass-permissions/scripts/codex-config.mjs +19 -8
- package/skills/setup-mcp/SKILL.md +1 -1
- package/skills/setup-omc-hud/SKILL.md +2 -1
- package/skills/setup-omc-hud/assets/codex-config.mjs +19 -8
- package/skills/ultra-chain/SKILL.md +32 -14
- package/skills/ultra-chain/references/chain-ledger.mjs +111 -3
- package/skills/ultra-init/SKILL.md +30 -22
- package/skills/ultraqa/NOTICE.md +34 -0
- package/skills/ultraqa/SKILL.md +197 -0
- package/skills/ultraqa/references/gate-evidence.mjs +457 -0
- package/skills/ultraqa/references/run-folder.mjs +299 -0
- package/skills/ultraqa/references/tmux-drive.md +52 -0
- package/skills/ultraqa/references/uqa-run.mjs +339 -0
- package/skills/update-banker/SKILL.md +1 -1
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// `node --test <이 폴더>/` 진입. Node 24 는 폴더 인자를 모듈 경로로 풀어 이 index.js 를 찾는다.
|
|
2
|
+
// 그래서 이 파일이 폴더의 *.test.mjs 를 모두 적재한다(CommonJS, Node 18 도 같음).
|
|
3
|
+
// 적재에 실패한 파일은 실패하는 `load <파일>` 시험으로 남기고 다음 파일을 계속 적재한다.
|
|
4
|
+
// 마지막 시험 `all <n> test files loaded` 는 모든 파일이 오류 없이 적재될 때만 통과한다.
|
|
5
|
+
// 적재 중 process.exit 를 부르는 파일이 있으면 이 시험이 TAP 에 나오지 않아 판정에서 걸린다.
|
|
6
|
+
const fs = require('node:fs');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
const { pathToFileURL } = require('node:url');
|
|
9
|
+
const { test } = require('node:test');
|
|
10
|
+
|
|
11
|
+
(async () => {
|
|
12
|
+
const files = fs.readdirSync(__dirname).filter((n) => /\.test\.mjs$/.test(n)).sort();
|
|
13
|
+
let attempted = 0;
|
|
14
|
+
const failed = [];
|
|
15
|
+
for (const f of files) {
|
|
16
|
+
attempted++;
|
|
17
|
+
try {
|
|
18
|
+
await import(pathToFileURL(path.join(__dirname, f)).href);
|
|
19
|
+
} catch (e) {
|
|
20
|
+
failed.push(f);
|
|
21
|
+
test(`load ${f}`, { timeout: 60000 }, () => {
|
|
22
|
+
throw e;
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
test(`all ${files.length} test files loaded`, { timeout: 60000 }, () => {
|
|
27
|
+
if (attempted !== files.length || failed.length) {
|
|
28
|
+
throw new Error(`attempted ${attempted}/${files.length}, load failures ${failed.length}: ${failed.join(', ')}`);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
})();
|
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
// pm-check.mjs: 자료 파일 검사(계획 6.3절, 4.9절). 한 줄에 하나씩 `FAIL <파일> <json 경로> <이유>` 또는 `WARN ...`,
|
|
2
|
+
// 끝에 `check: FAIL n, WARN m`. FAIL 이 있으면 종료 1. 자료 폴더를 만들지 않는다.
|
|
3
|
+
// 구성도와 workflow 기하는 렌더와 같은 배치 계산(archLayout, archFlaws, flowLayout)에 검사한다.
|
|
4
|
+
// 밀도(4.9절)는 init 이 만든 자료 폴더(state.json 의 memory)나 inventory.json 이 있을 때만 재고, 미달은 WARN(종료 코드 영향 없음).
|
|
5
|
+
// 항목마다 재는 밀도 미달 줄은 끝에 못 채운 대상 이름을 붙인다(`: a, b 외 n`).
|
|
6
|
+
// 끝에 `density <탭> ok|low` 4줄. 형식 시험 자료와 옮긴 자료(둘 다 init 기록 없음)는 밀도를 재지 않는다.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { loadConfig, noBom, readRegular, UsageError } from './pm-config.mjs';
|
|
10
|
+
import { gitLog, regexBatch, readItems, storyList, workflowRoots } from './pm-sources.mjs';
|
|
11
|
+
import { ITEM_STATUS, STAGE_STATUS } from './pm-data.mjs';
|
|
12
|
+
import { archLayout, archFlaws, flowLayout, nameFits, FLOW } from './pm-render.mjs';
|
|
13
|
+
import { color, finite, textW, wrap, TABS } from './pm-html.mjs';
|
|
14
|
+
import { memoryWarnings } from './pm-memory.mjs';
|
|
15
|
+
|
|
16
|
+
const arr = (v) => (Array.isArray(v) ? v : []);
|
|
17
|
+
const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : null);
|
|
18
|
+
const str = (v) => typeof v === 'string' && v.length > 0;
|
|
19
|
+
const REQ_STATES = ['done', 'part', 'doing', 'todo', 'unv', 'excl', 'none'];
|
|
20
|
+
const EDGE_KINDS = ['api', 'control', 'data'];
|
|
21
|
+
// 크기 지침(4.5절). 넘으면 WARN.
|
|
22
|
+
const SIZE = { lanes: 6, steps: 15, nodes: 60, edges: 60 };
|
|
23
|
+
// 밀도 하한(4.9절).
|
|
24
|
+
const DENSITY = { zones: 5, nodes: 20, edges: 15, legend: 1, main: 1, scenes: 5, lanes: 2, steps: 7 };
|
|
25
|
+
// 분기 표가 오른쪽 이웃 칸으로 들어가는 폭(상자 폭 + 칸 사이 - 표 시작 20).
|
|
26
|
+
const BRANCH_ROOM = FLOW.FW + FLOW.FGX - 20;
|
|
27
|
+
|
|
28
|
+
// 두 사각형이 가로와 세로 모두 1 px 이상 겹침. 변이 닿기만 하면 겹침 아님.
|
|
29
|
+
const ov = (a0, a1, b0, b1) => Math.min(a1, b1) - Math.max(a0, b0);
|
|
30
|
+
const hit = (a, b) => ov(a.x, a.x + a.w, b.x, b.x + b.w) >= 1 && ov(a.y, a.y + a.h, b.y, b.y + b.h) >= 1;
|
|
31
|
+
const insideRect = (n, z) => n.x >= z.x && n.y >= z.y && n.x + n.w <= z.x + z.w && n.y + n.h <= z.y + z.h;
|
|
32
|
+
|
|
33
|
+
// JSON 파일 읽기. 돌려줌: { missing } | { value } | { error }
|
|
34
|
+
function readJson(file) {
|
|
35
|
+
let text;
|
|
36
|
+
try {
|
|
37
|
+
text = readRegular(file);
|
|
38
|
+
} catch (e) {
|
|
39
|
+
return e.code === 'ENOENT' ? { missing: true } : { error: `읽기 실패(${e.code || 'error'})` };
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
return { value: JSON.parse(noBom(text)) };
|
|
43
|
+
} catch (e) {
|
|
44
|
+
return { error: `JSON 아님: ${e.message}` };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 검사 결과 모음. 파일마다 FAIL, WARN 줄.
|
|
49
|
+
function sink() {
|
|
50
|
+
const lines = [];
|
|
51
|
+
const add = (level) => (file, p, msg) => lines.push({ level, file, path: p || '$', msg });
|
|
52
|
+
return { lines, fail: add('FAIL'), warn: add('WARN') };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------- config.json ----------
|
|
56
|
+
|
|
57
|
+
function checkConfig(ctx, r) {
|
|
58
|
+
const { config, errors } = loadConfig(ctx);
|
|
59
|
+
for (const e of errors) r.fail('config.json', e.path === '$' ? '$' : e.path.replace(/^\$\./, ''), e.msg);
|
|
60
|
+
config.stories.forEach((s, i) => {
|
|
61
|
+
if (s.id.includes('-')) r.warn('config.json', `stories[${i}].id`, `id 에 - 가 있어 에이전트 라벨 토큰 판정이 안 됨: ${s.id}. agentLabelPattern 을 쓰거나 id 를 바꿈`);
|
|
62
|
+
});
|
|
63
|
+
return config;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// commitRegex 를 실제 커밋 제목으로 돌려 본다(컴파일만 보던 것). 역추적이 폭발하면 화면은 매 요청 500 인데 check 는
|
|
67
|
+
// 통과하던 것을 FAIL 로 잡는다. 화면처럼 스토리(ids)가 없으면 이 정규식을 쓰지 않으므로 돌리지 않는다.
|
|
68
|
+
// sources.git 이 꺼졌거나 git 저장소가 아니면 돌려보지 못해 WARN.
|
|
69
|
+
function checkCommitRegex(ctx, config, ids, r) {
|
|
70
|
+
if (!config.commitRegex || !ids.length) return;
|
|
71
|
+
if (!config.sources.git) return r.warn('config.json', 'commitRegex', 'sources.git 이 꺼져 있어 실제 커밋 제목으로 돌려보지 못함(화면도 이 정규식을 쓰지 않음)');
|
|
72
|
+
const lines = gitLog(ctx.project, config.sources.gitMax);
|
|
73
|
+
if (lines === null) return r.warn('config.json', 'commitRegex', 'git 저장소가 아니거나 커밋이 없어 실제 제목으로 돌려보지 못함');
|
|
74
|
+
try {
|
|
75
|
+
regexBatch(config.commitRegex, 'commitRegex', lines.map((l) => l.split('|').slice(2).join('|')));
|
|
76
|
+
} catch (e) {
|
|
77
|
+
r.fail('config.json', 'commitRegex', e.message);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------- items.json ----------
|
|
82
|
+
|
|
83
|
+
// storyCommits 의 값은 해시 문자열이나 {hash}. 둘 다 16진 4~40자(item --commit, story --commit 과 같은 규칙).
|
|
84
|
+
function checkStoryCommits(commits, r) {
|
|
85
|
+
for (const [id, c] of Object.entries(obj(commits) || {})) {
|
|
86
|
+
const hash = typeof c === 'string' ? c : obj(c)?.hash;
|
|
87
|
+
if (typeof hash !== 'string') r.fail('items.json', `storyCommits.${id}`, '해시 문자열 또는 {hash} 가 아님');
|
|
88
|
+
else if (!/^[0-9a-f]{4,40}$/i.test(hash)) r.fail('items.json', `storyCommits.${id}`, `4~40자 16진 해시가 아님: ${hash}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function checkItems(dir, r) {
|
|
93
|
+
const got = readJson(path.join(dir, 'items.json'));
|
|
94
|
+
if (got.missing) return;
|
|
95
|
+
const F = 'items.json';
|
|
96
|
+
if (got.error) return r.fail(F, '$', got.error);
|
|
97
|
+
const v = obj(got.value);
|
|
98
|
+
if (!v) return r.fail(F, '$', '객체가 아님');
|
|
99
|
+
for (const k of ['notes', 'storyCommits', 'storyStates']) if (v[k] !== undefined && !obj(v[k])) r.fail(F, k, '객체가 아님');
|
|
100
|
+
for (const k of ['items', 'stageEvents']) if (v[k] !== undefined && !Array.isArray(v[k])) r.fail(F, k, '배열이 아님');
|
|
101
|
+
for (const [k, n] of Object.entries(obj(v.notes) || {})) {
|
|
102
|
+
if (!obj(n)) r.fail(F, `notes.${k}`, '객체가 아님');
|
|
103
|
+
else for (const f of ['note', 'issue']) if (n[f] !== undefined && typeof n[f] !== 'string') r.fail(F, `notes.${k}.${f}`, '문자열이 아님');
|
|
104
|
+
}
|
|
105
|
+
arr(v.items).forEach((x, i) => {
|
|
106
|
+
const p = `items[${i}]`;
|
|
107
|
+
if (!obj(x)) return r.fail(F, p, '객체가 아님');
|
|
108
|
+
if (!ITEM_STATUS.includes(x.status)) r.fail(F, `${p}.status`, `todo, doing, done 가운데 하나가 아님: ${x.status}`);
|
|
109
|
+
if (typeof x.text !== 'string') r.fail(F, `${p}.text`, '문자열이 아님');
|
|
110
|
+
for (const f of ['key', 't', 's', 'note', 'issue', 'hash']) if (x[f] !== undefined && typeof x[f] !== 'string') r.fail(F, `${p}.${f}`, '문자열이 아님');
|
|
111
|
+
if (typeof x.hash === 'string' && !/^[0-9a-f]{4,40}$/i.test(x.hash)) r.fail(F, `${p}.hash`, `4~40자 16진 해시가 아님: ${x.hash}`);
|
|
112
|
+
});
|
|
113
|
+
checkStoryCommits(v.storyCommits, r);
|
|
114
|
+
for (const [id, s] of Object.entries(obj(v.storyStates) || {})) {
|
|
115
|
+
if (!obj(s) || !ITEM_STATUS.includes(s.status)) r.fail(F, `storyStates.${id}`, '{status: todo|doing|done} 가 아님');
|
|
116
|
+
}
|
|
117
|
+
arr(v.stageEvents).forEach((e, i) => {
|
|
118
|
+
if (!obj(e) || !str(e.stage) || !STAGE_STATUS.includes(e.status)) r.fail(F, `stageEvents[${i}]`, '{t, stage, status: started|done|skipped|failed|stopped} 가 아님');
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------- 구현요건 ----------
|
|
123
|
+
|
|
124
|
+
function checkReq(dir, r, storyIds) {
|
|
125
|
+
const spec = readJson(path.join(dir, 'req-spec.json'));
|
|
126
|
+
const reqs = readJson(path.join(dir, 'reqs.json'));
|
|
127
|
+
const S = 'req-spec.json';
|
|
128
|
+
const Q = 'reqs.json';
|
|
129
|
+
let acIds = null;
|
|
130
|
+
const acStories = new Map();
|
|
131
|
+
if (reqs.error) r.fail(Q, '$', reqs.error);
|
|
132
|
+
else if (reqs.value !== undefined) {
|
|
133
|
+
const v = obj(reqs.value);
|
|
134
|
+
if (!v) r.fail(Q, '$', '객체가 아님');
|
|
135
|
+
else if (!Array.isArray(v.acs)) r.fail(Q, 'acs', '배열이 아님');
|
|
136
|
+
else {
|
|
137
|
+
acIds = new Set();
|
|
138
|
+
const known = new Set([...storyIds, ...Object.keys(obj(spec.value && spec.value.stories) || {})]);
|
|
139
|
+
v.acs.forEach((a, i) => {
|
|
140
|
+
if (!obj(a) || !str(a.id)) return r.fail(Q, `acs[${i}]`, 'id 가 없음');
|
|
141
|
+
if (acIds.has(a.id)) r.fail(Q, `acs[${i}].id`, `id 겹침: ${a.id}`);
|
|
142
|
+
acIds.add(a.id);
|
|
143
|
+
if (a.stories !== undefined && !(Array.isArray(a.stories) && a.stories.every(str))) return r.fail(Q, `acs[${i}].stories`, '문자열 배열이 아님');
|
|
144
|
+
acStories.set(a.id, arr(a.stories));
|
|
145
|
+
for (const s of arr(a.stories)) if (!known.has(s)) r.fail(Q, `acs[${i}].stories`, `스토리 없음: ${s}`);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (spec.missing) return { users: [], specs: [], acStories };
|
|
150
|
+
if (spec.error) {
|
|
151
|
+
r.fail(S, '$', spec.error);
|
|
152
|
+
return { users: [], specs: [], acStories };
|
|
153
|
+
}
|
|
154
|
+
const v = obj(spec.value);
|
|
155
|
+
if (!v) {
|
|
156
|
+
r.fail(S, '$', '객체가 아님');
|
|
157
|
+
return { users: [], specs: [], acStories };
|
|
158
|
+
}
|
|
159
|
+
if (!Array.isArray(v.users)) r.fail(S, 'users', '배열이 아님');
|
|
160
|
+
if (v.stories !== undefined && !obj(v.stories)) r.fail(S, 'stories', '객체가 아님');
|
|
161
|
+
if (reqs.missing && arr(v.users).some((u) => arr(obj(u) && u.specs).some((x) => arr(obj(x) && x.acs).length))) r.warn(Q, '$', '파일 없음. 근거 AC 의 담당 스토리를 알 수 없어 미검증으로 판정');
|
|
162
|
+
const specs = [];
|
|
163
|
+
// 밀도 미달 줄에 쓰는 json 경로(요구, 명세 객체마다).
|
|
164
|
+
const where = new Map();
|
|
165
|
+
arr(v.users).forEach((u, ui) => {
|
|
166
|
+
const p = `users[${ui}]`;
|
|
167
|
+
if (!obj(u)) return r.fail(S, p, '객체가 아님');
|
|
168
|
+
where.set(u, p);
|
|
169
|
+
if (typeof u.text !== 'string') r.fail(S, `${p}.text`, '문자열이 아님');
|
|
170
|
+
if (!Array.isArray(u.specs)) return r.fail(S, `${p}.specs`, '배열이 아님');
|
|
171
|
+
u.specs.forEach((x, si) => {
|
|
172
|
+
const q = `${p}.specs[${si}]`;
|
|
173
|
+
if (!obj(x)) return r.fail(S, q, '객체가 아님');
|
|
174
|
+
if (typeof x.text !== 'string') r.fail(S, `${q}.text`, '문자열이 아님');
|
|
175
|
+
if (x.acs !== undefined && !(Array.isArray(x.acs) && x.acs.every(str))) return r.fail(S, `${q}.acs`, '문자열 배열이 아님');
|
|
176
|
+
if (x.state !== undefined && !REQ_STATES.includes(x.state)) r.fail(S, `${q}.state`, `${REQ_STATES.join(', ')} 가운데 하나가 아님: ${x.state}`);
|
|
177
|
+
if (acIds) for (const a of arr(x.acs)) if (!acIds.has(a)) r.fail(S, `${q}.acs`, `AC 없음: ${a}`);
|
|
178
|
+
specs.push(x);
|
|
179
|
+
where.set(x, q);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
return { users: arr(v.users).filter(obj), specs, acStories, where };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---------- workflow ----------
|
|
186
|
+
|
|
187
|
+
function checkFlow(dir, r) {
|
|
188
|
+
const got = readJson(path.join(dir, 'flow.json'));
|
|
189
|
+
const F = 'flow.json';
|
|
190
|
+
if (got.missing) return null;
|
|
191
|
+
if (got.error) return r.fail(F, '$', got.error), null;
|
|
192
|
+
const f = obj(got.value);
|
|
193
|
+
if (!f) return r.fail(F, '$', '객체가 아님'), null;
|
|
194
|
+
if (!Array.isArray(f.lanes)) return r.fail(F, 'lanes', '배열이 아님'), null;
|
|
195
|
+
if (f.lanes.length > SIZE.lanes) r.warn(F, 'lanes', `크기 지침 초과: 흐름 ${f.lanes.length} > ${SIZE.lanes}`);
|
|
196
|
+
const laneIds = new Map();
|
|
197
|
+
let broken = false;
|
|
198
|
+
f.lanes.forEach((l, li) => {
|
|
199
|
+
const p = `lanes[${li}]`;
|
|
200
|
+
if (!obj(l)) return r.fail(F, p, '객체가 아님'), (broken = true);
|
|
201
|
+
if (!str(l.id)) r.fail(F, `${p}.id`, '빈 문자열이 아닌 문자열이 아님');
|
|
202
|
+
else if (laneIds.has(l.id)) r.fail(F, `${p}.id`, `id 겹침: ${l.id}`);
|
|
203
|
+
if (typeof l.title !== 'string') r.fail(F, `${p}.title`, '문자열이 아님');
|
|
204
|
+
if (l.color !== undefined && !color(l.color, '')) r.fail(F, `${p}.color`, `#rrggbb 가 아님: ${l.color}`);
|
|
205
|
+
if (!Array.isArray(l.steps)) return r.fail(F, `${p}.steps`, '배열이 아님'), (broken = true);
|
|
206
|
+
if (l.steps.length > SIZE.steps) r.warn(F, `${p}.steps`, `크기 지침 초과: 단계 ${l.steps.length} > ${SIZE.steps}`);
|
|
207
|
+
const ids = new Set();
|
|
208
|
+
l.steps.forEach((s, si) => {
|
|
209
|
+
const q = `${p}.steps[${si}]`;
|
|
210
|
+
if (!obj(s) || !str(s.id)) return r.fail(F, q, 'id 가 없음'), (broken = true);
|
|
211
|
+
if (ids.has(s.id)) r.fail(F, `${q}.id`, `id 겹침: ${s.id}`);
|
|
212
|
+
ids.add(s.id);
|
|
213
|
+
for (const k of ['label', 'detail', 'actor']) if (typeof s[k] !== 'string') r.fail(F, `${q}.${k}`, '문자열이 아님');
|
|
214
|
+
if (s.refs !== undefined && !(Array.isArray(s.refs) && s.refs.every((x) => typeof x === 'string'))) r.fail(F, `${q}.refs`, '문자열 배열이 아님');
|
|
215
|
+
});
|
|
216
|
+
if (str(l.id)) laneIds.set(l.id, ids);
|
|
217
|
+
for (const [key, ends] of [['branches', ['from', 'to']], ['externals', ['linkTo']]]) {
|
|
218
|
+
if (l[key] === undefined) continue;
|
|
219
|
+
if (!Array.isArray(l[key])) return r.fail(F, `${p}.${key}`, '배열이 아님'), (broken = true);
|
|
220
|
+
l[key].forEach((b, bi) => {
|
|
221
|
+
const q = `${p}.${key}[${bi}]`;
|
|
222
|
+
if (!obj(b)) return r.fail(F, q, '객체가 아님'), (broken = true);
|
|
223
|
+
for (const k of ends) {
|
|
224
|
+
// 분기 to 가 빈 문자열이면 흐름 끝(종료)
|
|
225
|
+
if (k === 'to' && (b.to === '' || b.to === undefined)) continue;
|
|
226
|
+
if (!ids.has(b[k])) r.fail(F, `${q}.${k}`, `단계 없음: ${b[k]}`);
|
|
227
|
+
}
|
|
228
|
+
const text = key === 'branches' ? 'when' : 'label';
|
|
229
|
+
if (typeof b[text] !== 'string') r.fail(F, `${q}.${text}`, '문자열이 아님');
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
arr(f.scenes).forEach((s, i) => {
|
|
234
|
+
const q = `scenes[${i}]`;
|
|
235
|
+
if (!obj(s)) return r.fail(F, q, '객체가 아님');
|
|
236
|
+
if (s.sec !== undefined && !(finite(s.sec) && s.sec >= 0)) r.fail(F, `${q}.sec`, '0 이상 수가 아님');
|
|
237
|
+
// lane 이 없거나 빈 문자열이면 전체(렌더의 "전체")
|
|
238
|
+
const all = s.lane === undefined || s.lane === '';
|
|
239
|
+
if (!all && !laneIds.has(s.lane)) return r.fail(F, `${q}.lane`, `흐름 없음: ${s.lane}`);
|
|
240
|
+
const ids = all ? new Set([...laneIds.values()].flatMap((x) => [...x])) : laneIds.get(s.lane);
|
|
241
|
+
for (const x of arr(s.focus)) if (!ids.has(x)) r.fail(F, `${q}.focus`, `단계 없음: ${x}`);
|
|
242
|
+
});
|
|
243
|
+
if (broken) return null;
|
|
244
|
+
f.lanes.forEach((l, li) => flowGeometry(l, li, r));
|
|
245
|
+
return f;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// workflow 기하(6.3절 표). 렌더와 같은 배치(flowLayout: 간격 확장, 이름 줄바꿈 뒤)에 검사한다.
|
|
249
|
+
function flowGeometry(l, li, r) {
|
|
250
|
+
const F = 'flow.json';
|
|
251
|
+
const L = flowLayout(l);
|
|
252
|
+
const { FW } = FLOW;
|
|
253
|
+
const steps = arr(l.steps);
|
|
254
|
+
const box = (i) => ({ x: L.pos[i].x, y: L.pos[i].y, w: FW, h: L.hs[i] });
|
|
255
|
+
const brRect = (q) => ({ x: q.x, y: q.y, w: q.w, h: 20 });
|
|
256
|
+
const exRect = (q) => ({ x: q.x, y: q.y, w: q.w, h: 18 });
|
|
257
|
+
const bp = (q) => `lanes[${li}].branches[${arr(l.branches).indexOf(q.b)}]`;
|
|
258
|
+
const ep = (q) => `lanes[${li}].externals[${arr(l.externals).indexOf(q.e)}]`;
|
|
259
|
+
const stepId = (i) => steps[i].id;
|
|
260
|
+
for (const q of L.branches) {
|
|
261
|
+
const rr = brRect(q);
|
|
262
|
+
const row = L.pos[q.i].r;
|
|
263
|
+
for (let i = 0; i < L.n; i++) if (L.pos[i].r > row && hit(rr, box(i))) r.fail(F, bp(q), `분기 표가 아래 줄 상자와 겹침: ${stepId(q.i)}, ${stepId(i)}`);
|
|
264
|
+
for (const x of L.externals) if (hit(rr, exRect(x))) r.fail(F, bp(q), `외부 표와 분기 표 겹침: ${stepId(q.i)}, ${x.e.id}`);
|
|
265
|
+
for (const o of L.branches) {
|
|
266
|
+
if (o.i === q.i) continue;
|
|
267
|
+
const line = { x: o.sx - 0.5, y: o.y0, w: 1, h: o.cy - o.y0 };
|
|
268
|
+
if (hit(rr, brRect(o)) || (o.sx > rr.x && o.sx < rr.x + rr.w && ov(rr.y, rr.y + rr.h, line.y, line.y + line.h) >= 1)) {
|
|
269
|
+
r.fail(F, bp(q), `분기 표가 이웃 칸 분기와 겹침: ${stepId(q.i)}, ${stepId(o.i)}`);
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const right = L.pos.some((p) => p.r === row && p.x === L.pos[q.i].x + FW + FLOW.FGX);
|
|
274
|
+
if (q.w > BRANCH_ROOM && right) r.warn(F, bp(q), `분기 표 폭 ${Math.round(q.w)} px 가 ${BRANCH_ROOM} px 를 넘어 오른쪽 칸으로 들어감: ${stepId(q.i)}`);
|
|
275
|
+
}
|
|
276
|
+
const extOf = new Map();
|
|
277
|
+
for (const x of L.externals) {
|
|
278
|
+
if (!extOf.has(x.i)) extOf.set(x.i, []);
|
|
279
|
+
extOf.get(x.i).push(x);
|
|
280
|
+
for (const o of L.externals) if (o.i !== x.i && L.externals.indexOf(o) > L.externals.indexOf(x) && hit(exRect(x), exRect(o))) r.fail(F, ep(o), `외부 표가 이웃 칸 외부 표와 겹침: ${o.e.id}, ${x.e.id}`);
|
|
281
|
+
}
|
|
282
|
+
for (const [i, xs] of extOf) {
|
|
283
|
+
const last = xs[xs.length - 1];
|
|
284
|
+
const span = last.x + last.w - xs[0].x;
|
|
285
|
+
if (span > FW) r.warn(F, ep(xs[0]), `외부 표 합계 폭 ${Math.round(span)} px 가 상자 폭 ${FW} px 를 넘음: ${stepId(i)}`);
|
|
286
|
+
}
|
|
287
|
+
steps.forEach((s, i) => {
|
|
288
|
+
const nm = L.names[i];
|
|
289
|
+
const p = `lanes[${li}].steps[${i}]`;
|
|
290
|
+
// 이름 둘째 줄(기준선 37)과 설명 첫 줄(기준선 42 + 내림)의 글 상자(기준선 위 11, 아래 3)
|
|
291
|
+
if (nm.length > 1 && L.descs[i].length && 42 + L.downs[i] - 37 < 14) r.fail(F, p, `이름 둘째 줄과 설명 첫 줄 겹침: ${s.id}`);
|
|
292
|
+
if (nm.some((t) => !nameFits(t, s.planned))) r.warn(F, `${p}.label`, `단계 이름 넘침(두 줄로도 넘침): ${s.id}`);
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ---------- 구성도 ----------
|
|
297
|
+
|
|
298
|
+
function anyCoord(f) {
|
|
299
|
+
if (f.width !== undefined || f.height !== undefined) return true;
|
|
300
|
+
for (const x of [...arr(f.zones), ...arr(f.nodes)]) if (obj(x) && ['x', 'y', 'w', 'h'].some((k) => x[k] !== undefined)) return true;
|
|
301
|
+
return arr(f.edges).some((e) => obj(e) && ['via', 'lx', 'ly', 'off', 'offTo'].some((k) => e[k] !== undefined));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function checkArch(dir, r, prefix) {
|
|
305
|
+
const got = readJson(path.join(dir, 'arch.json'));
|
|
306
|
+
const F = 'arch.json';
|
|
307
|
+
if (got.missing) return null;
|
|
308
|
+
if (got.error) return r.fail(F, '$', got.error), null;
|
|
309
|
+
const f = obj(got.value);
|
|
310
|
+
if (!f) return r.fail(F, '$', '객체가 아님'), null;
|
|
311
|
+
for (const k of ['zones', 'nodes', 'edges']) if (f[k] !== undefined && !Array.isArray(f[k])) return r.fail(F, k, '배열이 아님'), null;
|
|
312
|
+
for (const k of ['legend', 'main', 'scenes']) if (f[k] !== undefined && !Array.isArray(f[k])) r.fail(F, k, '배열이 아님');
|
|
313
|
+
const zones = arr(f.zones);
|
|
314
|
+
const nodes = arr(f.nodes);
|
|
315
|
+
const edges = arr(f.edges);
|
|
316
|
+
let broken = false;
|
|
317
|
+
const zoneIds = new Set();
|
|
318
|
+
zones.forEach((z, i) => {
|
|
319
|
+
if (!obj(z) || !str(z.id)) return r.fail(F, `zones[${i}]`, 'id 가 없음'), (broken = true);
|
|
320
|
+
if (zoneIds.has(z.id)) r.fail(F, `zones[${i}].id`, `id 겹침: ${z.id}`);
|
|
321
|
+
zoneIds.add(z.id);
|
|
322
|
+
if (typeof z.label !== 'string') r.fail(F, `zones[${i}].label`, '문자열이 아님');
|
|
323
|
+
if (z.color !== undefined && !color(z.color, '')) r.fail(F, `zones[${i}].color`, `#rrggbb 가 아님: ${z.color}`);
|
|
324
|
+
});
|
|
325
|
+
const nodeIds = new Set();
|
|
326
|
+
nodes.forEach((n, i) => {
|
|
327
|
+
if (!obj(n) || !str(n.id)) return r.fail(F, `nodes[${i}]`, 'id 가 없음'), (broken = true);
|
|
328
|
+
if (nodeIds.has(n.id)) r.fail(F, `nodes[${i}].id`, `id 겹침: ${n.id}`);
|
|
329
|
+
nodeIds.add(n.id);
|
|
330
|
+
if (typeof n.label !== 'string') r.fail(F, `nodes[${i}].label`, '문자열이 아님');
|
|
331
|
+
if (!zoneIds.has(n.zone)) r.fail(F, `nodes[${i}].zone`, `영역 없음: ${n.zone}`);
|
|
332
|
+
if (n.color !== undefined && !color(n.color, '')) r.fail(F, `nodes[${i}].color`, `#rrggbb 가 아님: ${n.color}`);
|
|
333
|
+
if (n.lines !== undefined && !(Number.isInteger(n.lines) && n.lines >= 1)) r.fail(F, `nodes[${i}].lines`, '1 이상 정수가 아님');
|
|
334
|
+
if (n.items !== undefined && !(Array.isArray(n.items) && n.items.every((t) => typeof t === 'string'))) r.fail(F, `nodes[${i}].items`, '문자열 배열이 아님');
|
|
335
|
+
});
|
|
336
|
+
const edgeIds = new Set();
|
|
337
|
+
edges.forEach((e, i) => {
|
|
338
|
+
if (!obj(e)) return r.fail(F, `edges[${i}]`, '객체가 아님'), (broken = true);
|
|
339
|
+
const id = e.id || `${e.from}-${e.to}`;
|
|
340
|
+
if (edgeIds.has(id)) r.fail(F, `edges[${i}].id`, `id 겹침: ${id}`);
|
|
341
|
+
edgeIds.add(id);
|
|
342
|
+
for (const k of ['from', 'to']) if (!nodeIds.has(e[k]) && !zoneIds.has(e[k])) r.fail(F, `edges[${i}].${k}`, `연결 끝 없음: ${id}, ${e[k]}`);
|
|
343
|
+
// 자기 연결은 그릴 길이 없다(화면에는 길이 0 인 선).
|
|
344
|
+
if (e.from === e.to && (nodeIds.has(e.from) || zoneIds.has(e.from))) r.warn(F, `edges[${i}]`, `자기 연결(from 과 to 가 같음): ${id}`);
|
|
345
|
+
if (e.kind !== undefined && !EDGE_KINDS.includes(e.kind)) r.fail(F, `edges[${i}].kind`, `api, control, data 가운데 하나가 아님: ${e.kind}`);
|
|
346
|
+
if (e.color !== undefined && !color(e.color, '')) r.fail(F, `edges[${i}].color`, `#rrggbb 가 아님: ${e.color}`);
|
|
347
|
+
});
|
|
348
|
+
arr(f.scenes).forEach((s, i) => {
|
|
349
|
+
if (!obj(s)) return r.fail(F, `scenes[${i}]`, '객체가 아님');
|
|
350
|
+
if (s.sec !== undefined && !(finite(s.sec) && s.sec >= 0)) r.fail(F, `scenes[${i}].sec`, '0 이상 수가 아님');
|
|
351
|
+
for (const x of arr(s.focus)) if (!nodeIds.has(x) && !edgeIds.has(x)) r.fail(F, `scenes[${i}].focus`, `장면 대상 없음: ${x}`);
|
|
352
|
+
});
|
|
353
|
+
arr(f.main).forEach((m, i) => {
|
|
354
|
+
if (!nodeIds.has(m)) r.fail(F, `main[${i}]`, `구성요소 없음: ${m}`);
|
|
355
|
+
});
|
|
356
|
+
if (nodes.length > SIZE.nodes) r.warn(F, 'nodes', `크기 지침 초과: 상자 ${nodes.length} > ${SIZE.nodes}`);
|
|
357
|
+
if (edges.length > SIZE.edges) r.warn(F, 'edges', `크기 지침 초과: 연결 ${edges.length} > ${SIZE.edges}`);
|
|
358
|
+
if (broken) return null;
|
|
359
|
+
const L = archLayout(f, prefix);
|
|
360
|
+
if (L.auto && anyCoord(f)) r.warn(F, L.missing, '좌표 일부가 빠지거나 틀려 그림 전체를 자동 배치');
|
|
361
|
+
archGeometry(f, L, r, prefix);
|
|
362
|
+
return { f, L };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// 구성도 기하 5종(6.3절 표). 좌표 사용이면 모두 FAIL, 자동 배치면 선과 이름표는 WARN.
|
|
366
|
+
function archGeometry(f, L, r, prefix) {
|
|
367
|
+
const F = 'arch.json';
|
|
368
|
+
const nodes = arr(f.nodes);
|
|
369
|
+
const soft = L.auto ? r.warn : r.fail;
|
|
370
|
+
const zoneOf = (n) => L.zone.get(n.zone);
|
|
371
|
+
nodes.forEach((n, i) => {
|
|
372
|
+
const b = L.node.get(n.id);
|
|
373
|
+
const p = `nodes[${i}]`;
|
|
374
|
+
const z = zoneOf(n);
|
|
375
|
+
if (z && !insideRect(b, z)) r.fail(F, p, `영역 밖: ${n.id}, ${n.zone}`);
|
|
376
|
+
if (b.x < 0 || b.y < 0 || b.x + b.w > L.width || b.y + b.h > L.height) r.fail(F, p, `그림 밖: ${n.id}`);
|
|
377
|
+
const tag = n.planned ? textW(prefix) + 8 : 0;
|
|
378
|
+
if (textW(n.label) * 1.15 + 8 + tag > b.w) r.fail(F, p, `글 넘침: ${n.id} 이름`);
|
|
379
|
+
const all = wrap(n.detail, b.w - 16);
|
|
380
|
+
const sub = all.slice(0, n.lines || 2);
|
|
381
|
+
if (sub.some((t) => textW(t) * 1.1 > b.w - 12)) r.fail(F, p, `글 넘침: ${n.id} 설명`);
|
|
382
|
+
if (all.length > sub.length) r.warn(F, p, `설명 잘림: ${n.id} (${all.length}줄 > ${sub.length}줄). lines 를 늘리거나 설명을 줄임`);
|
|
383
|
+
let cx = b.x + 8;
|
|
384
|
+
let cy = b.y + 26 + sub.length * 14 + 4;
|
|
385
|
+
let bottom = b.y + 34 + (sub.length - 1) * 14 + 4;
|
|
386
|
+
for (const t of arr(n.items)) {
|
|
387
|
+
const w = textW(t) + 12;
|
|
388
|
+
if (cx + w > b.x + b.w - 6) {
|
|
389
|
+
cx = b.x + 8;
|
|
390
|
+
cy += 19;
|
|
391
|
+
}
|
|
392
|
+
if (w > b.w - 14) r.fail(F, p, `글 넘침: ${n.id} 칩 ${t}`);
|
|
393
|
+
cx += w + 4;
|
|
394
|
+
bottom = cy + 16;
|
|
395
|
+
}
|
|
396
|
+
if (bottom > b.y + b.h - 2) r.fail(F, p, `글 넘침: ${n.id} 내용이 상자 아래로 넘침`);
|
|
397
|
+
for (let j = 0; j < i; j++) if (hit(b, L.node.get(nodes[j].id))) r.fail(F, p, `상자 겹침: ${n.id}, ${nodes[j].id}`);
|
|
398
|
+
});
|
|
399
|
+
const zones = arr(f.zones);
|
|
400
|
+
zones.forEach((z, i) => {
|
|
401
|
+
for (let j = 0; j < i; j++) if (hit(L.zone.get(z.id), L.zone.get(zones[j].id))) r.fail(F, `zones[${i}]`, `영역 겹침: ${z.id}, ${zones[j].id}`);
|
|
402
|
+
});
|
|
403
|
+
const edges = arr(f.edges);
|
|
404
|
+
const msg = { cross: (x) => `선이 상자 지남: ${x.g.id}, ${x.id}`, out: (x) => `이름표가 그림 밖: ${x.g.id}`, cover: (x) => `이름표 덮음: ${x.g.id}, ${x.id}` };
|
|
405
|
+
for (const x of archFlaws(f, L)) soft(F, `edges[${edges.indexOf(x.g.e0)}]`, msg[x.kind](x));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ---------- inventory.json ----------
|
|
409
|
+
|
|
410
|
+
function checkInventory(ctx, r) {
|
|
411
|
+
const got = readJson(path.join(ctx.dataDir, 'inventory.json'));
|
|
412
|
+
const F = 'inventory.json';
|
|
413
|
+
if (got.missing) return null;
|
|
414
|
+
if (got.error) return r.fail(F, '$', got.error), null;
|
|
415
|
+
const v = obj(got.value);
|
|
416
|
+
if (!v) return r.fail(F, '$', '객체가 아님'), null;
|
|
417
|
+
// 근거 경로는 프로젝트 루트 기준. `파일:줄` 이면 줄 번호를 뗀다.
|
|
418
|
+
const exists = (p) => str(p) && fs.existsSync(path.resolve(ctx.project, p.replace(/:\d+(?:-\d+)?$/, '')));
|
|
419
|
+
for (const [key, field] of [['components', 'path'], ['flows', 'evidence'], ['externals', 'evidence']]) {
|
|
420
|
+
if (!Array.isArray(v[key])) {
|
|
421
|
+
r.fail(F, key, '배열이 아님');
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
v[key].forEach((x, i) => {
|
|
425
|
+
if (!obj(x)) return r.fail(F, `${key}[${i}]`, '객체가 아님');
|
|
426
|
+
if (!exists(x[field])) r.fail(F, `${key}[${i}].${field}`, `경로 없음: ${x[field]}`);
|
|
427
|
+
if (key === 'flows' && !(Number.isInteger(x.steps) && x.steps >= 0)) r.fail(F, `${key}[${i}].steps`, '0 이상 정수가 아님');
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
if (v.read !== undefined && !Array.isArray(v.read)) r.fail(F, 'read', '배열이 아님');
|
|
431
|
+
return { components: arr(v.components).length, flows: arr(v.flows).map((x) => (obj(x) && Number.isInteger(x.steps) ? x.steps : 0)) };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ---------- 밀도(4.9절) ----------
|
|
435
|
+
|
|
436
|
+
const sentences = (t) => String(t || '').split(/\.\s+/).filter((x) => x.trim()).length;
|
|
437
|
+
const lines23 = (n) => n >= 2 && n <= 3;
|
|
438
|
+
|
|
439
|
+
// 돌려줌: { rows: [{tab, item, value, min, miss}], low: Set(탭) }. value < min 이면 미달.
|
|
440
|
+
// miss 는 항목마다 재는 줄(상자, 단계, 흐름, 명세, 기타 항목)에서 기준을 못 채운 대상 이름. 수만 재는 줄은 빈 배열.
|
|
441
|
+
export function density({ arch, flow, req, items, inv, storyCount }) {
|
|
442
|
+
const rows = [];
|
|
443
|
+
const add = (tab, item, value, min, miss = []) => rows.push({ tab, item, value, min, miss });
|
|
444
|
+
// 대상마다 ok 를 재어 값, 하한(대상 수), 못 채운 대상 이름을 한 줄로.
|
|
445
|
+
const each = (tab, item, list, ok, name) => {
|
|
446
|
+
const miss = [];
|
|
447
|
+
list.forEach((x, i) => ok(x) || miss.push(name(x, i)));
|
|
448
|
+
add(tab, item, list.length - miss.length, list.length, miss);
|
|
449
|
+
};
|
|
450
|
+
// 작은 저장소 예외: inventory.json 의 실제 수로 하한을 낮춘다.
|
|
451
|
+
const lim = (base, actual) => (inv ? Math.min(base, actual) : base);
|
|
452
|
+
const f = arch ? arch.f : {};
|
|
453
|
+
const zones = arr(f.zones);
|
|
454
|
+
const nodes = arr(f.nodes);
|
|
455
|
+
const edges = arr(f.edges);
|
|
456
|
+
add('arch', '영역', zones.length, lim(DENSITY.zones, inv && inv.components));
|
|
457
|
+
add('arch', '상자', nodes.length, lim(DENSITY.nodes, inv && inv.components));
|
|
458
|
+
const boxLines = (n) => wrap(n.detail, (arch ? arch.L.node.get(n.id).w : 0) - 16).length;
|
|
459
|
+
each('arch', '상자설명', nodes, (n) => lines23(boxLines(n)), (n) => `${n.id}(${boxLines(n)}줄)`);
|
|
460
|
+
// 연결 하한: inventory.json 이 있으면 arch.json 에 그린 상자 수(nodes) - 1 로 낮춘다.
|
|
461
|
+
add('arch', '연결', edges.length, lim(DENSITY.edges, Math.max(0, nodes.length - 1)));
|
|
462
|
+
each('arch', '연결근거', edges, (e) => arr(e.refs).length, (e) => e.id || `${e.from}-${e.to}`);
|
|
463
|
+
add('arch', '범례', arr(f.legend).length, DENSITY.legend);
|
|
464
|
+
add('arch', '주흐름', arr(f.main).length, DENSITY.main);
|
|
465
|
+
add('arch', '장면', arr(f.scenes).length, DENSITY.scenes);
|
|
466
|
+
const lanes = flow ? arr(flow.lanes) : [];
|
|
467
|
+
const invSteps = inv ? [...inv.flows].sort((a, b) => b - a) : [];
|
|
468
|
+
add('workflow', '흐름', lanes.length, lim(DENSITY.lanes, inv && inv.flows.length));
|
|
469
|
+
// 흐름당 단계: 단계가 많은 흐름부터 inventory 흐름과 짝지어 하한을 낮춘다.
|
|
470
|
+
const byLen = lanes.map((l, i) => ({ l, i })).sort((a, b) => arr(b.l.steps).length - arr(a.l.steps).length || a.i - b.i);
|
|
471
|
+
const bound = new Map(byLen.map((x, k) => [x.l, inv ? Math.min(DENSITY.steps, invSteps[k] ?? invSteps[invSteps.length - 1] ?? 0) : DENSITY.steps]));
|
|
472
|
+
for (const l of lanes) add('workflow', `단계:${l.id}`, arr(l.steps).length, bound.get(l));
|
|
473
|
+
// 단계 이름은 `<흐름 id>/<단계 id>`(단계 id 는 흐름 안에서만 겹치지 않음).
|
|
474
|
+
const steps = lanes.flatMap((l) => arr(l.steps).map((s) => ({ s, name: `${l.id}/${s.id}` })));
|
|
475
|
+
const stepLines = (x) => wrap(x.s.detail, FLOW.FW - 20).length;
|
|
476
|
+
each('workflow', '단계설명', steps, (x) => lines23(stepLines(x)), (x) => `${x.name}(${stepLines(x)}줄)`);
|
|
477
|
+
each('workflow', '행위자', steps, (x) => str(x.s.actor), (x) => x.name);
|
|
478
|
+
each('workflow', '단계근거', steps, (x) => arr(x.s.refs).length, (x) => x.name);
|
|
479
|
+
each('workflow', '실패분기', lanes, (l) => arr(l.branches).length, (l) => l.id);
|
|
480
|
+
each('workflow', '외부', lanes, (l) => arr(l.externals).length, (l) => l.id);
|
|
481
|
+
each('workflow', '흐름설명', lanes, (l) => sentences(l.note) >= 3, (l) => `${l.id}(${sentences(l.note)}문장)`);
|
|
482
|
+
const at = (x, alt) => (req.where && req.where.get(x)) || alt;
|
|
483
|
+
if (req.users.length) each('req', '요구', req.users, (u) => str(u.text) && str(u.source), (u, i) => at(u, `users[${i}]`));
|
|
484
|
+
else add('req', '요구', 0, 1);
|
|
485
|
+
const specName = (x, i) => at(x, `specs[${i}]`);
|
|
486
|
+
each('req', '명세AC', req.specs, (x) => arr(x.acs).length, specName);
|
|
487
|
+
if (storyCount) each('req', '명세스토리', req.specs, (x) => arr(x.acs).some((a) => arr(req.acStories.get(a)).length), specName);
|
|
488
|
+
const stated = req.specs.filter((x) => x.state !== undefined);
|
|
489
|
+
each('req', '비고', stated, (x) => str(x.note), (x) => at(x, `specs[${req.specs.indexOf(x)}]`));
|
|
490
|
+
each('history', '비고', items, (x) => str(x.note), (x, i) => (str(x.key) ? x.key : `items[${i}]`));
|
|
491
|
+
return { rows, low: new Set(rows.filter((x) => x.value < x.min).map((x) => x.tab)) };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// 밀도 미달 줄. 못 채운 대상이 있으면 `: ` 뒤에 앞 10개, 넘으면 `외 n`.
|
|
495
|
+
const MISS_MAX = 10;
|
|
496
|
+
export function densityLine(x) {
|
|
497
|
+
const head = `WARN density ${x.tab} ${x.item} ${x.value}/${x.min}`;
|
|
498
|
+
const miss = arr(x.miss);
|
|
499
|
+
if (!miss.length) return head;
|
|
500
|
+
const more = miss.length > MISS_MAX ? ` 외 ${miss.length - MISS_MAX}` : '';
|
|
501
|
+
return `${head}: ${miss.slice(0, MISS_MAX).join(', ')}${more}`;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// ---------- 실행 ----------
|
|
505
|
+
|
|
506
|
+
// 검사 전체. 돌려줌: { lines: [출력 줄], fails }
|
|
507
|
+
export function runCheck(ctx, env = process.env) {
|
|
508
|
+
const r = sink();
|
|
509
|
+
const config = checkConfig(ctx, r);
|
|
510
|
+
checkItems(ctx.dataDir, r);
|
|
511
|
+
const items = readItems(ctx.dataDir);
|
|
512
|
+
const ids = storyList(config, items).map((s) => s.id);
|
|
513
|
+
checkCommitRegex(ctx, config, ids, r);
|
|
514
|
+
const req = checkReq(ctx.dataDir, r, ids);
|
|
515
|
+
const flow = checkFlow(ctx.dataDir, r);
|
|
516
|
+
const arch = checkArch(ctx.dataDir, r, config.text.plannedPrefix);
|
|
517
|
+
const inv = checkInventory(ctx, r);
|
|
518
|
+
let state = null;
|
|
519
|
+
try {
|
|
520
|
+
state = obj(JSON.parse(readRegular(path.join(ctx.dataDir, 'state.json'))));
|
|
521
|
+
} catch {
|
|
522
|
+
state = null;
|
|
523
|
+
}
|
|
524
|
+
const memory = state && obj(state.memory);
|
|
525
|
+
// 경고 줄에 뒤따르는 대안 줄을 붙여 WARN 한 줄로.
|
|
526
|
+
for (const l of memoryWarnings(memory, ctx.project, env)) {
|
|
527
|
+
if (l.startsWith('경고: ')) r.warn('state.json', 'memory', l.slice(4));
|
|
528
|
+
else if (r.lines.length) r.lines[r.lines.length - 1].msg += `. ${l}`;
|
|
529
|
+
}
|
|
530
|
+
const out = r.lines.map((x) => `${x.level} ${x.file} ${x.path} ${x.msg}`);
|
|
531
|
+
if (config.sources.workflowDirs === 'auto' && !workflowRoots(config, ctx, env).length) out.push('WARN workflowDirs auto: 찾은 폴더 0');
|
|
532
|
+
let dens = null;
|
|
533
|
+
if (inv || memory) {
|
|
534
|
+
dens = density({ arch, flow, req, items: items.items, inv, storyCount: ids.length });
|
|
535
|
+
for (const x of dens.rows) if (x.value < x.min) out.push(densityLine(x));
|
|
536
|
+
}
|
|
537
|
+
const fails = out.filter((l) => l.startsWith('FAIL ')).length;
|
|
538
|
+
out.push(`check: FAIL ${fails}, WARN ${out.length - fails}`);
|
|
539
|
+
if (dens) for (const [k] of TABS) out.push(`density ${k} ${dens.low.has(k) ? 'low' : 'ok'}`);
|
|
540
|
+
return { lines: out, fails };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
export async function command(cmd, ctx, o, io) {
|
|
544
|
+
if (cmd !== 'check') throw new UsageError(`${cmd} 는 pm-check.mjs 가 맡지 않음`);
|
|
545
|
+
if (o._.length > 1) throw new UsageError(`check 는 위치 인자를 받지 않음: ${o._.slice(1).join(' ')}`);
|
|
546
|
+
const { lines, fails } = runCheck(ctx);
|
|
547
|
+
for (const l of lines) io.out(l);
|
|
548
|
+
return fails ? 1 : 0;
|
|
549
|
+
}
|