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