@tienne/gestalt 0.88.0 → 0.90.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 (226) hide show
  1. package/CLAUDE.md +7 -2
  2. package/README.ko.md +2 -1
  3. package/README.md +3 -1
  4. package/dist/package.json +7 -4
  5. package/dist/plugin/skills/_shared/proactive-routing.md +29 -12
  6. package/dist/plugin/skills/architecture/SKILL.md +944 -0
  7. package/dist/plugin/skills/brief/SKILL.md +2 -1
  8. package/dist/plugin/skills/dispatch/SKILL.md +2 -0
  9. package/dist/plugin/skills/explain/SKILL.md +4 -2
  10. package/dist/plugin/skills/jira-create/SKILL.md +2 -0
  11. package/dist/plugin/skills/pr/SKILL.md +2 -0
  12. package/dist/plugin/skills/presentation/SKILL.md +2 -0
  13. package/dist/plugin/skills/review/SKILL.md +1 -0
  14. package/dist/plugin/skills/review-loop/SKILL.md +3 -1
  15. package/dist/plugin/skills/review-reply/SKILL.md +2 -0
  16. package/dist/plugin/skills/ship/SKILL.md +2 -2
  17. package/dist/plugin/skills/slack-send/SKILL.md +2 -0
  18. package/dist/schemas/architecture-ir.schema.json +596 -0
  19. package/dist/src/architecture/canvas-geometry.d.ts +40 -0
  20. package/dist/src/architecture/canvas-geometry.d.ts.map +1 -0
  21. package/dist/src/architecture/canvas-geometry.js +212 -0
  22. package/dist/src/architecture/canvas-geometry.js.map +1 -0
  23. package/dist/src/architecture/codeowners.d.ts +16 -0
  24. package/dist/src/architecture/codeowners.d.ts.map +1 -0
  25. package/dist/src/architecture/codeowners.js +58 -0
  26. package/dist/src/architecture/codeowners.js.map +1 -0
  27. package/dist/src/architecture/compare-layout.d.ts +27 -0
  28. package/dist/src/architecture/compare-layout.d.ts.map +1 -0
  29. package/dist/src/architecture/compare-layout.js +72 -0
  30. package/dist/src/architecture/compare-layout.js.map +1 -0
  31. package/dist/src/architecture/dataflow-layout.d.ts +29 -0
  32. package/dist/src/architecture/dataflow-layout.d.ts.map +1 -0
  33. package/dist/src/architecture/dataflow-layout.js +229 -0
  34. package/dist/src/architecture/dataflow-layout.js.map +1 -0
  35. package/dist/src/architecture/doc-fresh.d.ts +31 -0
  36. package/dist/src/architecture/doc-fresh.d.ts.map +1 -0
  37. package/dist/src/architecture/doc-fresh.js +84 -0
  38. package/dist/src/architecture/doc-fresh.js.map +1 -0
  39. package/dist/src/architecture/doc-link.d.ts +84 -0
  40. package/dist/src/architecture/doc-link.d.ts.map +1 -0
  41. package/dist/src/architecture/doc-link.js +396 -0
  42. package/dist/src/architecture/doc-link.js.map +1 -0
  43. package/dist/src/architecture/doc-routes.d.ts +39 -0
  44. package/dist/src/architecture/doc-routes.d.ts.map +1 -0
  45. package/dist/src/architecture/doc-routes.js +123 -0
  46. package/dist/src/architecture/doc-routes.js.map +1 -0
  47. package/dist/src/architecture/doc-scan.d.ts +107 -0
  48. package/dist/src/architecture/doc-scan.d.ts.map +1 -0
  49. package/dist/src/architecture/doc-scan.js +617 -0
  50. package/dist/src/architecture/doc-scan.js.map +1 -0
  51. package/dist/src/architecture/doc-stale.d.ts +32 -0
  52. package/dist/src/architecture/doc-stale.d.ts.map +1 -0
  53. package/dist/src/architecture/doc-stale.js +60 -0
  54. package/dist/src/architecture/doc-stale.js.map +1 -0
  55. package/dist/src/architecture/drilldown.d.ts +51 -0
  56. package/dist/src/architecture/drilldown.d.ts.map +1 -0
  57. package/dist/src/architecture/drilldown.js +1160 -0
  58. package/dist/src/architecture/drilldown.js.map +1 -0
  59. package/dist/src/architecture/endpoint-match.d.ts +43 -0
  60. package/dist/src/architecture/endpoint-match.d.ts.map +1 -0
  61. package/dist/src/architecture/endpoint-match.js +133 -0
  62. package/dist/src/architecture/endpoint-match.js.map +1 -0
  63. package/dist/src/architecture/flow-layout.d.ts +79 -0
  64. package/dist/src/architecture/flow-layout.d.ts.map +1 -0
  65. package/dist/src/architecture/flow-layout.js +519 -0
  66. package/dist/src/architecture/flow-layout.js.map +1 -0
  67. package/dist/src/architecture/focus.d.ts +14 -0
  68. package/dist/src/architecture/focus.d.ts.map +1 -0
  69. package/dist/src/architecture/focus.js +107 -0
  70. package/dist/src/architecture/focus.js.map +1 -0
  71. package/dist/src/architecture/global-context.d.ts +23 -0
  72. package/dist/src/architecture/global-context.d.ts.map +1 -0
  73. package/dist/src/architecture/global-context.js +67 -0
  74. package/dist/src/architecture/global-context.js.map +1 -0
  75. package/dist/src/architecture/html-client.d.ts +35 -0
  76. package/dist/src/architecture/html-client.d.ts.map +1 -0
  77. package/dist/src/architecture/html-client.js +1852 -0
  78. package/dist/src/architecture/html-client.js.map +1 -0
  79. package/dist/src/architecture/html-renderer.d.ts +22 -0
  80. package/dist/src/architecture/html-renderer.d.ts.map +1 -0
  81. package/dist/src/architecture/html-renderer.js +1290 -0
  82. package/dist/src/architecture/html-renderer.js.map +1 -0
  83. package/dist/src/architecture/html-theme.d.ts +25 -0
  84. package/dist/src/architecture/html-theme.d.ts.map +1 -0
  85. package/dist/src/architecture/html-theme.js +681 -0
  86. package/dist/src/architecture/html-theme.js.map +1 -0
  87. package/dist/src/architecture/index.d.ts +9 -0
  88. package/dist/src/architecture/index.d.ts.map +1 -0
  89. package/dist/src/architecture/index.js +9 -0
  90. package/dist/src/architecture/index.js.map +1 -0
  91. package/dist/src/architecture/ir-schema.d.ts +1832 -0
  92. package/dist/src/architecture/ir-schema.d.ts.map +1 -0
  93. package/dist/src/architecture/ir-schema.js +343 -0
  94. package/dist/src/architecture/ir-schema.js.map +1 -0
  95. package/dist/src/architecture/kind-text.d.ts +21 -0
  96. package/dist/src/architecture/kind-text.d.ts.map +1 -0
  97. package/dist/src/architecture/kind-text.js +42 -0
  98. package/dist/src/architecture/kind-text.js.map +1 -0
  99. package/dist/src/architecture/layout.d.ts +227 -0
  100. package/dist/src/architecture/layout.d.ts.map +1 -0
  101. package/dist/src/architecture/layout.js +627 -0
  102. package/dist/src/architecture/layout.js.map +1 -0
  103. package/dist/src/architecture/mcp-tool-match.d.ts +49 -0
  104. package/dist/src/architecture/mcp-tool-match.d.ts.map +1 -0
  105. package/dist/src/architecture/mcp-tool-match.js +53 -0
  106. package/dist/src/architecture/mcp-tool-match.js.map +1 -0
  107. package/dist/src/architecture/merge.d.ts +54 -0
  108. package/dist/src/architecture/merge.d.ts.map +1 -0
  109. package/dist/src/architecture/merge.js +681 -0
  110. package/dist/src/architecture/merge.js.map +1 -0
  111. package/dist/src/architecture/micro-app.d.ts +20 -0
  112. package/dist/src/architecture/micro-app.d.ts.map +1 -0
  113. package/dist/src/architecture/micro-app.js +41 -0
  114. package/dist/src/architecture/micro-app.js.map +1 -0
  115. package/dist/src/architecture/name-ref-match.d.ts +43 -0
  116. package/dist/src/architecture/name-ref-match.d.ts.map +1 -0
  117. package/dist/src/architecture/name-ref-match.js +127 -0
  118. package/dist/src/architecture/name-ref-match.js.map +1 -0
  119. package/dist/src/architecture/packs/data.d.ts +109 -0
  120. package/dist/src/architecture/packs/data.d.ts.map +1 -0
  121. package/dist/src/architecture/packs/data.js +64 -0
  122. package/dist/src/architecture/packs/data.js.map +1 -0
  123. package/dist/src/architecture/packs/generic.d.ts +47 -0
  124. package/dist/src/architecture/packs/generic.d.ts.map +1 -0
  125. package/dist/src/architecture/packs/generic.js +35 -0
  126. package/dist/src/architecture/packs/generic.js.map +1 -0
  127. package/dist/src/architecture/packs/harness.d.ts +96 -0
  128. package/dist/src/architecture/packs/harness.d.ts.map +1 -0
  129. package/dist/src/architecture/packs/harness.js +66 -0
  130. package/dist/src/architecture/packs/harness.js.map +1 -0
  131. package/dist/src/architecture/packs/index.d.ts +840 -0
  132. package/dist/src/architecture/packs/index.d.ts.map +1 -0
  133. package/dist/src/architecture/packs/index.js +87 -0
  134. package/dist/src/architecture/packs/index.js.map +1 -0
  135. package/dist/src/architecture/packs/infra.d.ts +91 -0
  136. package/dist/src/architecture/packs/infra.d.ts.map +1 -0
  137. package/dist/src/architecture/packs/infra.js +57 -0
  138. package/dist/src/architecture/packs/infra.js.map +1 -0
  139. package/dist/src/architecture/packs/knowledge.d.ts +72 -0
  140. package/dist/src/architecture/packs/knowledge.d.ts.map +1 -0
  141. package/dist/src/architecture/packs/knowledge.js +58 -0
  142. package/dist/src/architecture/packs/knowledge.js.map +1 -0
  143. package/dist/src/architecture/packs/process.d.ts +87 -0
  144. package/dist/src/architecture/packs/process.d.ts.map +1 -0
  145. package/dist/src/architecture/packs/process.js +52 -0
  146. package/dist/src/architecture/packs/process.js.map +1 -0
  147. package/dist/src/architecture/packs/render-classes.d.ts +11 -0
  148. package/dist/src/architecture/packs/render-classes.d.ts.map +1 -0
  149. package/dist/src/architecture/packs/render-classes.js +92 -0
  150. package/dist/src/architecture/packs/render-classes.js.map +1 -0
  151. package/dist/src/architecture/packs/types.d.ts +66 -0
  152. package/dist/src/architecture/packs/types.d.ts.map +1 -0
  153. package/dist/src/architecture/packs/types.js +18 -0
  154. package/dist/src/architecture/packs/types.js.map +1 -0
  155. package/dist/src/architecture/packs/web-product.d.ts +348 -0
  156. package/dist/src/architecture/packs/web-product.d.ts.map +1 -0
  157. package/dist/src/architecture/packs/web-product.js +226 -0
  158. package/dist/src/architecture/packs/web-product.js.map +1 -0
  159. package/dist/src/architecture/sequence-layout.d.ts +50 -0
  160. package/dist/src/architecture/sequence-layout.d.ts.map +1 -0
  161. package/dist/src/architecture/sequence-layout.js +140 -0
  162. package/dist/src/architecture/sequence-layout.js.map +1 -0
  163. package/dist/src/architecture/service-facts.d.ts +30 -0
  164. package/dist/src/architecture/service-facts.d.ts.map +1 -0
  165. package/dist/src/architecture/service-facts.js +88 -0
  166. package/dist/src/architecture/service-facts.js.map +1 -0
  167. package/dist/src/architecture/store.d.ts +50 -0
  168. package/dist/src/architecture/store.d.ts.map +1 -0
  169. package/dist/src/architecture/store.js +320 -0
  170. package/dist/src/architecture/store.js.map +1 -0
  171. package/dist/src/architecture/types.d.ts +400 -0
  172. package/dist/src/architecture/types.d.ts.map +1 -0
  173. package/dist/src/architecture/types.js +75 -0
  174. package/dist/src/architecture/types.js.map +1 -0
  175. package/dist/src/architecture/validator.d.ts +65 -0
  176. package/dist/src/architecture/validator.d.ts.map +1 -0
  177. package/dist/src/architecture/validator.js +1249 -0
  178. package/dist/src/architecture/validator.js.map +1 -0
  179. package/dist/src/core/errors.d.ts +4 -0
  180. package/dist/src/core/errors.d.ts.map +1 -1
  181. package/dist/src/core/errors.js +8 -0
  182. package/dist/src/core/errors.js.map +1 -1
  183. package/dist/src/core/types.d.ts +4 -0
  184. package/dist/src/core/types.d.ts.map +1 -1
  185. package/dist/src/mcp/schemas.d.ts +317 -0
  186. package/dist/src/mcp/schemas.d.ts.map +1 -1
  187. package/dist/src/mcp/schemas.js +151 -0
  188. package/dist/src/mcp/schemas.js.map +1 -1
  189. package/dist/src/mcp/server.d.ts.map +1 -1
  190. package/dist/src/mcp/server.js +7 -1
  191. package/dist/src/mcp/server.js.map +1 -1
  192. package/dist/src/mcp/tools/architecture-passthrough.d.ts +9 -0
  193. package/dist/src/mcp/tools/architecture-passthrough.d.ts.map +1 -0
  194. package/dist/src/mcp/tools/architecture-passthrough.js +587 -0
  195. package/dist/src/mcp/tools/architecture-passthrough.js.map +1 -0
  196. package/dist/src/skills/parser.d.ts.map +1 -1
  197. package/dist/src/skills/parser.js +1 -0
  198. package/dist/src/skills/parser.js.map +1 -1
  199. package/dist/src/skills/types.d.ts +4 -0
  200. package/dist/src/skills/types.d.ts.map +1 -1
  201. package/dist/src/utils/claude-projects.d.ts +19 -0
  202. package/dist/src/utils/claude-projects.d.ts.map +1 -0
  203. package/dist/src/utils/claude-projects.js +157 -0
  204. package/dist/src/utils/claude-projects.js.map +1 -0
  205. package/dist/src/utils/read-only-tools.d.ts +19 -0
  206. package/dist/src/utils/read-only-tools.d.ts.map +1 -0
  207. package/dist/src/utils/read-only-tools.js +132 -0
  208. package/dist/src/utils/read-only-tools.js.map +1 -0
  209. package/package.json +7 -4
  210. package/plugin/.codex-plugin/plugin.json +1 -1
  211. package/plugin/.mcp.json +1 -1
  212. package/plugin/mcp.json +1 -1
  213. package/plugin/skills/_shared/proactive-routing.md +29 -12
  214. package/plugin/skills/architecture/SKILL.md +944 -0
  215. package/plugin/skills/brief/SKILL.md +2 -1
  216. package/plugin/skills/dispatch/SKILL.md +2 -0
  217. package/plugin/skills/explain/SKILL.md +4 -2
  218. package/plugin/skills/jira-create/SKILL.md +2 -0
  219. package/plugin/skills/pr/SKILL.md +2 -0
  220. package/plugin/skills/presentation/SKILL.md +2 -0
  221. package/plugin/skills/review/SKILL.md +1 -0
  222. package/plugin/skills/review-loop/SKILL.md +3 -1
  223. package/plugin/skills/review-reply/SKILL.md +2 -0
  224. package/plugin/skills/ship/SKILL.md +2 -2
  225. package/plugin/skills/slack-send/SKILL.md +2 -0
  226. package/schemas/architecture-ir.schema.json +596 -0
