@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.
Files changed (106) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +68 -0
  4. package/PRIVACY.md +1 -0
  5. package/README.md +39 -13
  6. package/bin/banker.js +118 -22
  7. package/bin/lib/codex-hooks.mjs +116 -18
  8. package/bin/lib/codex-names.mjs +4 -0
  9. package/bin/lib/codex-routes.mjs +93 -1
  10. package/bin/lib/command-skill.cjs +37 -6
  11. package/codex/command-skills/front-qa/SKILL.md +1 -1
  12. package/codex/command-skills/setup/SKILL.md +4 -4
  13. package/codex/hooks.json +5 -5
  14. package/codex/manifest.json +18 -15
  15. package/codex/skills/progress/scripts/progress.mjs +44 -14
  16. package/codex/skills/teamwork/SKILL.md +143 -0
  17. package/codex/skills/teamwork/scripts/teamwork.mjs +1007 -0
  18. package/codex/transform-matrix.md +5 -3
  19. package/hooks/obsidize-hook.mjs +75 -32
  20. package/hooks/telemetry-count-codex.mjs +238 -57
  21. package/hooks/update-fetch.mjs +2 -1
  22. package/hooks/update-notify.mjs +82 -20
  23. package/package.json +3 -1
  24. package/skills/3d-intro-build/SKILL.md +101 -20
  25. package/skills/3d-intro-build/references/NOTICE.md +9 -2
  26. package/skills/3d-intro-build/references/assemble.mjs +113 -22
  27. package/skills/3d-intro-build/references/azure-adapter.mjs +110 -24
  28. package/skills/3d-intro-build/references/clip-post.mjs +443 -0
  29. package/skills/3d-intro-build/references/dive-mode.mjs +299 -0
  30. package/skills/3d-intro-build/references/intro-fixes.css +11 -0
  31. package/skills/3d-intro-build/references/prompts.md +51 -4
  32. package/skills/3d-intro-build/references/scrub-engine.js +1 -0
  33. package/skills/3d-intro-build/references/step-nav.css +29 -0
  34. package/skills/3d-intro-build/references/step-nav.js +284 -106
  35. package/skills/3d-intro-build/references/verify-intro.mjs +373 -32
  36. package/skills/3d-intro-build/references/verify.md +6 -0
  37. package/skills/3d-intro-build/references/video-pool.mjs +142 -12
  38. package/skills/3d-intro-setup/SKILL.md +20 -7
  39. package/skills/3d-intro-setup/references/azure-adapter.mjs +110 -24
  40. package/skills/3d-intro-setup/references/video-pool.mjs +142 -12
  41. package/skills/all-in-one/SKILL.md +125 -86
  42. package/skills/omc-patch/SKILL.md +1 -1
  43. package/skills/payload-mon/SKILL.md +1 -1
  44. package/skills/play-qa/SKILL.md +6 -4
  45. package/skills/progress-mon/README.md +164 -0
  46. package/skills/progress-mon/SKILL.md +165 -0
  47. package/skills/progress-mon/assets/fixture/README.md +1 -0
  48. package/skills/progress-mon/assets/fixture/arch.json +33 -0
  49. package/skills/progress-mon/assets/fixture/commits.json +7 -0
  50. package/skills/progress-mon/assets/fixture/config.json +30 -0
  51. package/skills/progress-mon/assets/fixture/events.jsonl +7 -0
  52. package/skills/progress-mon/assets/fixture/flow.json +44 -0
  53. package/skills/progress-mon/assets/fixture/items.json +14 -0
  54. package/skills/progress-mon/assets/fixture/req-spec.json +31 -0
  55. package/skills/progress-mon/assets/fixture/reqs.json +11 -0
  56. package/skills/progress-mon/assets/fixture/variants/arch-auto/arch.json +58 -0
  57. package/skills/progress-mon/assets/fixture/variants/check-auto/arch.json +15 -0
  58. package/skills/progress-mon/assets/fixture/variants/check-overlap/arch.json +14 -0
  59. package/skills/progress-mon/assets/fixture/variants/history-manual/config.json +13 -0
  60. package/skills/progress-mon/assets/fixture/variants/history-manual/items.json +7 -0
  61. package/skills/progress-mon/assets/fixture/variants/workflow-wide/flow.json +35 -0
  62. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a01.jsonl +1 -0
  63. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a02.jsonl +1 -0
  64. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a03.jsonl +1 -0
  65. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a04.jsonl +1 -0
  66. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/journal.jsonl +7 -0
  67. package/skills/progress-mon/references/arch.md +280 -0
  68. package/skills/progress-mon/references/authoring.md +199 -0
  69. package/skills/progress-mon/references/config.md +115 -0
  70. package/skills/progress-mon/references/flow.md +75 -0
  71. package/skills/progress-mon/references/inventory.md +58 -0
  72. package/skills/progress-mon/references/items.md +74 -0
  73. package/skills/progress-mon/references/req-spec.md +74 -0
  74. package/skills/progress-mon/references/reqs.md +30 -0
  75. package/skills/progress-mon/scripts/index.js +31 -0
  76. package/skills/progress-mon/scripts/pm-check.mjs +549 -0
  77. package/skills/progress-mon/scripts/pm-config.mjs +343 -0
  78. package/skills/progress-mon/scripts/pm-data.mjs +394 -0
  79. package/skills/progress-mon/scripts/pm-html.mjs +77 -0
  80. package/skills/progress-mon/scripts/pm-memory.mjs +333 -0
  81. package/skills/progress-mon/scripts/pm-render.mjs +1644 -0
  82. package/skills/progress-mon/scripts/pm-server.mjs +781 -0
  83. package/skills/progress-mon/scripts/pm-sources.mjs +501 -0
  84. package/skills/progress-mon/scripts/progress-mon.mjs +167 -0
  85. package/skills/ralph-qa/SKILL.md +107 -17
  86. package/skills/ralph-qa/references/gate-evidence.mjs +457 -0
  87. package/skills/ralph-qa/references/payload-scan.mjs +19 -2
  88. package/skills/ralph-qa/references/run-folder.mjs +299 -0
  89. package/skills/ralph-qa/references/seat-payload.mjs +60 -13
  90. package/skills/ralph-qa/references/seat-run.mjs +200 -159
  91. package/skills/remains/SKILL.md +2 -0
  92. package/skills/remains/scripts/boxes.mjs +6 -2
  93. package/skills/setup-bypass-permissions/scripts/codex-config.mjs +19 -8
  94. package/skills/setup-mcp/SKILL.md +1 -1
  95. package/skills/setup-omc-hud/SKILL.md +2 -1
  96. package/skills/setup-omc-hud/assets/codex-config.mjs +19 -8
  97. package/skills/ultra-chain/SKILL.md +32 -14
  98. package/skills/ultra-chain/references/chain-ledger.mjs +111 -3
  99. package/skills/ultra-init/SKILL.md +30 -22
  100. package/skills/ultraqa/NOTICE.md +34 -0
  101. package/skills/ultraqa/SKILL.md +197 -0
  102. package/skills/ultraqa/references/gate-evidence.mjs +457 -0
  103. package/skills/ultraqa/references/run-folder.mjs +299 -0
  104. package/skills/ultraqa/references/tmux-drive.md +52 -0
  105. package/skills/ultraqa/references/uqa-run.mjs +339 -0
  106. 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
+ }