@@ -0,0 +1,1249 @@
1
+ import { readFileSync, statSync } from 'node:fs';
2
+ import { isAbsolute, relative, resolve } from 'node:path';
3
+ import { classifyCliCommand } from '../utils/read-only-tools.js';
4
+ import { indexMicroApps } from './micro-app.js';
5
+ import { ALL_PACKS_VOCABULARY, irVocabulary, packById } from './packs/index.js';
6
+ import { KNOWLEDGE_DOC_KINDS } from './packs/knowledge.js';
7
+ import { FLOW_REF_KINDS, HARNESS_KINDS, PARENT_KINDS, } from './types.js';
8
+ const CODE_LOCATION_RE = /^([^:]+):(.+):(\d+)$/;
9
+ /** AWS 계정 ID 꼴(숫자 12자리). 앞뒤가 숫자면 더 긴 수의 일부라 계정 ID로 보지 않는다 */
10
+ export const ACCOUNT_ID_RE = /(?<!\d)\d{12}(?!\d)/g;
11
+ // CloudFront 배포 ID 꼴. 대문자 id나 이름과 헷갈릴 수 있어 이름은 cdn 노드에서만, 질문 글자는 통째로 가린다
12
+ const DISTRIBUTION_ID_RE = /\bE[0-9A-Z]{12,13}\b/g;
13
+ // live 근거만 있는 선은 점선이다. 조회는 지금 그렇다는 사실이지 코드가 그렇게 만든다는 증거가 아니라서
14
+ // 다음 배포에 바뀔 수 있다. 코드나 스펙이 함께 있어야 실선이 된다
15
+ export function deriveLineStyle(evidence) {
16
+ return evidence.some((e) => e.type === 'code' || e.type === 'spec') ? 'solid' : 'dashed';
17
+ }
18
+ function countLines(content) {
19
+ if (content.length === 0)
20
+ return 0;
21
+ const lines = content.split('\n');
22
+ return lines[lines.length - 1] === '' ? lines.length - 1 : lines.length;
23
+ }
24
+ function readLineCount(absPath, cache) {
25
+ if (cache.has(absPath))
26
+ return cache.get(absPath);
27
+ let count = null;
28
+ try {
29
+ if (statSync(absPath).isFile())
30
+ count = countLines(readFileSync(absPath, 'utf-8'));
31
+ }
32
+ catch {
33
+ count = null;
34
+ }
35
+ cache.set(absPath, count);
36
+ return count;
37
+ }
38
+ /** code 근거 위치가 실제 파일의 실제 줄을 가리키는지 본다. 문제가 없으면 null */
39
+ function checkCodeLocation(location, repoRoots, cache) {
40
+ const match = CODE_LOCATION_RE.exec(location);
41
+ if (!match) {
42
+ return `code 근거 위치 "${location}"가 <repoId>:<relPath>:<line> 꼴이 아니다.`;
43
+ }
44
+ const repoId = match[1];
45
+ const relPath = match[2];
46
+ const line = Number(match[3]);
47
+ const root = repoRoots[repoId];
48
+ if (!root) {
49
+ return `code 근거 위치 "${location}"의 레포 "${repoId}"를 repos에서 찾지 못했다.`;
50
+ }
51
+ if (isAbsolute(relPath)) {
52
+ return `code 근거 위치 "${location}"의 경로가 절대 경로다. 레포 루트 기준 상대 경로여야 한다.`;
53
+ }
54
+ const rootAbs = resolve(root);
55
+ const fileAbs = resolve(rootAbs, relPath);
56
+ const rel = relative(rootAbs, fileAbs);
57
+ if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
58
+ return `code 근거 위치 "${location}"의 경로가 레포 루트 밖을 가리킨다.`;
59
+ }
60
+ const lineCount = readLineCount(fileAbs, cache);
61
+ if (lineCount === null) {
62
+ return `code 근거 위치 "${location}"의 파일이 레포 "${repoId}"에 없다.`;
63
+ }
64
+ if (line < 1 || line > lineCount) {
65
+ return `code 근거 위치 "${location}"의 줄 번호 ${line}이 파일 범위(1~${lineCount}) 밖이다.`;
66
+ }
67
+ return null;
68
+ }
69
+ function checkEvidenceList(evidence, owner, ctx, errors) {
70
+ evidence.forEach((ev, evidenceIndex) => {
71
+ if (ev.visibility === 'private' && ev.excerpt !== undefined) {
72
+ errors.push({
73
+ code: 'PRIVATE_EXCERPT_PRESENT',
74
+ message: ev.type === 'live'
75
+ ? `private live 근거 "${ev.location}"에 조회 응답 원문(excerpt)이 들어 있다. 명령과 조회 시각만 남긴다.`
76
+ : `private 근거 "${ev.location}"에 excerpt가 들어 있다. private 근거는 원문을 싣지 않는다.`,
77
+ ...owner,
78
+ evidenceIndex,
79
+ });
80
+ }
81
+ if (ev.type === 'live' && ev.command !== undefined) {
82
+ const verdict = classifyCliCommand(ev.command);
83
+ if (verdict !== 'allow') {
84
+ errors.push({
85
+ code: 'LIVE_COMMAND_NOT_READ_ONLY',
86
+ message: `live 근거의 명령 "${ev.command}"이 읽기 전용 조회로 판정되지 않는다(${verdict}). 하위 명령은 list, get, describe만 쓴다.`,
87
+ ...owner,
88
+ evidenceIndex,
89
+ });
90
+ }
91
+ }
92
+ if (ev.type === 'code' && ctx.checkFiles) {
93
+ const problem = checkCodeLocation(ev.location, ctx.repoRoots, ctx.cache);
94
+ if (problem) {
95
+ errors.push({ code: 'CODE_EVIDENCE_NOT_FOUND', message: problem, ...owner, evidenceIndex });
96
+ }
97
+ }
98
+ });
99
+ }
100
+ function checkGroups(ir, errors) {
101
+ const nodeIds = new Set(ir.nodes.map((n) => n.id));
102
+ const seen = new Set();
103
+ for (const group of ir.groups ?? []) {
104
+ if (seen.has(group.id)) {
105
+ errors.push({ code: 'DUPLICATE_GROUP_ID', message: `그룹 id "${group.id}"가 겹친다.` });
106
+ }
107
+ seen.add(group.id);
108
+ for (const member of group.members) {
109
+ if (nodeIds.has(member))
110
+ continue;
111
+ errors.push({
112
+ code: 'GROUP_MEMBER_NOT_FOUND',
113
+ message: `그룹 "${group.id}"의 member "${member}"가 nodes에 없다.`,
114
+ nodeId: member,
115
+ });
116
+ }
117
+ }
118
+ }
119
+ // 한 노드를 두 구간에 이름으로 올리면 어느 칸에 설지 입력 순서가 정하게 된다. 세션이 의도를 밝히게 거부한다
120
+ function checkStages(ir, errors) {
121
+ const nodeIds = new Set(ir.nodes.map((n) => n.id));
122
+ const seen = new Set();
123
+ const listedIn = new Map();
124
+ for (const stage of ir.stages ?? []) {
125
+ if (seen.has(stage.id)) {
126
+ errors.push({ code: 'DUPLICATE_STAGE_ID', message: `구간 id "${stage.id}"가 겹친다.` });
127
+ }
128
+ seen.add(stage.id);
129
+ for (const id of stage.nodes ?? []) {
130
+ if (!nodeIds.has(id)) {
131
+ errors.push({
132
+ code: 'STAGE_NODE_NOT_FOUND',
133
+ message: `구간 "${stage.id}"의 노드 "${id}"가 nodes에 없다.`,
134
+ nodeId: id,
135
+ });
136
+ continue;
137
+ }
138
+ const other = listedIn.get(id);
139
+ if (other !== undefined && other !== stage.id) {
140
+ errors.push({
141
+ code: 'STAGE_NODE_TWICE',
142
+ message: `노드 "${id}"가 구간 "${other}"와 "${stage.id}"에 함께 올라 있다. 한 구간에만 둔다.`,
143
+ nodeId: id,
144
+ });
145
+ }
146
+ listedIn.set(id, stage.id);
147
+ }
148
+ }
149
+ }
150
+ function checkAccounts(ir, errors) {
151
+ const byId = new Map(ir.nodes.map((n) => [n.id, n]));
152
+ for (const node of ir.nodes) {
153
+ if (node.account === undefined)
154
+ continue;
155
+ const account = byId.get(node.account);
156
+ if (!account) {
157
+ errors.push({
158
+ code: 'ACCOUNT_NOT_FOUND',
159
+ message: `노드 "${node.id}"의 account "${node.account}"가 nodes에 없다.`,
160
+ nodeId: node.id,
161
+ });
162
+ }
163
+ else if (account.kind !== 'cloud_account') {
164
+ errors.push({
165
+ code: 'INVALID_ACCOUNT_KIND',
166
+ message: `노드 "${node.id}"의 account는 cloud_account 노드여야 하는데 ${account.kind}다.`,
167
+ nodeId: node.id,
168
+ });
169
+ }
170
+ }
171
+ }
172
+ // id는 HTML 속성과 주소창 해시에 그대로 실려 공유본에서도 못 가린다. 계정 ID와 배포 ID는 label에만 두고 id는 별칭으로 짓게 한다
173
+ const accountLike = (id) => new RegExp(ACCOUNT_ID_RE.source).test(id);
174
+ const distributionLike = (id) => new RegExp(DISTRIBUTION_ID_RE.source).test(id);
175
+ /** 이 id가 공유본에서 못 가리는 클라우드 식별자를 담는지. kind를 모르면(엣지) 계정 ID만 본다 */
176
+ export function idCarriesCloudId(id, kind) {
177
+ return accountLike(id) || (kind === 'cdn' && distributionLike(id));
178
+ }
179
+ function checkIdsForCloudIds(ir, errors) {
180
+ for (const node of ir.nodes) {
181
+ const what = accountLike(node.id)
182
+ ? '계정 ID로 보이는 12자리 숫자'
183
+ : idCarriesCloudId(node.id, node.kind)
184
+ ? 'CDN 배포 ID'
185
+ : undefined;
186
+ if (what === undefined)
187
+ continue;
188
+ errors.push({
189
+ code: 'CLOUD_ID_IN_ID',
190
+ message: `노드 id "${node.id}"에 ${what}가 들어 있다. id는 별칭(prod-customer 같은)으로 짓고 실제 ID는 label에 둔다.`,
191
+ nodeId: node.id,
192
+ });
193
+ }
194
+ for (const edge of ir.edges) {
195
+ if (!accountLike(edge.id))
196
+ continue;
197
+ errors.push({
198
+ code: 'CLOUD_ID_IN_ID',
199
+ message: `엣지 id "${edge.id}"에 계정 ID로 보이는 12자리 숫자가 들어 있다. id는 별칭으로 짓는다.`,
200
+ edgeId: edge.id,
201
+ });
202
+ }
203
+ }
204
+ function checkParents(ir, errors) {
205
+ const byId = new Map(ir.nodes.map((n) => [n.id, n]));
206
+ for (const node of ir.nodes) {
207
+ if (node.parent === undefined)
208
+ continue;
209
+ const parent = byId.get(node.parent);
210
+ if (!parent) {
211
+ errors.push({
212
+ code: 'PARENT_NOT_FOUND',
213
+ message: `노드 "${node.id}"의 parent "${node.parent}"가 nodes에 없다.`,
214
+ nodeId: node.id,
215
+ });
216
+ continue;
217
+ }
218
+ // 고리에 걸린 노드마다 한 번씩만 알린다. 고리 밖에서 고리로 들어가는 노드는 고리 쪽 에러로 충분하다
219
+ const seen = new Set([node.id]);
220
+ let cursor = parent;
221
+ while (cursor) {
222
+ if (cursor.id === node.id) {
223
+ errors.push({
224
+ code: 'PARENT_CYCLE',
225
+ message: `노드 "${node.id}"의 parent를 따라가면 자기 자신으로 돌아온다.`,
226
+ nodeId: node.id,
227
+ });
228
+ break;
229
+ }
230
+ if (seen.has(cursor.id) || cursor.parent === undefined)
231
+ break;
232
+ seen.add(cursor.id);
233
+ cursor = byId.get(cursor.parent);
234
+ }
235
+ const allowed = node.kind === 'endpoint' && node.protocol !== 'mcp' ? undefined : PARENT_KINDS[node.kind];
236
+ if (!allowed?.includes(parent.kind)) {
237
+ errors.push({
238
+ code: 'INVALID_PARENT_KIND',
239
+ message: allowed
240
+ ? `${node.kind} 노드 "${node.id}"의 parent는 ${allowed.join(' 또는 ')}여야 하는데 ${parent.kind}다.`
241
+ : `${node.kind} 노드 "${node.id}"는 parent를 가질 수 없다.`,
242
+ nodeId: node.id,
243
+ });
244
+ }
245
+ }
246
+ }
247
+ /**
248
+ * 마이크로 프론트엔드 규칙. 호스트와 리모트는 버킷, CDN, 도메인을 따로 가지므로 서빙 사슬은 자기 앱을 가리켜야 한다.
249
+ * 앱이 달린 서비스를 serves로 가리키면 어느 앱 인프라인지 구조에서 사라져 거부한다
250
+ */
251
+ function checkMicroApps(ir, errors) {
252
+ const byId = new Map(ir.nodes.map((n) => [n.id, n]));
253
+ const grouped = new Set(ir.nodes.filter((n) => n.kind === 'micro_app' && n.parent !== undefined).map((n) => n.parent));
254
+ for (const edge of ir.edges) {
255
+ if (edge.kind === 'serves' && grouped.has(edge.to)) {
256
+ errors.push({
257
+ code: 'SERVES_SERVICE_WITH_APPS',
258
+ message: `엣지 "${edge.id}"가 micro_app이 달린 서비스 "${edge.to}"를 serves로 가리킨다. 서빙 사슬은 그 인프라를 쓰는 호스트나 리모트 micro_app을 가리켜야 한다.`,
259
+ edgeId: edge.id,
260
+ });
261
+ }
262
+ if (edge.kind !== 'loads')
263
+ continue;
264
+ const ends = [edge.from, edge.to].map((id) => byId.get(id)?.kind);
265
+ if (ends.every((k) => k === 'micro_app' || k === undefined))
266
+ continue;
267
+ // 하네스 클라이언트가 플러그인을 읽어 들이는 것도 런타임 로드다
268
+ if (ends[0] === 'client' && (ends[1] === 'service' || ends[1] === undefined))
269
+ continue;
270
+ // MCP 서버의 레지스트리가 AGENT.md를 읽어 세션에 넘기는 자리다. 스킬이 안 띄우는 에이전트는 이 선으로만 그림에 닿는다
271
+ if (ends[0] === 'app_module' && (ends[1] === 'agent' || ends[1] === undefined))
272
+ continue;
273
+ errors.push({
274
+ code: 'INVALID_LOADS_ENDS',
275
+ message: `loads 엣지 "${edge.id}"는 micro_app에서 micro_app으로, client에서 service로, app_module에서 agent로만 이을 수 있는데 ${ends.join(' → ')}다.`,
276
+ edgeId: edge.id,
277
+ });
278
+ }
279
+ }
280
+ /** root 없는 레포는 문서 묶음이다. 그 레포를 가리키는 code 근거는 확인할 파일이 없으니 실선 근거로 못 쓴다 */
281
+ function checkDocRepos(ir, errors) {
282
+ const docRepos = new Set(ir.repos.filter((r) => r.root === undefined).map((r) => r.id));
283
+ if (docRepos.size === 0)
284
+ return;
285
+ const check = (evidence, owner) => evidence.forEach((ev, evidenceIndex) => {
286
+ const repoId = ev.type === 'code' ? CODE_LOCATION_RE.exec(ev.location)?.[1] : undefined;
287
+ if (repoId === undefined || !docRepos.has(repoId))
288
+ return;
289
+ errors.push({
290
+ code: 'CODE_EVIDENCE_IN_DOC_REPO',
291
+ message: `code 근거 "${ev.location}"가 root 없는 문서 묶음 "${repoId}"를 가리킨다. 문서면 doc 근거로 적는다.`,
292
+ ...owner,
293
+ evidenceIndex,
294
+ });
295
+ });
296
+ for (const n of ir.nodes) {
297
+ check(n.evidence, { nodeId: n.id });
298
+ for (const list of Object.values(n.platformEvidence ?? {}))
299
+ check(list, { nodeId: n.id });
300
+ }
301
+ for (const e of ir.edges)
302
+ check(e.evidence, { edgeId: e.id });
303
+ for (const f of ir.flows ?? []) {
304
+ for (const st of f.steps)
305
+ check(st.evidence, { flowId: f.id, stepId: st.id });
306
+ for (const t of f.transitions)
307
+ check(t.evidence, { flowId: f.id, transitionId: t.id });
308
+ }
309
+ for (const p of ir.projections ?? []) {
310
+ for (const m of p.messages)
311
+ check(m.evidence, { projectionId: p.id, messageId: m.id });
312
+ }
313
+ }
314
+ /** 노드와 엣지 kind는 IR이 적은 팩(requires 포함)에 있어야 한다. 안 적은 팩의 kind가 섞이면 어떤 범례로 읽을지 정할 수 없다 */
315
+ function checkPacks(ir, errors) {
316
+ for (const id of ir.packs ?? []) {
317
+ if (packById(id) === undefined) {
318
+ errors.push({ code: 'UNKNOWN_PACK', message: `packs의 "${id}"는 없는 팩이다.` });
319
+ }
320
+ }
321
+ const vocab = irVocabulary(ir);
322
+ const declared = vocab.packIds.join(', ');
323
+ for (const node of ir.nodes) {
324
+ if (!(node.kind in vocab.nodeKinds)) {
325
+ errors.push({
326
+ code: 'KIND_NOT_IN_PACKS',
327
+ message: `노드 "${node.id}"의 kind ${node.kind}는 이 IR의 팩(${declared})에 없다. packs에 그 kind가 든 팩을 더한다.`,
328
+ nodeId: node.id,
329
+ });
330
+ }
331
+ }
332
+ for (const edge of ir.edges) {
333
+ if (!(edge.kind in vocab.edgeKinds)) {
334
+ errors.push({
335
+ code: 'KIND_NOT_IN_PACKS',
336
+ message: `엣지 "${edge.id}"의 kind ${edge.kind}는 이 IR의 팩(${declared})에 없다. packs에 그 kind가 든 팩을 더한다.`,
337
+ edgeId: edge.id,
338
+ });
339
+ }
340
+ }
341
+ }
342
+ /** 양 끝 kind를 정해둔 엣지. 규칙은 팩의 edgeKinds.ends에 있고 키에 없는 엣지 kind는 여기서 보지 않는다 */
343
+ const HARNESS_EDGE_ENDS = Object.fromEntries(Object.entries(ALL_PACKS_VOCABULARY.edgeKinds).flatMap(([k, d]) => d.ends === undefined ? [] : [[k, d.ends]]));
344
+ function isMcpEndpoint(node) {
345
+ return node?.kind === 'endpoint' && node.protocol === 'mcp';
346
+ }
347
+ function checkHarnessEdges(ir, errors) {
348
+ const byId = new Map(ir.nodes.map((n) => [n.id, n]));
349
+ for (const edge of ir.edges) {
350
+ const from = byId.get(edge.from);
351
+ const to = byId.get(edge.to);
352
+ // 끝점이 없는 엣지는 DANGLING_EDGE가 따로 잡는다
353
+ if (from === undefined || to === undefined)
354
+ continue;
355
+ const ends = HARNESS_EDGE_ENDS[edge.kind];
356
+ if (ends !== undefined && (!ends.from.includes(from.kind) || !ends.to.includes(to.kind))) {
357
+ errors.push({
358
+ code: 'INVALID_HARNESS_EDGE_ENDS',
359
+ message: `${edge.kind} 엣지 "${edge.id}"는 ${ends.from.join(', ')}에서 ${ends.to.join(', ')}로만 이을 수 있는데 ${from.kind} → ${to.kind}다.`,
360
+ edgeId: edge.id,
361
+ });
362
+ }
363
+ if (edge.kind === 'calls' && (from.kind === 'skill' || from.kind === 'agent')) {
364
+ if (to.kind !== 'endpoint') {
365
+ errors.push({
366
+ code: 'INVALID_HARNESS_EDGE_ENDS',
367
+ message: `calls 엣지 "${edge.id}"는 ${from.kind}에서 endpoint로만 이을 수 있는데 ${to.kind}를 가리킨다. 다른 스킬은 invokes, 에이전트는 spawns로 잇는다.`,
368
+ edgeId: edge.id,
369
+ });
370
+ }
371
+ }
372
+ if (edge.actions === undefined)
373
+ continue;
374
+ if (edge.kind !== 'calls' || !isMcpEndpoint(to)) {
375
+ errors.push({
376
+ code: 'INVALID_CALL_ACTIONS',
377
+ message: `엣지 "${edge.id}"에 actions가 있다. actions는 mcp endpoint로 가는 calls에만 단다.`,
378
+ edgeId: edge.id,
379
+ });
380
+ continue;
381
+ }
382
+ if (to.actions === undefined)
383
+ continue;
384
+ const unknown = edge.actions.filter((a) => !to.actions.includes(a));
385
+ if (unknown.length === 0)
386
+ continue;
387
+ errors.push({
388
+ code: 'UNKNOWN_MCP_ACTION',
389
+ message: `엣지 "${edge.id}"가 도구 "${to.label}"에 없는 action ${unknown.map((a) => `"${a}"`).join(', ')}을 부른다. 도구의 actions는 ${to.actions.join(', ')}이다.`,
390
+ edgeId: edge.id,
391
+ });
392
+ }
393
+ }
394
+ /**
395
+ * describes는 문서가 말한 연결이다. 보내는 쪽은 문서여야 하고 선은 늘 점선이다.
396
+ * 가리킨 파일이 있어도 문서 서술이 코드와 맞는지는 모르기 때문이다. 확인 여부는 docLink.refs의 verified로 따로 보인다
397
+ */
398
+ function checkDescribes(ir, errors) {
399
+ const byId = new Map(ir.nodes.map((n) => [n.id, n]));
400
+ for (const edge of ir.edges) {
401
+ if (edge.kind !== 'describes')
402
+ continue;
403
+ const from = byId.get(edge.from);
404
+ if (from !== undefined && from.kind !== 'document' && from.kind !== 'design_screen') {
405
+ errors.push({
406
+ code: 'INVALID_DESCRIBES_FROM',
407
+ message: `describes 엣지 "${edge.id}"는 document나 design_screen에서 나가야 하는데 ${from.kind}에서 나간다.`,
408
+ edgeId: edge.id,
409
+ });
410
+ }
411
+ if (edge.lineStyle === 'solid') {
412
+ errors.push({
413
+ code: 'SOLID_DESCRIBES',
414
+ message: `describes 엣지 "${edge.id}"가 실선이다. 문서가 가리킨 연결은 파일이 있어도 점선으로 그린다.`,
415
+ edgeId: edge.id,
416
+ });
417
+ }
418
+ }
419
+ }
420
+ const MD_PATH_RE = /\.mdx?$/i;
421
+ /**
422
+ * 하네스 IR에서 md 파일 줄을 code 근거로 받는 자리를 스킬과 에이전트로 좁힌다. 세션이 실행하는 지시문만 코드로 친다.
423
+ * README나 docs의 언급까지 실선이 되면 문서에 적힌 계획과 실제 동작이 그림에서 안 갈린다.
424
+ * 웹 IR은 README 줄을 code로 써 온 결과가 있어 하네스 kind가 있을 때만 건다
425
+ */
426
+ function checkMdCodeEvidence(ir, errors) {
427
+ if (!ir.nodes.some((n) => HARNESS_KINDS.includes(n.kind)))
428
+ return;
429
+ const byId = new Map(ir.nodes.map((n) => [n.id, n]));
430
+ const instructionKind = (id) => {
431
+ const kind = byId.get(id)?.kind;
432
+ return kind === 'skill' || kind === 'agent';
433
+ };
434
+ const mdIndexes = (evidence) => evidence.flatMap((ev, i) => {
435
+ if (ev.type !== 'code')
436
+ return [];
437
+ const match = CODE_LOCATION_RE.exec(ev.location);
438
+ return match && MD_PATH_RE.test(match[2]) ? [i] : [];
439
+ });
440
+ const message = (location, subject) => `${subject}의 code 근거 "${location}"가 md 파일이다. md 줄을 code로 받는 건 skill, agent 노드와 그 둘에서 나가는 엣지뿐이다. 문서 언급이면 doc 근거로 바꾼다.`;
441
+ for (const node of ir.nodes) {
442
+ if (instructionKind(node.id))
443
+ continue;
444
+ for (const i of mdIndexes(node.evidence)) {
445
+ errors.push({
446
+ code: 'MD_CODE_EVIDENCE',
447
+ message: message(node.evidence[i].location, `노드 "${node.id}"`),
448
+ nodeId: node.id,
449
+ evidenceIndex: i,
450
+ });
451
+ }
452
+ }
453
+ for (const edge of ir.edges) {
454
+ if (instructionKind(edge.from))
455
+ continue;
456
+ for (const i of mdIndexes(edge.evidence)) {
457
+ errors.push({
458
+ code: 'MD_CODE_EVIDENCE',
459
+ message: message(edge.evidence[i].location, `엣지 "${edge.id}"`),
460
+ edgeId: edge.id,
461
+ evidenceIndex: i,
462
+ });
463
+ }
464
+ }
465
+ }
466
+ // 자동 질문은 페이지에 그대로 보이므로 사람이 부르는 이름으로 묻는다
467
+ function nameOf(node) {
468
+ return node.displayName ?? node.label;
469
+ }
470
+ const PLATFORM_QUESTION = {
471
+ web: '웹',
472
+ android: 'Android 앱',
473
+ ios: 'iOS 앱',
474
+ };
475
+ /** 서비스나 그 서비스에 달린 micro_app을 버킷이나 서버가 서빙하는지 */
476
+ function isServedByWebHost(serviceId, edges, nodeById, drawableEdgeIds) {
477
+ const target = (id) => {
478
+ const n = nodeById.get(id);
479
+ return id === serviceId || (n?.kind === 'micro_app' && n.parent === serviceId);
480
+ };
481
+ return edges.some((e) => e.kind === 'serves' &&
482
+ target(e.to) &&
483
+ drawableEdgeIds.has(e.id) &&
484
+ (nodeById.get(e.from)?.kind === 'bucket' || nodeById.get(e.from)?.kind === 'deploy_target'));
485
+ }
486
+ function hasQuestionFor(unresolved, subject) {
487
+ return unresolved.some((q) => q.subject.nodeId === subject.nodeId &&
488
+ q.subject.edgeId === subject.edgeId &&
489
+ q.subject.stepId === subject.stepId &&
490
+ q.subject.transitionId === subject.transitionId &&
491
+ q.subject.messageId === subject.messageId);
492
+ }
493
+ /** 메시지가 기대는 근거. 자기 근거에 가리킨 엣지의 근거를 더한다 */
494
+ function messageEvidence(m, edgeById) {
495
+ const edge = m.edge !== undefined ? edgeById.get(m.edge) : undefined;
496
+ return edge ? [...m.evidence, ...edge.evidence] : m.evidence;
497
+ }
498
+ /**
499
+ * 투영 구조 검사. 투영은 지도를 가리키기만 하므로 지도에 없는 노드와 엣지를 적으면 거부한다.
500
+ * 메시지 id는 투영을 넘어 겹치면 안 된다. 질문의 messageId가 투영 id 없이 가리키기 때문이다
501
+ */
502
+ function checkProjections(ir, ctx, errors) {
503
+ const nodeIds = new Set(ir.nodes.map((n) => n.id));
504
+ const edgeById = new Map(ir.edges.map((e) => [e.id, e]));
505
+ const projectionIds = new Set();
506
+ const messageIds = new Set();
507
+ for (const p of ir.projections ?? []) {
508
+ if (projectionIds.has(p.id)) {
509
+ errors.push({
510
+ code: 'DUPLICATE_PROJECTION_ID',
511
+ message: `투영 id "${p.id}"가 두 번 나온다.`,
512
+ projectionId: p.id,
513
+ });
514
+ }
515
+ projectionIds.add(p.id);
516
+ const missingNode = (id, where, messageId) => {
517
+ if (nodeIds.has(id))
518
+ return;
519
+ errors.push({
520
+ code: 'PROJECTION_NODE_NOT_FOUND',
521
+ message: `투영 "${p.id}"의 ${where} "${id}"가 nodes에 없다. 지도에 근거와 함께 먼저 넣는다.`,
522
+ projectionId: p.id,
523
+ ...(messageId !== undefined ? { messageId } : {}),
524
+ });
525
+ };
526
+ for (const id of p.participants ?? [])
527
+ missingNode(id, 'participants');
528
+ checkProjectionShape(p, errors);
529
+ for (const side of p.sides ?? []) {
530
+ for (const id of side.nodes)
531
+ missingNode(id, `묶음 "${side.id}"의 nodes`);
532
+ }
533
+ const participants = p.participants !== undefined ? new Set(p.participants) : undefined;
534
+ const blockIds = new Set((p.blocks ?? []).map((b) => b.id));
535
+ const closedBlocks = new Set();
536
+ let openBlock;
537
+ for (const m of p.messages) {
538
+ const at = { projectionId: p.id, messageId: m.id };
539
+ if (messageIds.has(m.id)) {
540
+ errors.push({
541
+ code: 'DUPLICATE_MESSAGE_ID',
542
+ message: `메시지 id "${m.id}"가 두 번 나온다. 투영을 넘어서도 겹치면 안 된다.`,
543
+ ...at,
544
+ });
545
+ }
546
+ messageIds.add(m.id);
547
+ missingNode(m.from, `메시지 "${m.id}"의 from`, m.id);
548
+ missingNode(m.to, `메시지 "${m.id}"의 to`, m.id);
549
+ for (const end of [m.from, m.to]) {
550
+ if (participants && nodeIds.has(end) && !participants.has(end)) {
551
+ errors.push({
552
+ code: 'PROJECTION_NODE_NOT_FOUND',
553
+ message: `메시지 "${m.id}"의 "${end}"가 투영 "${p.id}"의 participants에 없다.`,
554
+ ...at,
555
+ });
556
+ }
557
+ }
558
+ if (m.edge !== undefined) {
559
+ const edge = edgeById.get(m.edge);
560
+ if (!edge) {
561
+ errors.push({
562
+ code: 'PROJECTION_EDGE_NOT_FOUND',
563
+ message: `메시지 "${m.id}"의 edge "${m.edge}"가 edges에 없다.`,
564
+ ...at,
565
+ });
566
+ }
567
+ else {
568
+ const same = (edge.from === m.from && edge.to === m.to) ||
569
+ (edge.from === m.to && edge.to === m.from);
570
+ if (!same) {
571
+ errors.push({
572
+ code: 'PROJECTION_EDGE_MISMATCH',
573
+ message: `메시지 "${m.id}"는 "${m.from}"와 "${m.to}"를 잇는데 edge "${m.edge}"는 "${edge.from}"에서 "${edge.to}"로 간다.`,
574
+ ...at,
575
+ });
576
+ }
577
+ }
578
+ }
579
+ // 묶음은 이어진 메시지 한 덩이다. 중간에 끊겼다가 다시 나오면 사각형 하나로 못 그린다
580
+ if (m.block !== undefined && !blockIds.has(m.block)) {
581
+ errors.push({
582
+ code: 'PROJECTION_BLOCK_NOT_FOUND',
583
+ message: `메시지 "${m.id}"의 block "${m.block}"가 투영 "${p.id}"의 blocks에 없다.`,
584
+ ...at,
585
+ });
586
+ }
587
+ if (openBlock !== m.block) {
588
+ if (openBlock !== undefined)
589
+ closedBlocks.add(openBlock);
590
+ if (m.block !== undefined && closedBlocks.has(m.block)) {
591
+ errors.push({
592
+ code: 'PROJECTION_BLOCK_SPLIT',
593
+ message: `투영 "${p.id}"의 묶음 "${m.block}" 메시지가 이어져 있지 않다. 한 묶음의 메시지는 붙여 적는다.`,
594
+ ...at,
595
+ });
596
+ }
597
+ openBlock = m.block;
598
+ }
599
+ if (m.lineStyle === 'solid' && deriveLineStyle(messageEvidence(m, edgeById)) !== 'solid') {
600
+ errors.push({
601
+ code: 'SOLID_MESSAGE_WITHOUT_EVIDENCE',
602
+ message: `메시지 "${m.id}"가 실선인데 자기 근거와 가리킨 엣지 근거 어디에도 code나 spec이 없다.`,
603
+ ...at,
604
+ });
605
+ }
606
+ checkEvidenceList(m.evidence, at, ctx, errors);
607
+ }
608
+ }
609
+ }
610
+ /** 모양마다 쓰는 필드가 다르다. 안 쓰는 필드에 값이 있으면 세션이 모양을 잘못 고른 것이라 거부한다 */
611
+ function checkProjectionShape(p, errors) {
612
+ const misuse = (field) => {
613
+ errors.push({
614
+ code: 'PROJECTION_SHAPE_FIELD',
615
+ message: `투영 "${p.id}"는 ${p.shape}인데 ${field}`,
616
+ projectionId: p.id,
617
+ });
618
+ };
619
+ if (p.shape === 'compare') {
620
+ if (p.messages.length > 0)
621
+ misuse('messages가 있다. compare는 sides만 쓴다.');
622
+ if (p.blocks !== undefined)
623
+ misuse('blocks가 있다. 묶음은 sequence만 쓴다.');
624
+ const sides = p.sides ?? [];
625
+ if (sides.length !== 2)
626
+ misuse(`sides가 ${sides.length}개다. 두 개를 견준다.`);
627
+ else if (sides[0].id === sides[1].id)
628
+ misuse(`두 sides의 id가 "${sides[0].id}"로 같다.`);
629
+ return;
630
+ }
631
+ if (p.sides !== undefined)
632
+ misuse('sides가 있다. 묶음 견주기는 compare만 쓴다.');
633
+ if (p.messages.length === 0) {
634
+ errors.push({
635
+ code: 'PROJECTION_EMPTY',
636
+ message: `투영 "${p.id}"에 메시지가 없다. ${p.shape}는 메시지를 하나 이상 적는다.`,
637
+ projectionId: p.id,
638
+ });
639
+ }
640
+ if (p.shape === 'dataflow') {
641
+ if (p.blocks !== undefined)
642
+ misuse('blocks가 있다. 묶음은 sequence만 쓴다.');
643
+ const seqOnly = p.messages.find((m) => m.reply !== undefined || m.block !== undefined || m.branch !== undefined);
644
+ if (seqOnly)
645
+ misuse(`메시지 "${seqOnly.id}"에 reply, block, branch가 있다. 순서 표시는 sequence만 쓴다.`);
646
+ }
647
+ }
648
+ /**
649
+ * 투영에서 그릴 메시지를 고른다. 근거가 없으면 그리지 않고 질문으로 돌린다.
650
+ * 양 끝 노드가 안 그려지는 메시지도 뺀다. 선은 근거로 다시 정한다
651
+ */
652
+ function settleProjections(projections, edgeById, drawableNodeIds, nameById, ask) {
653
+ const messages = new Set();
654
+ const drawable = new Set();
655
+ const settled = projections.map((p) => ({
656
+ ...p,
657
+ messages: p.messages.map((m) => {
658
+ const evidence = messageEvidence(m, edgeById);
659
+ if (evidence.length === 0) {
660
+ const verb = p.shape === 'dataflow' ? '옮겨 가나요' : '주고받나요';
661
+ ask({
662
+ id: `auto:message:${m.id}`,
663
+ subject: { messageId: m.id },
664
+ question: `"${nameById(m.from)}"에서 "${nameById(m.to)}"로 가는 "${m.label}"를 코드나 문서에서 확인하지 못했어요. 실제로 ${verb}?`,
665
+ });
666
+ }
667
+ else if (drawableNodeIds.has(m.from) && drawableNodeIds.has(m.to)) {
668
+ messages.add(m.id);
669
+ drawable.add(p.id);
670
+ }
671
+ return { ...m, lineStyle: deriveLineStyle(evidence) };
672
+ }),
673
+ }));
674
+ // compare는 선이 없다. 견줄 노드가 하나라도 그려지면 그린다
675
+ for (const p of projections) {
676
+ if (p.sides?.some((side) => side.nodes.some((id) => drawableNodeIds.has(id)))) {
677
+ drawable.add(p.id);
678
+ }
679
+ }
680
+ return { projections: settled, messages, drawable };
681
+ }
682
+ /**
683
+ * 흐름 구조 검사. 단계와 전이 id는 흐름을 넘어 겹치면 안 된다. 질문의 stepId, transitionId가 흐름 id 없이 가리키기 때문이다.
684
+ * refs는 기술 그림으로 내려가는 입구라 없는 노드나 화면, API가 아닌 노드를 가리키면 거부한다
685
+ */
686
+ function checkFlows(ir, ctx, errors) {
687
+ const byId = new Map(ir.nodes.map((n) => [n.id, n]));
688
+ const seen = new Set();
689
+ const dup = (flowId, id, what) => {
690
+ if (!seen.has(`${what}\u0000${id}`)) {
691
+ seen.add(`${what}\u0000${id}`);
692
+ return;
693
+ }
694
+ errors.push({
695
+ code: 'DUPLICATE_FLOW_ID',
696
+ message: `흐름 "${flowId}"의 ${what} id "${id}"가 다른 곳에서도 쓰인다. 흐름 전체에서 겹치지 않아야 한다.`,
697
+ flowId,
698
+ });
699
+ };
700
+ for (const flow of ir.flows ?? []) {
701
+ dup(flow.id, flow.id, '흐름');
702
+ if (flow.service !== undefined && byId.get(flow.service)?.kind !== 'service') {
703
+ errors.push({
704
+ code: 'FLOW_SERVICE_NOT_FOUND',
705
+ message: `흐름 "${flow.id}"의 service "${flow.service}"가 service 노드가 아니거나 nodes에 없다.`,
706
+ flowId: flow.id,
707
+ });
708
+ }
709
+ const actorIds = new Set();
710
+ for (const a of flow.actors) {
711
+ if (actorIds.has(a.id)) {
712
+ errors.push({
713
+ code: 'DUPLICATE_FLOW_ID',
714
+ message: `흐름 "${flow.id}"에 행위자 id "${a.id}"가 두 번 있다.`,
715
+ flowId: flow.id,
716
+ });
717
+ }
718
+ actorIds.add(a.id);
719
+ }
720
+ const stepIds = new Set();
721
+ for (const step of flow.steps) {
722
+ dup(flow.id, step.id, '단계');
723
+ stepIds.add(step.id);
724
+ const where = { flowId: flow.id, stepId: step.id };
725
+ if (!actorIds.has(step.actor)) {
726
+ errors.push({
727
+ code: 'FLOW_ACTOR_NOT_FOUND',
728
+ message: `단계 "${step.id}"의 actor "${step.actor}"가 흐름 "${flow.id}"의 actors에 없다.`,
729
+ ...where,
730
+ });
731
+ }
732
+ for (const ref of step.refs ?? []) {
733
+ const target = byId.get(ref);
734
+ if (!target) {
735
+ errors.push({
736
+ code: 'FLOW_REF_NOT_FOUND',
737
+ message: `단계 "${step.id}"의 refs "${ref}"가 nodes에 없다.`,
738
+ ...where,
739
+ });
740
+ }
741
+ else if (!FLOW_REF_KINDS.includes(target.kind)) {
742
+ errors.push({
743
+ code: 'INVALID_FLOW_REF_KIND',
744
+ message: `단계 "${step.id}"의 refs "${ref}"는 ${target.kind}다. ${FLOW_REF_KINDS.join(', ')}만 가리킬 수 있다.`,
745
+ ...where,
746
+ });
747
+ }
748
+ }
749
+ checkEvidenceList(step.evidence, where, ctx, errors);
750
+ }
751
+ for (const t of flow.transitions) {
752
+ dup(flow.id, t.id, '전이');
753
+ const where = { flowId: flow.id, transitionId: t.id };
754
+ const missing = [t.from, t.to].filter((id) => !stepIds.has(id));
755
+ if (missing.length > 0) {
756
+ errors.push({
757
+ code: 'FLOW_STEP_NOT_FOUND',
758
+ message: `전이 "${t.id}"가 흐름 "${flow.id}"에 없는 단계 ${missing.map((id) => `"${id}"`).join(', ')}를 가리킨다.`,
759
+ ...where,
760
+ });
761
+ }
762
+ for (const actor of t.actors ?? []) {
763
+ if (actorIds.has(actor))
764
+ continue;
765
+ errors.push({
766
+ code: 'FLOW_ACTOR_NOT_FOUND',
767
+ message: `전이 "${t.id}"의 actors "${actor}"가 흐름 "${flow.id}"의 actors에 없다.`,
768
+ ...where,
769
+ });
770
+ }
771
+ // 엣지와 같은 규칙이다. 실선은 상태를 바꾸는 코드를 봤다는 주장이라 기획서만으로는 못 긋는다
772
+ if (t.lineStyle === 'solid' && deriveLineStyle(t.evidence) !== 'solid') {
773
+ errors.push({
774
+ code: 'SOLID_TRANSITION_WITHOUT_EVIDENCE',
775
+ message: t.evidence.length === 0
776
+ ? `전이 "${t.id}"가 실선인데 근거가 하나도 없다. 근거를 달거나 점선으로 바꿔 미해결 질문으로 돌려야 한다.`
777
+ : `전이 "${t.id}"가 실선인데 code나 spec 근거가 없다. 기획서나 지식베이스(doc)만 있으면 점선이어야 한다.`,
778
+ ...where,
779
+ });
780
+ }
781
+ checkEvidenceList(t.evidence, where, ctx, errors);
782
+ }
783
+ }
784
+ }
785
+ /**
786
+ * 사람이 두 단계 사이를 잇기만 하는 단계를 찾아 전이로 적을지 묻는다.
787
+ * 들어오고 나가는 선이 하나씩이고 나간 선이 이미 다른 길로 닿는 단계로 합류할 때가 그렇다.
788
+ * 그중 상태 값이 없거나 앞 단계로 되돌아가는 것만 고른다. 상태가 남고 앞으로 나아가는 단계는 진짜 단계라서다
789
+ */
790
+ function askStepsAsTransitions(flow, drawable, ask) {
791
+ const people = new Set(flow.actors.filter((a) => a.kind === 'person').map((a) => a.id));
792
+ const name = new Map(flow.steps.map((s) => [s.id, s.label]));
793
+ const reaches = (from, target) => {
794
+ const seen = new Set([from]);
795
+ const queue = [from];
796
+ while (queue.length > 0) {
797
+ const at = queue.shift();
798
+ if (at === target)
799
+ return true;
800
+ for (const t of flow.transitions) {
801
+ if (t.from !== at || seen.has(t.to))
802
+ continue;
803
+ seen.add(t.to);
804
+ queue.push(t.to);
805
+ }
806
+ }
807
+ return false;
808
+ };
809
+ for (const step of flow.steps) {
810
+ if (!drawable.has(step.id) || !people.has(step.actor))
811
+ continue;
812
+ const ins = flow.transitions.filter((t) => t.to === step.id);
813
+ const outs = flow.transitions.filter((t) => t.from === step.id);
814
+ if (ins.length !== 1 || outs.length !== 1)
815
+ continue;
816
+ const from = ins[0].from;
817
+ const to = outs[0].to;
818
+ // 들어온 단계로 바로 돌아가는 건 그 단계를 되돌린 것이지 이 단계가 잇는 게 아니다
819
+ if (from === to)
820
+ continue;
821
+ const rejoins = flow.transitions.some((t) => t.to === to && t.from !== step.id);
822
+ if (!rejoins)
823
+ continue;
824
+ if (step.state !== undefined && !reaches(to, from))
825
+ continue;
826
+ ask({
827
+ id: `auto:as-transition:${step.id}`,
828
+ subject: { stepId: step.id },
829
+ question: `"${step.label}" 단계는 "${name.get(from) ?? from}"에서 "${name.get(to) ?? to}"로 넘어가는 동작이라 전이로 적을 수 있어요. 이 단계에 머무는 상태가 따로 있나요? 없으면 전이의 label과 actors로 옮기는 게 읽기 편해요.`,
830
+ });
831
+ }
832
+ }
833
+ /**
834
+ * 나가는 전이가 없는데 끝 단계로 표시하지 않은 단계를 묻는다.
835
+ * 미루기처럼 다시 줄로 돌아가는 단계가 선을 빠뜨리면 그림만 봐서는 흐름이 거기서 끝난 것처럼 읽힌다
836
+ */
837
+ function askDeadEnds(flow, drawable, ask) {
838
+ const hasOut = new Set(flow.transitions.map((t) => t.from));
839
+ for (const step of flow.steps) {
840
+ if (step.terminal || hasOut.has(step.id) || !drawable.has(step.id))
841
+ continue;
842
+ ask({
843
+ id: `auto:dead-end:${step.id}`,
844
+ subject: { stepId: step.id },
845
+ question: `"${step.label}" 단계 다음에 어디로 가는지 못 찾았어요. 흐름이 여기서 끝나나요, 아니면 이어지는 단계가 있나요?`,
846
+ });
847
+ }
848
+ }
849
+ /** 근거로 다시 정한 선 모양을 싣는다. 그릴 단계와 전이를 고르고 근거 없는 것은 질문으로 돌린다 */
850
+ function settleFlows(flows, ask, askById) {
851
+ const steps = new Set();
852
+ const transitions = new Set();
853
+ const settled = flows.map((flow) => {
854
+ const stepName = new Map(flow.steps.map((s) => [s.id, s.label]));
855
+ for (const step of flow.steps) {
856
+ if (step.evidence.length > 0) {
857
+ steps.add(step.id);
858
+ continue;
859
+ }
860
+ ask({
861
+ id: `auto:step:${step.id}`,
862
+ subject: { stepId: step.id },
863
+ question: `"${flow.title}"의 "${step.label}" 단계를 코드나 지식베이스, 기획서에서 확인하지 못했어요. 실제로 있는 단계인가요?`,
864
+ });
865
+ }
866
+ // 세션이 그 단계에 다른 질문을 이미 달았어도 이 질문은 따로 선다
867
+ askStepsAsTransitions(flow, steps, askById);
868
+ askDeadEnds(flow, steps, askById);
869
+ const transitionsOut = flow.transitions.map((t) => ({
870
+ ...t,
871
+ lineStyle: deriveLineStyle(t.evidence),
872
+ }));
873
+ for (const t of transitionsOut) {
874
+ if (t.evidence.length === 0) {
875
+ ask({
876
+ id: `auto:transition:${t.id}`,
877
+ subject: { transitionId: t.id },
878
+ question: `"${stepName.get(t.from) ?? t.from}"에서 "${stepName.get(t.to) ?? t.to}"로 넘어가는 걸 코드나 문서에서 확인하지 못했어요. 실제로 이렇게 넘어가나요?`,
879
+ });
880
+ continue;
881
+ }
882
+ if (steps.has(t.from) && steps.has(t.to))
883
+ transitions.add(t.id);
884
+ }
885
+ return { ...flow, transitions: transitionsOut };
886
+ });
887
+ return { flows: settled, steps, transitions };
888
+ }
889
+ /**
890
+ * ArchitectureIr를 그리기 전에 검증한다.
891
+ * 근거가 없는 노드나 엣지는 에러로 보지 않고 그리기 대상에서 빼며 미해결 질문을 만든다.
892
+ * 엣지의 lineStyle은 근거 종류로 다시 계산해 반환하는 ir에 덮어쓴다. 입력은 바꾸지 않는다.
893
+ */
894
+ export function validateArchitectureIr(ir, opts = {}) {
895
+ const checkFiles = opts.checkFiles ?? true;
896
+ const repoRoots = opts.repoRoots ??
897
+ Object.fromEntries(ir.repos.flatMap((r) => (r.root === undefined ? [] : [[r.id, r.root]])));
898
+ const ctx = { checkFiles, repoRoots, cache: new Map() };
899
+ const errors = [];
900
+ const nodeIds = new Set(ir.nodes.map((n) => n.id));
901
+ for (const node of ir.nodes) {
902
+ checkEvidenceList(node.evidence, { nodeId: node.id }, ctx, errors);
903
+ for (const platform of Object.keys(node.platformEvidence ?? {}).sort()) {
904
+ checkEvidenceList(node.platformEvidence[platform], { nodeId: node.id }, ctx, errors);
905
+ }
906
+ }
907
+ checkPacks(ir, errors);
908
+ checkDocRepos(ir, errors);
909
+ checkParents(ir, errors);
910
+ checkAccounts(ir, errors);
911
+ checkGroups(ir, errors);
912
+ checkStages(ir, errors);
913
+ checkIdsForCloudIds(ir, errors);
914
+ checkMicroApps(ir, errors);
915
+ checkHarnessEdges(ir, errors);
916
+ checkMdCodeEvidence(ir, errors);
917
+ checkDescribes(ir, errors);
918
+ checkFlows(ir, ctx, errors);
919
+ checkProjections(ir, ctx, errors);
920
+ for (const edge of ir.edges) {
921
+ const missing = [edge.from, edge.to].filter((id) => !nodeIds.has(id));
922
+ if (missing.length > 0) {
923
+ errors.push({
924
+ code: 'DANGLING_EDGE',
925
+ message: `엣지 "${edge.id}"가 nodes에 없는 노드 ${missing.map((id) => `"${id}"`).join(', ')}를 가리킨다.`,
926
+ edgeId: edge.id,
927
+ });
928
+ }
929
+ // 실선은 "코드나 스펙으로 확인했다"는 주장이라 근거 없이 내면 그림 전체를 믿을 수 없게 된다.
930
+ // 그래서 미해결로 조용히 돌리지 않고 거부한다. 근거 없는 점선 엣지만 (e)에서 미해결로 간다.
931
+ if (edge.lineStyle === 'solid' && deriveLineStyle(edge.evidence) !== 'solid') {
932
+ errors.push({
933
+ code: 'SOLID_EDGE_WITHOUT_EVIDENCE',
934
+ message: edge.evidence.length === 0
935
+ ? `엣지 "${edge.id}"가 실선인데 근거가 하나도 없다. 근거를 달거나 점선으로 바꿔 미해결 질문으로 돌려야 한다.`
936
+ : `엣지 "${edge.id}"가 실선인데 code나 spec 근거가 없다. doc, user, live 근거만 있으면 점선이어야 한다.`,
937
+ edgeId: edge.id,
938
+ });
939
+ }
940
+ checkEvidenceList(edge.evidence, { edgeId: edge.id }, ctx, errors);
941
+ }
942
+ if (errors.length > 0)
943
+ return { ok: false, errors };
944
+ const drawableNodeIds = new Set();
945
+ const drawableEdgeIds = new Set();
946
+ const autoUnresolved = [];
947
+ const known = [...ir.unresolved];
948
+ const ask = (q) => {
949
+ if (hasQuestionFor(known, q.subject))
950
+ return;
951
+ known.push(q);
952
+ autoUnresolved.push(q);
953
+ };
954
+ // 한 대상에 질문이 여럿 설 수 있는 자리다. 대상이 아니라 id로 겹침을 본다
955
+ const askById = (q) => {
956
+ if (known.some((k) => k.id === q.id))
957
+ return;
958
+ known.push(q);
959
+ autoUnresolved.push(q);
960
+ };
961
+ // 지식과 아키텍처 그림에서 문서는 카드가 아니라 기술 카드의 배지와 서랍으로 보인다. 근거가 있어도 레이아웃에 넣지 않는다
962
+ const overlayOnly = (node) => ir.view === 'knowledge-link' && KNOWLEDGE_DOC_KINDS.includes(node.kind);
963
+ for (const node of ir.nodes) {
964
+ if (overlayOnly(node))
965
+ continue;
966
+ if (node.evidence.length > 0) {
967
+ drawableNodeIds.add(node.id);
968
+ continue;
969
+ }
970
+ ask({
971
+ id: `auto:node:${node.id}`,
972
+ subject: { nodeId: node.id },
973
+ question: `"${nameOf(node)}"가 어디에 정의돼 있는지 못 찾았어요. 위치를 알려주실 수 있나요?`,
974
+ });
975
+ }
976
+ const nodeById = new Map(ir.nodes.map((n) => [n.id, n]));
977
+ const nameById = (id) => {
978
+ const n = nodeById.get(id);
979
+ return n ? nameOf(n) : id;
980
+ };
981
+ const edges = ir.edges.map((edge) => ({
982
+ ...edge,
983
+ lineStyle: deriveLineStyle(edge.evidence),
984
+ }));
985
+ for (const edge of edges) {
986
+ if (edge.evidence.length === 0) {
987
+ ask({
988
+ id: `auto:edge:${edge.id}`,
989
+ subject: { edgeId: edge.id },
990
+ question: `"${nameById(edge.from)}"에서 "${nameById(edge.to)}"로 가는 연결을 코드나 문서에서 확인하지 못했어요. 실제로 이어져 있나요?`,
991
+ });
992
+ continue;
993
+ }
994
+ if (drawableNodeIds.has(edge.from) && drawableNodeIds.has(edge.to)) {
995
+ drawableEdgeIds.add(edge.id);
996
+ }
997
+ }
998
+ for (const node of ir.nodes) {
999
+ if (node.kind !== 'service' || !drawableNodeIds.has(node.id))
1000
+ continue;
1001
+ for (const platform of node.platforms ?? []) {
1002
+ if ((node.platformEvidence?.[platform] ?? []).length > 0)
1003
+ continue;
1004
+ if (platform === 'web' && isServedByWebHost(node.id, edges, nodeById, drawableEdgeIds))
1005
+ continue;
1006
+ askById({
1007
+ id: `auto:platform:${node.id}:${platform}`,
1008
+ subject: { nodeId: node.id },
1009
+ question: `"${nameOf(node)}"를 ${PLATFORM_QUESTION[platform]}으로 배포한다는 근거를 못 찾았어요. 배포 워크플로나 스토어 설정이 어디 있나요?`,
1010
+ });
1011
+ }
1012
+ }
1013
+ // 진입 앱을 못 정하면 사용자가 받는 도메인이 어느 사슬인지 그림이 말하지 못한다
1014
+ const drawn = ir.nodes.filter((n) => drawableNodeIds.has(n.id));
1015
+ const apps = indexMicroApps(drawn, edges.filter((e) => drawableEdgeIds.has(e.id)));
1016
+ for (const [service, list] of [...apps.appsOf].sort(([a], [b]) => (a < b ? -1 : 1))) {
1017
+ if (apps.entryOf.has(service) || list.length < 2)
1018
+ continue;
1019
+ const hosts = list.filter((a) => apps.hosts.has(a)).map(nameById);
1020
+ ask({
1021
+ id: `auto:entry:${service}`,
1022
+ subject: { nodeId: service },
1023
+ question: hosts.length === 0
1024
+ ? `"${nameById(service)}"의 앱이 모두 다른 앱에 불려 와요. 사용자가 처음 받는 호스트 앱은 어느 것인가요?`
1025
+ : `"${nameById(service)}"에서 호스트로 보이는 앱이 여럿이에요 (${hosts.map((h) => `"${h}"`).join(', ')}). 리모트를 불러오는 remotes 설정은 어느 앱에 있나요?`,
1026
+ });
1027
+ }
1028
+ const flowResult = settleFlows(ir.flows ?? [], ask, askById);
1029
+ const projectionResult = ir.projections !== undefined
1030
+ ? settleProjections(ir.projections, new Map(ir.edges.map((e) => [e.id, e])), drawableNodeIds, nameById, ask)
1031
+ : undefined;
1032
+ return {
1033
+ ok: true,
1034
+ value: {
1035
+ ir: {
1036
+ ...ir,
1037
+ edges,
1038
+ ...(ir.flows !== undefined ? { flows: flowResult.flows } : {}),
1039
+ ...(projectionResult !== undefined ? { projections: projectionResult.projections } : {}),
1040
+ },
1041
+ drawableNodeIds,
1042
+ drawableEdgeIds,
1043
+ drawableStepIds: flowResult.steps,
1044
+ drawableTransitionIds: flowResult.transitions,
1045
+ ...(projectionResult !== undefined
1046
+ ? {
1047
+ drawableMessageIds: projectionResult.messages,
1048
+ drawableProjectionIds: projectionResult.drawable,
1049
+ }
1050
+ : {}),
1051
+ autoUnresolved,
1052
+ },
1053
+ };
1054
+ }
1055
+ function mapEvidence(ir, fn) {
1056
+ const mapNode = (n) => ({
1057
+ ...n,
1058
+ evidence: n.evidence.map(fn),
1059
+ ...(n.platformEvidence !== undefined
1060
+ ? {
1061
+ platformEvidence: Object.fromEntries(Object.entries(n.platformEvidence).map(([p, list]) => [p, list.map(fn)])),
1062
+ }
1063
+ : {}),
1064
+ });
1065
+ const mapEdge = (e) => ({
1066
+ ...e,
1067
+ evidence: e.evidence.map(fn),
1068
+ });
1069
+ const mapFlow = (f) => ({
1070
+ ...f,
1071
+ steps: f.steps.map((st) => ({ ...st, evidence: st.evidence.map(fn) })),
1072
+ transitions: f.transitions.map((t) => ({ ...t, evidence: t.evidence.map(fn) })),
1073
+ });
1074
+ return {
1075
+ ...ir,
1076
+ nodes: ir.nodes.map(mapNode),
1077
+ edges: ir.edges.map(mapEdge),
1078
+ ...(ir.flows !== undefined ? { flows: ir.flows.map(mapFlow) } : {}),
1079
+ ...(ir.projections !== undefined
1080
+ ? {
1081
+ projections: ir.projections.map((p) => ({
1082
+ ...p,
1083
+ messages: p.messages.map((m) => ({ ...m, evidence: m.evidence.map(fn) })),
1084
+ })),
1085
+ }
1086
+ : {}),
1087
+ };
1088
+ }
1089
+ function maskProjectionText(p) {
1090
+ return {
1091
+ ...p,
1092
+ title: maskSharedText(p.title),
1093
+ question: maskSharedText(p.question),
1094
+ messages: p.messages.map((m) => ({
1095
+ ...m,
1096
+ label: maskSharedText(m.label),
1097
+ ...(m.branch !== undefined ? { branch: maskSharedText(m.branch) } : {}),
1098
+ })),
1099
+ ...(p.blocks !== undefined
1100
+ ? { blocks: p.blocks.map((b) => ({ ...b, label: maskSharedText(b.label) })) }
1101
+ : {}),
1102
+ ...(p.sides !== undefined
1103
+ ? { sides: p.sides.map((side) => ({ ...side, label: maskSharedText(side.label) })) }
1104
+ : {}),
1105
+ };
1106
+ }
1107
+ // 흐름 글자도 노드 이름처럼 아무 글이나 들어올 수 있어 질문과 같은 기준으로 가린다
1108
+ function maskFlowText(f) {
1109
+ const opt = (t) => t !== undefined ? { description: maskSharedText(t) } : {};
1110
+ return {
1111
+ ...f,
1112
+ title: maskSharedText(f.title),
1113
+ ...opt(f.description),
1114
+ ...(f.stateLabels !== undefined
1115
+ ? {
1116
+ stateLabels: Object.fromEntries(Object.entries(f.stateLabels).map(([k, v]) => [k, maskSharedText(v)])),
1117
+ }
1118
+ : {}),
1119
+ actors: f.actors.map((a) => ({ ...a, label: maskSharedText(a.label) })),
1120
+ steps: f.steps.map((st) => ({
1121
+ ...st,
1122
+ label: maskSharedText(st.label),
1123
+ ...opt(st.description),
1124
+ })),
1125
+ transitions: f.transitions.map((t) => ({
1126
+ ...t,
1127
+ ...(t.label !== undefined ? { label: maskSharedText(t.label) } : {}),
1128
+ })),
1129
+ };
1130
+ }
1131
+ const MASKED_ACCOUNT_ID = '[계정 ID]';
1132
+ const MASKED_DISTRIBUTION_ID = '[배포 ID]';
1133
+ /** 공유본에 실을 글자에서 계정 ID 꼴 숫자를 가린다 */
1134
+ export function maskAccountIds(text) {
1135
+ return text.replace(ACCOUNT_ID_RE, MASKED_ACCOUNT_ID);
1136
+ }
1137
+ /** 질문처럼 어느 노드 이름이 섞였는지 모르는 글자. 계정 ID와 배포 ID 꼴을 둘 다 가린다 */
1138
+ export function maskSharedText(text) {
1139
+ return maskAccountIds(text).replace(DISTRIBUTION_ID_RE, MASKED_DISTRIBUTION_ID);
1140
+ }
1141
+ /**
1142
+ * 공유본의 문서 정보. 문서 제목(displayName)과 경로(label, doc.path)는 공유본에도 남긴다.
1143
+ * 본문에서 온 글자(절 제목, 구멍 설명과 담당, 안내 키워드, 화면 이름과 썸네일)는 빼고 숫자와 날짜, 참거짓만 둔다
1144
+ */
1145
+ function maskDocInfo(doc) {
1146
+ const { sections: _sections, keywords: _keywords, gaps, routes, screen, evidenceMix, ...rest } = doc;
1147
+ return {
1148
+ ...rest,
1149
+ path: doc.path,
1150
+ ...(evidenceMix !== undefined ? { evidenceMix: { ...evidenceMix } } : {}),
1151
+ ...(gaps !== undefined ? { gaps: gaps.map((g) => ({ kind: g.kind })) } : {}),
1152
+ ...(routes !== undefined
1153
+ ? {
1154
+ routes: routes.map((r) => ({
1155
+ keywords: [MASKED_KEYWORD],
1156
+ targetPath: r.targetPath,
1157
+ ...(r.target !== undefined ? { target: r.target } : {}),
1158
+ })),
1159
+ }
1160
+ : {}),
1161
+ ...(screen !== undefined
1162
+ ? {
1163
+ screen: {
1164
+ ...(screen.route !== undefined ? { route: screen.route } : {}),
1165
+ ...(screen.screenType !== undefined ? { screenType: screen.screenType } : {}),
1166
+ ...(screen.symbolized !== undefined ? { symbolized: screen.symbolized } : {}),
1167
+ ...(screen.hasSpec !== undefined ? { hasSpec: screen.hasSpec } : {}),
1168
+ },
1169
+ }
1170
+ : {}),
1171
+ };
1172
+ }
1173
+ const MASKED_KEYWORD = '(공유본이라 가림)';
1174
+ /** describes가 가리킨 파일 경로는 문서 밖 레포의 경로라 공유본에서 가리고 확인 여부만 남긴다 */
1175
+ function maskDocLink(edge) {
1176
+ if (edge.docLink === undefined)
1177
+ return edge;
1178
+ return {
1179
+ ...edge,
1180
+ docLink: {
1181
+ via: edge.docLink.via,
1182
+ refs: edge.docLink.refs.map((r) => ({
1183
+ target: MASKED_KEYWORD,
1184
+ verified: r.verified,
1185
+ ...(r.pinned !== undefined ? { pinned: r.pinned } : {}),
1186
+ ...(r.committedAt !== undefined ? { committedAt: r.committedAt } : {}),
1187
+ })),
1188
+ },
1189
+ };
1190
+ }
1191
+ function maskNodeText(node) {
1192
+ const mask = (t) => {
1193
+ const masked = maskAccountIds(t);
1194
+ return node.kind === 'cdn'
1195
+ ? masked.replace(DISTRIBUTION_ID_RE, MASKED_DISTRIBUTION_ID)
1196
+ : masked;
1197
+ };
1198
+ const { owners: _owners, doc, ...rest } = node;
1199
+ return {
1200
+ ...rest,
1201
+ label: mask(node.label),
1202
+ ...(node.displayName !== undefined ? { displayName: mask(node.displayName) } : {}),
1203
+ ...(node.description !== undefined ? { description: mask(node.description) } : {}),
1204
+ ...(doc !== undefined ? { doc: maskDocInfo(doc) } : {}),
1205
+ };
1206
+ }
1207
+ /**
1208
+ * 공유용 사본. 입력은 바꾸지 않는다.
1209
+ * private 근거는 type과 visibility만 남긴다. live 근거는 public이어도 명령과 리소스 식별자에 계정 ID나
1210
+ * 리소스 ID가 담기므로 조회 시각만 남긴다. 노드 이름과 질문 글자의 계정 ID 꼴 숫자도 가린다
1211
+ */
1212
+ export function redactForSharing(ir) {
1213
+ const redacted = mapEvidence(ir, (ev) => {
1214
+ if (ev.type === 'live') {
1215
+ return {
1216
+ type: ev.type,
1217
+ visibility: ev.visibility,
1218
+ ...(ev.observedAt !== undefined ? { observedAt: ev.observedAt } : {}),
1219
+ };
1220
+ }
1221
+ return ev.visibility === 'private'
1222
+ ? { type: ev.type, visibility: ev.visibility }
1223
+ : { ...ev };
1224
+ });
1225
+ return {
1226
+ ...redacted,
1227
+ nodes: redacted.nodes.map(maskNodeText),
1228
+ edges: redacted.edges.map(maskDocLink),
1229
+ ...(redacted.flows !== undefined ? { flows: redacted.flows.map(maskFlowText) } : {}),
1230
+ ...(redacted.projections !== undefined
1231
+ ? { projections: redacted.projections.map(maskProjectionText) }
1232
+ : {}),
1233
+ unresolved: redacted.unresolved.map((q) => ({
1234
+ ...q,
1235
+ question: maskSharedText(q.question),
1236
+ ...(q.answer !== undefined ? { answer: maskSharedText(q.answer) } : {}),
1237
+ })),
1238
+ };
1239
+ }
1240
+ /** 저장용 사본. private 근거의 excerpt만 지운다. 입력은 바꾸지 않는다 */
1241
+ export function stripPrivateExcerpts(ir) {
1242
+ return mapEvidence(ir, (ev) => {
1243
+ if (ev.visibility !== 'private' || ev.excerpt === undefined)
1244
+ return { ...ev };
1245
+ const { excerpt: _excerpt, ...rest } = ev;
1246
+ return rest;
1247
+ });
1248
+ }
1249
+ //# sourceMappingURL=validator.js.map