@cheongfish/bouncer 0.0.0-stage → 0.1.13

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 (156) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +31 -2
  3. package/bin/bouncer +26 -0
  4. package/bin/bouncer-root +25 -0
  5. package/bin/build-stamp.js +78 -0
  6. package/bin/register-path.js +71 -0
  7. package/dist/.build-stamp +1 -0
  8. package/dist/approval-snapshot.js +99 -0
  9. package/dist/bouncer-root.js +80 -0
  10. package/dist/canonical-json.js +37 -0
  11. package/dist/cli-commit-guard-command.js +74 -0
  12. package/dist/cli-config-command.js +47 -0
  13. package/dist/cli-context-review-command.js +273 -0
  14. package/dist/cli-current-command.js +374 -0
  15. package/dist/cli-dispatch-command.js +119 -0
  16. package/dist/cli-doc-commands.js +352 -0
  17. package/dist/cli-epic-command.js +130 -0
  18. package/dist/cli-flags.js +50 -0
  19. package/dist/cli-git-commands.js +931 -0
  20. package/dist/cli-intent-command.js +491 -0
  21. package/dist/cli-metrics-command.js +134 -0
  22. package/dist/cli-ontology-command.js +109 -0
  23. package/dist/cli-project-commands.js +580 -0
  24. package/dist/cli-retro-command.js +138 -0
  25. package/dist/cli-review-command.js +251 -0
  26. package/dist/cli-review-dispatch-command.js +338 -0
  27. package/dist/cli-usage.js +72 -0
  28. package/dist/cli.js +70 -0
  29. package/dist/codex-agents.js +189 -0
  30. package/dist/command-argv.js +125 -0
  31. package/dist/commit-guard.js +72 -0
  32. package/dist/commit-hook.js +470 -0
  33. package/dist/commit-message.js +77 -0
  34. package/dist/commit-sha.js +107 -0
  35. package/dist/commit.js +519 -0
  36. package/dist/composite-gates.js +129 -0
  37. package/dist/comprehension.js +127 -0
  38. package/dist/config.js +298 -0
  39. package/dist/context-ids.js +288 -0
  40. package/dist/context-migration-rewrite.js +421 -0
  41. package/dist/context-ontology.js +746 -0
  42. package/dist/context-review-contracts.js +2 -0
  43. package/dist/context-review-projection.js +147 -0
  44. package/dist/context-review-state.js +824 -0
  45. package/dist/context-review-store.js +602 -0
  46. package/dist/context-review-transitions.js +412 -0
  47. package/dist/coordinate-advance.js +253 -0
  48. package/dist/coordinate-next.js +998 -0
  49. package/dist/coordinate-output.js +57 -0
  50. package/dist/coordinator-command-contracts.js +2 -0
  51. package/dist/coordinator-commands.js +1908 -0
  52. package/dist/coordinator-fanin.js +1021 -0
  53. package/dist/coordinator-git.js +121 -0
  54. package/dist/coordinator-input.js +37 -0
  55. package/dist/coordinator-ledger.js +381 -0
  56. package/dist/coordinator-prepare.js +1047 -0
  57. package/dist/coordinator-repair.js +1416 -0
  58. package/dist/coordinator-verification.js +42 -0
  59. package/dist/coordinator-worker.js +388 -0
  60. package/dist/coordinator.js +7 -0
  61. package/dist/cross-drive.js +85 -0
  62. package/dist/current.js +1148 -0
  63. package/dist/dag-shape.js +93 -0
  64. package/dist/doc-shape-contracts.js +241 -0
  65. package/dist/drive-metrics.js +691 -0
  66. package/dist/epic-index.js +347 -0
  67. package/dist/epic-registry.js +453 -0
  68. package/dist/execute-prepare.js +262 -0
  69. package/dist/execution-contracts.js +46 -0
  70. package/dist/execution-dag.js +148 -0
  71. package/dist/finalize-checks.js +177 -0
  72. package/dist/finalize-collect.js +343 -0
  73. package/dist/finalize-comprehension.js +116 -0
  74. package/dist/finalize-digest-collect.js +388 -0
  75. package/dist/finalize-digest-model.js +595 -0
  76. package/dist/finalize-digest.js +164 -0
  77. package/dist/finalize-explain.js +176 -0
  78. package/dist/finalize-pr.js +610 -0
  79. package/dist/finalize-release-main.js +652 -0
  80. package/dist/finalize.js +714 -0
  81. package/dist/frontmatter.js +22 -0
  82. package/dist/git-ops.js +49 -0
  83. package/dist/graph-exec.js +179 -0
  84. package/dist/graph-scope.js +268 -0
  85. package/dist/graphify.js +995 -0
  86. package/dist/guards.js +82 -0
  87. package/dist/import-git.js +78 -0
  88. package/dist/import-history.js +392 -0
  89. package/dist/import-render.js +59 -0
  90. package/dist/import-types.js +2 -0
  91. package/dist/init.js +571 -0
  92. package/dist/intent-bundle.js +936 -0
  93. package/dist/intent-provenance.js +927 -0
  94. package/dist/layout.js +131 -0
  95. package/dist/lease.js +145 -0
  96. package/dist/legacy-comments.js +71 -0
  97. package/dist/migrate-context-slugs.js +262 -0
  98. package/dist/migrate-merge-legacy-epics.js +392 -0
  99. package/dist/migrate-review-scope.js +144 -0
  100. package/dist/migrate-task-layout.js +194 -0
  101. package/dist/path-overlap.js +38 -0
  102. package/dist/paths.js +45 -0
  103. package/dist/plan-approve.js +218 -0
  104. package/dist/plan-inspect.js +575 -0
  105. package/dist/plan-prerequisites.js +197 -0
  106. package/dist/plan-snapshot-contract.js +98 -0
  107. package/dist/plan-snapshot.js +138 -0
  108. package/dist/plugin-root.js +237 -0
  109. package/dist/posix-path.js +14 -0
  110. package/dist/pre-commit-hook.js +170 -0
  111. package/dist/print-dispatch.js +285 -0
  112. package/dist/render.js +8 -0
  113. package/dist/retention-migration.js +595 -0
  114. package/dist/retro-read.js +259 -0
  115. package/dist/retro-sources.js +318 -0
  116. package/dist/review-dispatch.js +1054 -0
  117. package/dist/review-policy.js +28 -0
  118. package/dist/review-record.js +447 -0
  119. package/dist/review-repair-state.js +52 -0
  120. package/dist/review-snapshot.js +659 -0
  121. package/dist/run-detach.js +165 -0
  122. package/dist/run-preflight.js +368 -0
  123. package/dist/runtime-state.js +1038 -0
  124. package/dist/scaffold.js +533 -0
  125. package/dist/schema.js +294 -0
  126. package/dist/scope.js +1073 -0
  127. package/dist/seed-worktree.js +926 -0
  128. package/dist/session-graph.js +337 -0
  129. package/dist/subagents.js +86 -0
  130. package/dist/symbol-index.js +1390 -0
  131. package/dist/task-brief-hash.js +34 -0
  132. package/dist/task-commits.js +124 -0
  133. package/dist/tasks-docs.js +192 -0
  134. package/dist/templates.js +469 -0
  135. package/dist/time.js +21 -0
  136. package/dist/validate-docs.js +302 -0
  137. package/dist/validate-gates-finalize.js +303 -0
  138. package/dist/validate-gates-plan.js +720 -0
  139. package/dist/validate-gates-run.js +420 -0
  140. package/dist/validate-gates-shared.js +124 -0
  141. package/dist/validate-gates.js +87 -0
  142. package/dist/validate-sections.js +654 -0
  143. package/dist/validate-structural.js +347 -0
  144. package/dist/validate.js +417 -0
  145. package/dist/verification-contracts.js +26 -0
  146. package/dist/verification-evidence.js +233 -0
  147. package/dist/verification-execution.js +163 -0
  148. package/dist/verification-identity.js +413 -0
  149. package/dist/verification-target.js +51 -0
  150. package/dist/verification.js +383 -0
  151. package/dist/windows-npm.js +38 -0
  152. package/package.json +32 -4
  153. package/resources/graphify-compat.json +9 -0
  154. package/vendor/README.md +23 -0
  155. package/vendor/js-yaml.LICENSE +21 -0
  156. package/vendor/js-yaml.js +3022 -0
@@ -0,0 +1,1390 @@
1
+ 'use strict';
2
+ const guards = require("./guards");
3
+ const { isFsCode, isSkippableFsError } = guards;
4
+ const posixPath = require("./posix-path");
5
+ const { toPosix } = posixPath;
6
+ const fs = require("node:fs");
7
+ const path = require('node:path');
8
+ const { createHash } = require('node:crypto');
9
+ const SKIP_DIR_NAMES = new Set(['.git', 'node_modules', '.worktrees', 'graphify-out']);
10
+ const SCRIPT_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts']);
11
+ const IDENT_START = /[A-Za-z_$]/;
12
+ const IDENT_PART = /[A-Za-z0-9_$]/;
13
+ // 이 키워드 뒤의 `/` 만 정규식이다. 식별자·숫자·`)` 뒤의 `/` 는 나눗셈이므로
14
+ // 본문을 정규식으로 삼켜 다른 파일 후보를 지우지 않는다.
15
+ const REGEX_AFTER_KEYWORD = new Set([
16
+ 'return', 'throw', 'case', 'delete', 'void', 'typeof', 'new', 'await', 'yield',
17
+ 'else', 'do', 'in', 'of', 'instanceof', 'extends',
18
+ ]);
19
+ const EXPR_CONTINUES = new Set([
20
+ '=', '+=', '-=', '*=', '/=', '%=', '&=', '|=', '^=', '&&=', '||=', '??=',
21
+ '(', '[', ',', '?', ':', '=>', '.', '...', '?.',
22
+ '+', '-', '*', '/', '%', '&', '|', '^', '!', '~',
23
+ '&&', '||', '??', '===', '!==', '==', '!=', '<', '>',
24
+ 'new', 'typeof', 'void', 'delete', 'await', 'yield', 'instanceof', 'in', 'of',
25
+ 'case',
26
+ ]);
27
+ const STMT_START_IDS = new Set([
28
+ 'function', 'class', 'const', 'let', 'var', 'if', 'for', 'while', 'do',
29
+ 'switch', 'try', 'with', 'return', 'throw', 'break', 'continue', 'debugger',
30
+ 'export', 'import', 'declare', 'interface', 'type', 'enum', 'namespace',
31
+ 'module', 'using',
32
+ ]);
33
+ const MEMBER_MODS = new Set([
34
+ 'public', 'private', 'protected', 'readonly', 'abstract', 'override',
35
+ 'accessor', 'static', 'async',
36
+ ]);
37
+ const PUNCT_MULTI = new Set([
38
+ '>>>=', '&&=', '||=', '??=', '===', '!==', '**=',
39
+ '...', '=>', '?.', '??', '&&', '||', '++', '--',
40
+ '+=', '-=', '*=', '/=', '%=', '&=', '|=', '^=',
41
+ '==', '!=', '**',
42
+ ]);
43
+ /**
44
+ * 요청한 함수명의 현재 checkout 정의만 구조적으로 모아 source를 고른다.
45
+ * 고유 source는 바로 고르고, 둘 이상이면 opaque candidate ref만으로 재선택한다.
46
+ * path 문자열을 재선택 키로 받지 않으며, 저장소와 Git 객체 DB는 바꾸지 않는다.
47
+ *
48
+ * @param {object} input - 조회 입력
49
+ * @param {string} input.repoRoot - 저장소 루트. 이 경계 밖 실경로는 후보가 되지 않는다
50
+ * @param {string} input.symbol - 함수명. 공백만 있으면 거절한다
51
+ * @param {string} [input.candidateRef] - 이 조회가 발급한 opaque ref. 현재 source 집합에 없으면 거절
52
+ * @returns {ResolveResult} resolved / ambiguous / unresolved. 비-source는 diagnostics에만 남긴다
53
+ */
54
+ function resolveSymbol(input) {
55
+ // 1. 입력 거절은 후보를 만들기 전에 끝낸다. 빈 이름으로 전 저장소를 훑지 않는다.
56
+ const symbol = requireSymbol(input.symbol);
57
+ const repoReal = resolveRepoRoot(input.repoRoot);
58
+ const files = listScriptFiles(repoReal);
59
+ const hits = [];
60
+ for (const abs of files) {
61
+ const collected = collectFromFile(abs, repoReal, symbol);
62
+ hits.push(...collected);
63
+ }
64
+ hits.sort(compareCandidates);
65
+ const sourceHits = hits.filter((item) => item.role === 'source');
66
+ const diagnostics = hits.filter((item) => item.role !== 'source');
67
+ const candidateRef = input.candidateRef;
68
+ // 2. ref가 있으면 현재 source 집합에서만 고른다. generated/test/vendor ref를
69
+ // source로 승격하거나, 없는 ref를 유일한 source로 대체하지 않는다.
70
+ if (candidateRef !== undefined && candidateRef !== null) {
71
+ if (typeof candidateRef !== 'string' || candidateRef.length === 0) {
72
+ throw new Error('unknown candidate ref');
73
+ }
74
+ const selected = sourceHits.find((item) => item.candidate_ref === candidateRef);
75
+ if (!selected) {
76
+ throw new Error('unknown candidate ref');
77
+ }
78
+ return { status: 'resolved', symbol_ref: selected.symbol_ref, diagnostics };
79
+ }
80
+ if (sourceHits.length === 1) {
81
+ const only = sourceHits[0];
82
+ return { status: 'resolved', symbol_ref: only.symbol_ref, diagnostics };
83
+ }
84
+ if (sourceHits.length > 1) {
85
+ return { status: 'ambiguous', candidates: sourceHits, diagnostics };
86
+ }
87
+ return { status: 'unresolved', candidates: diagnostics, diagnostics };
88
+ }
89
+ /**
90
+ * 식별자로 쓸 함수명을 받는다. trim 후 비면 거절해 공백 조회가 전 파일 스캔으로
91
+ * 번지지 않게 한다.
92
+ *
93
+ * @param {unknown} value - 호출자가 넘긴 symbol
94
+ * @returns {string} trim한 함수명
95
+ */
96
+ function requireSymbol(value) {
97
+ if (typeof value !== 'string') {
98
+ throw new Error('symbol must be a non-empty identifier');
99
+ }
100
+ const symbol = value.trim();
101
+ if (symbol.length === 0) {
102
+ throw new Error('symbol must be a non-empty identifier');
103
+ }
104
+ return symbol;
105
+ }
106
+ /**
107
+ * repoRoot를 실 디렉터리로 고정한다. 파일·부재는 같은 거절이고, 권한 오류는
108
+ * 부재로 바꾸지 않는다.
109
+ *
110
+ * @param {unknown} repoRoot - 저장소 루트 입력
111
+ * @returns {string} realpath
112
+ */
113
+ function resolveRepoRoot(repoRoot) {
114
+ if (typeof repoRoot !== 'string' || repoRoot.length === 0) {
115
+ throw new Error('repoRoot must be a directory');
116
+ }
117
+ let st;
118
+ try {
119
+ st = fs.statSync(repoRoot);
120
+ }
121
+ catch (error) {
122
+ // ENOENT만 "루트가 없다". EACCES를 여기 접으면 권한 문제가 잘못된 입력으로 보인다.
123
+ if (isFsCode(error, 'ENOENT')) {
124
+ throw new Error('repoRoot must be a directory', { cause: error });
125
+ }
126
+ throw error;
127
+ }
128
+ if (!st.isDirectory()) {
129
+ throw new Error('repoRoot must be a directory');
130
+ }
131
+ return fs.realpathSync(repoRoot);
132
+ }
133
+ /**
134
+ * 저장소 안 스크립트 파일만 모은다. symlink가 루트 밖으로 나가면 그 경로만 버리고
135
+ * 나머지 파일의 후보를 유지한다.
136
+ *
137
+ * @param {string} repoReal - realpath 저장소 루트
138
+ * @returns {string[]} 정렬된 절대 경로
139
+ */
140
+ function listScriptFiles(repoReal) {
141
+ const files = [];
142
+ walkDir(repoReal, repoReal, files);
143
+ files.sort();
144
+ return files;
145
+ }
146
+ function walkDir(absDir, repoReal, files) {
147
+ let entries;
148
+ try {
149
+ entries = fs.readdirSync(absDir, { withFileTypes: true });
150
+ }
151
+ catch (error) {
152
+ // 한 디렉터리의 ENOENT/EACCES/ELOOP만 건너뛴다. 그 외는 색인 전체를 공집합으로 위장하지 않는다.
153
+ if (isSkippableFsError(error))
154
+ return;
155
+ throw error;
156
+ }
157
+ for (const ent of entries) {
158
+ if (ent.name === '.' || ent.name === '..')
159
+ continue;
160
+ const abs = path.join(absDir, ent.name);
161
+ if (ent.isDirectory()) {
162
+ if (SKIP_DIR_NAMES.has(ent.name))
163
+ continue;
164
+ if (!isInsideRepo(abs, repoReal))
165
+ continue;
166
+ walkDir(abs, repoReal, files);
167
+ continue;
168
+ }
169
+ if (!isScriptFileName(ent.name))
170
+ continue;
171
+ let real;
172
+ try {
173
+ real = fs.realpathSync(abs);
174
+ }
175
+ catch (error) {
176
+ if (isSkippableFsError(error))
177
+ continue;
178
+ throw error;
179
+ }
180
+ // 저장소 밖 실경로는 후보 path로 쓰지 않는다. 호출자가 symlink로 경계를 넘긴 입력이다.
181
+ if (!isInsideRepo(real, repoReal))
182
+ continue;
183
+ files.push(abs);
184
+ }
185
+ }
186
+ function isScriptFileName(name) {
187
+ // .d.ts는 구현이 아니라 서명이다. 확장자만으로 source를 정하지는 않고 파싱 대상에서만 뺀다.
188
+ if (name.endsWith('.d.ts') || name.endsWith('.d.mts') || name.endsWith('.d.cts'))
189
+ return false;
190
+ return SCRIPT_EXTS.has(path.extname(name).toLowerCase());
191
+ }
192
+ function isInsideRepo(absPath, repoReal) {
193
+ if (absPath === repoReal)
194
+ return true;
195
+ const prefix = repoReal.endsWith(path.sep) ? repoReal : `${repoReal}${path.sep}`;
196
+ return absPath.startsWith(prefix);
197
+ }
198
+ /**
199
+ * 한 파일에서 요청한 이름의 지원 정의만 모은다. 구문 오류가 있으면 이 파일은
200
+ * 통째로 버리고 문자열 검색으로 보정하지 않는다 — 깨진 트리가 다른 파일 후보를
201
+ * 늘리거나 줄이지 않게 하기 위함이다.
202
+ *
203
+ * @param {string} absPath - 저장소 안 파일 절대 경로
204
+ * @param {string} repoReal - realpath 루트
205
+ * @param {string} symbol - 찾을 함수명
206
+ * @returns {SymbolCandidate[]} 역할·blob SHA·opaque ref가 붙은 후보
207
+ */
208
+ function collectFromFile(absPath, repoReal, symbol) {
209
+ let bytes;
210
+ try {
211
+ bytes = fs.readFileSync(absPath);
212
+ }
213
+ catch (error) {
214
+ // 읽기 실패는 이 파일에 정의가 없는 것과 같다. 다른 파일 후보를 지어내지 않는다.
215
+ if (isSkippableFsError(error))
216
+ return [];
217
+ throw error;
218
+ }
219
+ const rel = toPosix(path.relative(repoReal, absPath));
220
+ let tokens;
221
+ try {
222
+ tokens = tokenize(bytes.toString('utf8'));
223
+ }
224
+ catch (error) {
225
+ // 이 파일의 토큰 오류만 삼킨다. 다른 파일 후보와 상태를 공유하지 않는다.
226
+ if (error instanceof StructError)
227
+ return [];
228
+ throw error;
229
+ }
230
+ let raw;
231
+ try {
232
+ raw = collectDefinitions(tokens, symbol, rel);
233
+ }
234
+ catch (error) {
235
+ // 이 파일의 불일치 괄호·헤더 오류만 삼킨다. 이름을 본문에서 찾아 후보를 만들지 않는다.
236
+ if (error instanceof StructError)
237
+ return [];
238
+ throw error;
239
+ }
240
+ const blobSha = gitBlobSha(bytes);
241
+ const role = classifyRole(rel);
242
+ return raw.map((hit) => {
243
+ const symbolRef = {
244
+ path: hit.path,
245
+ qualified_name: hit.qualified_name,
246
+ kind: hit.kind,
247
+ start_line: hit.start_line,
248
+ end_line: hit.end_line,
249
+ blob_sha: blobSha,
250
+ };
251
+ return {
252
+ candidate_ref: makeCandidateRef(symbolRef),
253
+ role,
254
+ symbol_ref: symbolRef,
255
+ };
256
+ });
257
+ }
258
+ /**
259
+ * 한 파일의 구조 오류. 메시지·스택은 호출자에게 새지 않고 이 파일 후보만 비운다.
260
+ */
261
+ class StructError extends Error {
262
+ constructor() {
263
+ super('unparseable');
264
+ this.name = 'StructError';
265
+ }
266
+ }
267
+ /**
268
+ * 주석·문자열·템플릿·정규식을 토큰으로 가린다. 이름 문자열을 정의로 승격하지 않기
269
+ * 위해 본문 검색 없이 구조 토큰만 남긴다.
270
+ *
271
+ * @param {string} text - 파일 본문
272
+ * @returns {Token[]} eof 토큰으로 끝나는 목록
273
+ */
274
+ function tokenize(text) {
275
+ const s = { text, i: 0, line: 1 };
276
+ if (text.charCodeAt(0) === 0xFEFF)
277
+ s.i = 1;
278
+ if (text.startsWith('#!', s.i)) {
279
+ while (s.i < text.length && text[s.i] !== '\n')
280
+ s.i += 1;
281
+ }
282
+ const tokens = [];
283
+ fillTokens(s, tokens, false);
284
+ tokens.push({ kind: 'eof', value: '', line: s.line });
285
+ return tokens;
286
+ }
287
+ /**
288
+ * @param {ScanState} s - 공유 스캔 위치
289
+ * @param {Token[]} tokens - 출력
290
+ * @param {boolean} interp - 템플릿 `${` 안이면 깊이 0의 `}` 에서 멈춘다
291
+ */
292
+ function fillTokens(s, tokens, interp) {
293
+ let braceDepth = 0;
294
+ while (s.i < s.text.length) {
295
+ skipSpaces(s);
296
+ if (s.i >= s.text.length)
297
+ break;
298
+ if (skipComment(s))
299
+ continue;
300
+ if (interp && braceDepth === 0 && s.text[s.i] === '}') {
301
+ s.i += 1;
302
+ return;
303
+ }
304
+ if (s.text[s.i] === '`') {
305
+ scanTemplate(s, tokens);
306
+ continue;
307
+ }
308
+ const prev = tokens.length > 0 ? tokens[tokens.length - 1] : null;
309
+ const tok = scanPlainToken(s, prev);
310
+ if (!tok)
311
+ continue;
312
+ if (interp) {
313
+ if (tok.value === '{')
314
+ braceDepth += 1;
315
+ else if (tok.value === '}')
316
+ braceDepth -= 1;
317
+ }
318
+ tokens.push(tok);
319
+ }
320
+ if (interp)
321
+ throw new StructError();
322
+ }
323
+ function skipSpaces(s) {
324
+ while (s.i < s.text.length) {
325
+ const c = s.text[s.i];
326
+ if (c === '\n') {
327
+ s.line += 1;
328
+ s.i += 1;
329
+ continue;
330
+ }
331
+ if (c === ' ' || c === '\t' || c === '\r' || c === '\f' || c === '\v') {
332
+ s.i += 1;
333
+ continue;
334
+ }
335
+ break;
336
+ }
337
+ }
338
+ function skipComment(s) {
339
+ const text = s.text;
340
+ if (text.startsWith('//', s.i)) {
341
+ s.i += 2;
342
+ while (s.i < text.length && text[s.i] !== '\n')
343
+ s.i += 1;
344
+ return true;
345
+ }
346
+ if (text.startsWith('/*', s.i)) {
347
+ s.i += 2;
348
+ const end = text.indexOf('*/', s.i);
349
+ if (end < 0)
350
+ throw new StructError();
351
+ for (let k = s.i; k < end; k += 1) {
352
+ if (text[k] === '\n')
353
+ s.line += 1;
354
+ }
355
+ s.i = end + 2;
356
+ return true;
357
+ }
358
+ return false;
359
+ }
360
+ /**
361
+ * @param {ScanState} s - 스캔 위치
362
+ * @param {Token | null} prev - 직전 실토큰. `/` 가 정규식인지 나눗셈인지 가른다
363
+ * @returns {Token | null} 공백이면 null
364
+ */
365
+ function scanPlainToken(s, prev) {
366
+ if (s.i >= s.text.length)
367
+ return null;
368
+ const line = s.line;
369
+ const c = s.text[s.i];
370
+ if (IDENT_START.test(c))
371
+ return scanIdent(s, line);
372
+ if (c >= '0' && c <= '9')
373
+ return scanNumber(s, line);
374
+ if (c === '"' || c === "'")
375
+ return scanQuoted(s, c, line);
376
+ if (c === '/' && slashStartsRegex(prev))
377
+ return scanRegex(s, line);
378
+ return scanPunct(s, line);
379
+ }
380
+ function scanIdent(s, line) {
381
+ const start = s.i;
382
+ s.i += 1;
383
+ while (s.i < s.text.length && IDENT_PART.test(s.text[s.i]))
384
+ s.i += 1;
385
+ return { kind: 'id', value: s.text.slice(start, s.i), line };
386
+ }
387
+ function scanNumber(s, line) {
388
+ const start = s.i;
389
+ if (s.text.startsWith('0x', s.i) || s.text.startsWith('0X', s.i)) {
390
+ s.i += 2;
391
+ while (s.i < s.text.length && /[0-9A-Fa-f_]/.test(s.text[s.i]))
392
+ s.i += 1;
393
+ }
394
+ else if (s.text.startsWith('0b', s.i) || s.text.startsWith('0B', s.i)) {
395
+ s.i += 2;
396
+ while (s.i < s.text.length && /[01_]/.test(s.text[s.i]))
397
+ s.i += 1;
398
+ }
399
+ else if (s.text.startsWith('0o', s.i) || s.text.startsWith('0O', s.i)) {
400
+ s.i += 2;
401
+ while (s.i < s.text.length && /[0-7_]/.test(s.text[s.i]))
402
+ s.i += 1;
403
+ }
404
+ else {
405
+ while (s.i < s.text.length && /[0-9_]/.test(s.text[s.i]))
406
+ s.i += 1;
407
+ if (s.text[s.i] === '.') {
408
+ s.i += 1;
409
+ while (s.i < s.text.length && /[0-9_]/.test(s.text[s.i]))
410
+ s.i += 1;
411
+ }
412
+ if (s.text[s.i] === 'e' || s.text[s.i] === 'E') {
413
+ s.i += 1;
414
+ if (s.text[s.i] === '+' || s.text[s.i] === '-')
415
+ s.i += 1;
416
+ while (s.i < s.text.length && /[0-9_]/.test(s.text[s.i]))
417
+ s.i += 1;
418
+ }
419
+ }
420
+ if (s.text[s.i] === 'n')
421
+ s.i += 1;
422
+ return { kind: 'num', value: s.text.slice(start, s.i), line };
423
+ }
424
+ function scanQuoted(s, quote, line) {
425
+ s.i += 1;
426
+ let value = '';
427
+ while (s.i < s.text.length) {
428
+ const c = s.text[s.i];
429
+ if (c === '\\') {
430
+ value += s.text[s.i + 1] || '';
431
+ s.i += 2;
432
+ continue;
433
+ }
434
+ if (c === quote) {
435
+ s.i += 1;
436
+ return { kind: 'str', value, line };
437
+ }
438
+ if (c === '\n')
439
+ s.line += 1;
440
+ value += c;
441
+ s.i += 1;
442
+ }
443
+ throw new StructError();
444
+ }
445
+ /**
446
+ * 리터럴 청크는 str 토큰, `${` 안은 일반 토큰으로 펼친다. 리터럴 텍스트의
447
+ * `function name` 은 정의가 되지 않고, interpolations 안의 선언만 구조로 본다.
448
+ *
449
+ * @param {ScanState} s - 여는 백틱 위치
450
+ * @param {Token[]} tokens - 출력
451
+ */
452
+ function scanTemplate(s, tokens) {
453
+ s.i += 1;
454
+ let value = '';
455
+ let chunkLine = s.line;
456
+ while (s.i < s.text.length) {
457
+ const c = s.text[s.i];
458
+ if (c === '\\') {
459
+ value += s.text[s.i + 1] || '';
460
+ s.i += 2;
461
+ continue;
462
+ }
463
+ if (c === '`') {
464
+ s.i += 1;
465
+ tokens.push({ kind: 'str', value, line: chunkLine });
466
+ return;
467
+ }
468
+ if (c === '$' && s.text[s.i + 1] === '{') {
469
+ tokens.push({ kind: 'str', value, line: chunkLine });
470
+ s.i += 2;
471
+ fillTokens(s, tokens, true);
472
+ value = '';
473
+ chunkLine = s.line;
474
+ continue;
475
+ }
476
+ if (c === '\n')
477
+ s.line += 1;
478
+ value += c;
479
+ s.i += 1;
480
+ }
481
+ throw new StructError();
482
+ }
483
+ function slashStartsRegex(prev) {
484
+ if (!prev)
485
+ return true;
486
+ if (prev.kind === 'num' || prev.kind === 'str')
487
+ return false;
488
+ if (prev.kind === 'id')
489
+ return REGEX_AFTER_KEYWORD.has(prev.value);
490
+ if (prev.kind === 'punct') {
491
+ // postfix ++/-- 뒤의 / 는 나눗셈이다. 정규식으로 삼키면 앞선 정의까지
492
+ // 파일 단위로 버려 다른 파일 후보가 유일한 source처럼 보인다.
493
+ if (prev.value === '++' || prev.value === '--')
494
+ return false;
495
+ return prev.value !== ')' && prev.value !== ']' && prev.value !== '}';
496
+ }
497
+ return true;
498
+ }
499
+ function scanRegex(s, line) {
500
+ s.i += 1;
501
+ let inClass = false;
502
+ while (s.i < s.text.length) {
503
+ const c = s.text[s.i];
504
+ if (c === '\\') {
505
+ s.i += 2;
506
+ continue;
507
+ }
508
+ if (c === '[' && !inClass)
509
+ inClass = true;
510
+ else if (c === ']' && inClass)
511
+ inClass = false;
512
+ else if (c === '/' && !inClass) {
513
+ s.i += 1;
514
+ while (s.i < s.text.length && /[A-Za-z]/.test(s.text[s.i]))
515
+ s.i += 1;
516
+ return { kind: 'str', value: '', line };
517
+ }
518
+ if (c === '\n')
519
+ throw new StructError();
520
+ s.i += 1;
521
+ }
522
+ throw new StructError();
523
+ }
524
+ function scanPunct(s, line) {
525
+ for (const len of [4, 3, 2]) {
526
+ const op = s.text.slice(s.i, s.i + len);
527
+ if (PUNCT_MULTI.has(op)) {
528
+ s.i += len;
529
+ return { kind: 'punct', value: op, line };
530
+ }
531
+ }
532
+ const v = s.text[s.i];
533
+ s.i += 1;
534
+ return { kind: 'punct', value: v, line };
535
+ }
536
+ /**
537
+ * 토큰 열에서 지원하는 정의만 모은다. 한 파일의 구조 오류는 호출 쪽이 이 파일만 비운다.
538
+ *
539
+ * @param {Token[]} tokens - tokenize 결과
540
+ * @param {string} symbol - 찾을 함수명
541
+ * @param {string} rel - 저장소 상대 posix 경로
542
+ * @returns {RawHit[]} 이 파일의 후보
543
+ */
544
+ function collectDefinitions(tokens, symbol, rel) {
545
+ return new Collector(tokens, symbol, rel).run();
546
+ }
547
+ class Collector {
548
+ tokens;
549
+ i = 0;
550
+ hits = [];
551
+ symbol;
552
+ rel;
553
+ lastLine = 1;
554
+ lastValue = '';
555
+ ambient = 0;
556
+ eof;
557
+ constructor(tokens, symbol, rel) {
558
+ this.tokens = tokens;
559
+ this.symbol = symbol;
560
+ this.rel = rel;
561
+ this.eof = tokens[tokens.length - 1] || { kind: 'eof', value: '', line: 1 };
562
+ }
563
+ /**
564
+ * 최상위 문만 걷고, 남은 `}` 는 이 파일의 불일치로 본다.
565
+ *
566
+ * @returns {RawHit[]} 수집된 정의
567
+ */
568
+ run() {
569
+ this.parseStmts([]);
570
+ if (this.peek().kind !== 'eof')
571
+ throw new StructError();
572
+ return this.hits;
573
+ }
574
+ peek(n = 0) {
575
+ return this.tokens[this.i + n] || this.eof;
576
+ }
577
+ peekKind(n = 0) {
578
+ return this.peek(n).kind;
579
+ }
580
+ peekValue(n = 0) {
581
+ return this.peek(n).value;
582
+ }
583
+ next() {
584
+ const t = this.peek();
585
+ if (t.kind !== 'eof') {
586
+ this.i += 1;
587
+ this.lastLine = t.line;
588
+ this.lastValue = t.value;
589
+ }
590
+ return t;
591
+ }
592
+ eat(value) {
593
+ if (this.peekValue() === value) {
594
+ this.next();
595
+ return true;
596
+ }
597
+ return false;
598
+ }
599
+ eatId(value) {
600
+ if (this.peekKind() === 'id' && this.peekValue() === value) {
601
+ this.next();
602
+ return true;
603
+ }
604
+ return false;
605
+ }
606
+ expect(value) {
607
+ if (this.peekValue() !== value)
608
+ throw new StructError();
609
+ return this.next();
610
+ }
611
+ startsStatement() {
612
+ if (this.isFunctionStart())
613
+ return true;
614
+ if (this.peekValue() === '{')
615
+ return true;
616
+ return this.peekKind() === 'id' && STMT_START_IDS.has(this.peekValue());
617
+ }
618
+ isFunctionStart() {
619
+ if (this.peekKind() === 'id' && this.peekValue() === 'function')
620
+ return true;
621
+ return this.peekValue() === 'async'
622
+ && this.peek(1).kind === 'id'
623
+ && this.peek(1).value === 'function';
624
+ }
625
+ isNameFollowedByCall(offset = 0) {
626
+ const n = this.peek(offset + 1).value;
627
+ return n === '(' || n === '<' || n === ':' || n === ',' || n === '}' || n === '='
628
+ || n === '?' || n === ';';
629
+ }
630
+ parseStmts(stack) {
631
+ while (this.peek().kind !== 'eof') {
632
+ const v = this.peekValue();
633
+ if (v === '}' || v === 'case' || v === 'default')
634
+ return;
635
+ this.parseStmt(stack);
636
+ }
637
+ }
638
+ parseBlock(stack) {
639
+ this.expect('{');
640
+ this.parseStmts(stack);
641
+ this.expect('}');
642
+ }
643
+ parseStmt(stack) {
644
+ while (this.eat(';')) { /* empty */ }
645
+ if (this.peek().kind === 'eof' || this.peekValue() === '}')
646
+ return;
647
+ this.skipDecorators();
648
+ if (this.peekValue() === '{') {
649
+ this.parseBlock(stack);
650
+ return;
651
+ }
652
+ if (this.eatId('export')) {
653
+ this.parseExport(stack);
654
+ return;
655
+ }
656
+ if (this.eatId('declare')) {
657
+ this.ambient += 1;
658
+ try {
659
+ this.parseStmt(stack);
660
+ }
661
+ finally {
662
+ this.ambient -= 1;
663
+ }
664
+ return;
665
+ }
666
+ if (this.isFunctionStart()) {
667
+ this.parseFunction({
668
+ exported: false,
669
+ expression: false,
670
+ stack,
671
+ startLine: this.peek().line,
672
+ });
673
+ return;
674
+ }
675
+ if (this.peekValue() === 'class' && this.peekKind() === 'id') {
676
+ this.parseClass(stack);
677
+ return;
678
+ }
679
+ if (this.peekValue() === 'const' || this.peekValue() === 'let' || this.peekValue() === 'var') {
680
+ this.parseLexical(this.peekValue(), stack);
681
+ return;
682
+ }
683
+ if (this.eatId('interface') || this.eatId('enum')) {
684
+ this.skipNamedBrace();
685
+ return;
686
+ }
687
+ if (this.eatId('type')) {
688
+ this.skipTypeAlias();
689
+ return;
690
+ }
691
+ if (this.peekValue() === 'namespace'
692
+ || (this.peekValue() === 'module' && this.peek(1).value !== '.')) {
693
+ this.next();
694
+ if (this.peekKind() === 'id' || this.peekKind() === 'str')
695
+ this.next();
696
+ if (this.peekValue() === '{')
697
+ this.parseBlock(stack);
698
+ else
699
+ this.skipToSemi();
700
+ return;
701
+ }
702
+ if (this.eatId('import')) {
703
+ if (this.peekValue() === '(')
704
+ this.parseExprUntil(new Set([';']), stack);
705
+ else
706
+ this.skipToSemi();
707
+ this.eat(';');
708
+ return;
709
+ }
710
+ if (this.isControlStart()) {
711
+ this.parseControl(stack);
712
+ return;
713
+ }
714
+ if (this.peekValue() === 'return' || this.peekValue() === 'throw' || this.peekValue() === 'yield') {
715
+ this.next();
716
+ if (this.peekValue() !== ';' && this.peekValue() !== '}' && this.peek().kind !== 'eof') {
717
+ this.parseExprUntil(new Set([';']), stack);
718
+ }
719
+ this.eat(';');
720
+ return;
721
+ }
722
+ if (this.peekValue() === 'break' || this.peekValue() === 'continue' || this.peekValue() === 'debugger') {
723
+ this.next();
724
+ if (this.peekKind() === 'id')
725
+ this.next();
726
+ this.eat(';');
727
+ return;
728
+ }
729
+ this.parseExprUntil(new Set([';']), stack);
730
+ this.eat(';');
731
+ }
732
+ isControlStart() {
733
+ const v = this.peekValue();
734
+ return v === 'if' || v === 'for' || v === 'while' || v === 'do' || v === 'switch'
735
+ || v === 'try' || v === 'with';
736
+ }
737
+ skipDecorators() {
738
+ while (this.eat('@')) {
739
+ if (this.peekKind() === 'id')
740
+ this.next();
741
+ if (this.peekValue() === '(') {
742
+ this.next();
743
+ this.parseExprUntil(new Set([')']), []);
744
+ this.expect(')');
745
+ }
746
+ }
747
+ }
748
+ /**
749
+ * export 다음만 분기한다. `export function` 은 exported-function 이고
750
+ * `export const` arrow 는 그대로 arrow-function 이다.
751
+ *
752
+ * @param {string[]} stack - 바깥 이름
753
+ */
754
+ parseExport(stack) {
755
+ const startLine = this.lastLine;
756
+ // `export { a }` 의 닫는 `}` 는 블록 끝이 아니다. 먼저 짝을 맞추지 않으면
757
+ // 상위 parseStmts 가 파일을 끝난 것으로 오해한다.
758
+ if (this.peekValue() === '{') {
759
+ this.skipBalanced('{', '}');
760
+ this.skipToSemi();
761
+ this.eat(';');
762
+ return;
763
+ }
764
+ if (this.eat('*')) {
765
+ this.skipToSemi();
766
+ this.eat(';');
767
+ return;
768
+ }
769
+ if (this.eatId('default')) {
770
+ if (this.isFunctionStart()) {
771
+ this.parseFunction({ exported: true, expression: false, stack, startLine });
772
+ return;
773
+ }
774
+ if (this.peekValue() === 'class') {
775
+ this.parseClass(stack);
776
+ return;
777
+ }
778
+ this.parseExprUntil(new Set([';']), stack);
779
+ this.eat(';');
780
+ return;
781
+ }
782
+ if (this.eatId('type') || this.eatId('interface') || this.eatId('enum')) {
783
+ this.skipNamedBrace();
784
+ return;
785
+ }
786
+ if (this.eat('=')) {
787
+ this.parseExprUntil(new Set([';']), stack);
788
+ this.eat(';');
789
+ return;
790
+ }
791
+ if (this.isFunctionStart()) {
792
+ this.parseFunction({ exported: true, expression: false, stack, startLine });
793
+ return;
794
+ }
795
+ this.parseStmt(stack);
796
+ }
797
+ skipNamedBrace() {
798
+ if (this.peekKind() === 'id')
799
+ this.next();
800
+ if (this.peekValue() === '<')
801
+ this.skipBalanced('<', '>');
802
+ if (this.peekValue() === '{')
803
+ this.skipBalanced('{', '}');
804
+ else
805
+ this.skipToSemi();
806
+ this.eat(';');
807
+ }
808
+ skipTypeAlias() {
809
+ if (this.peekKind() === 'id')
810
+ this.next();
811
+ if (this.peekValue() === '<')
812
+ this.skipBalanced('<', '>');
813
+ if (this.eat('='))
814
+ this.skipType();
815
+ this.eat(';');
816
+ }
817
+ skipToSemi() {
818
+ let depth = 0;
819
+ while (this.peek().kind !== 'eof') {
820
+ const v = this.peekValue();
821
+ if (depth === 0 && v === ';')
822
+ return;
823
+ if (v === '{' || v === '(' || v === '[')
824
+ depth += 1;
825
+ else if (v === '}' || v === ')' || v === ']') {
826
+ if (depth === 0)
827
+ return;
828
+ depth -= 1;
829
+ }
830
+ this.next();
831
+ }
832
+ }
833
+ parseControl(stack) {
834
+ const kw = this.peekValue();
835
+ this.next();
836
+ if (kw === 'do') {
837
+ this.parseStmt(stack);
838
+ this.eatId('while');
839
+ this.parseParens(stack);
840
+ this.eat(';');
841
+ return;
842
+ }
843
+ if (kw === 'try') {
844
+ this.parseBlock(stack);
845
+ if (this.eatId('catch')) {
846
+ if (this.peekValue() === '(')
847
+ this.parseParens(stack);
848
+ this.parseBlock(stack);
849
+ }
850
+ if (this.eatId('finally'))
851
+ this.parseBlock(stack);
852
+ return;
853
+ }
854
+ if (kw === 'switch') {
855
+ this.parseParens(stack);
856
+ this.expect('{');
857
+ while (this.peekValue() !== '}' && this.peek().kind !== 'eof') {
858
+ if (this.eatId('case')) {
859
+ this.parseExprUntil(new Set([':']), stack);
860
+ this.expect(':');
861
+ continue;
862
+ }
863
+ if (this.eatId('default')) {
864
+ this.expect(':');
865
+ continue;
866
+ }
867
+ this.parseStmt(stack);
868
+ }
869
+ this.expect('}');
870
+ return;
871
+ }
872
+ if (kw === 'for')
873
+ this.eatId('await');
874
+ if (this.peekValue() === '(')
875
+ this.parseParens(stack);
876
+ this.parseStmt(stack);
877
+ if (kw === 'if' && this.eatId('else'))
878
+ this.parseStmt(stack);
879
+ }
880
+ parseParens(stack) {
881
+ this.expect('(');
882
+ this.parseExprUntil(new Set([')']), stack);
883
+ this.expect(')');
884
+ }
885
+ /**
886
+ * const/let arrow 만 정본으로 모은다. var 와 `new Function` 할당은 런타임 생성이라
887
+ * 같은 이름이어도 정의가 아니다.
888
+ *
889
+ * @param {string} kind - const | let | var
890
+ * @param {string[]} stack - 바깥 이름
891
+ */
892
+ parseLexical(kind, stack) {
893
+ this.next();
894
+ while (true) {
895
+ if (this.peekValue() === '{' || this.peekValue() === '[') {
896
+ const open = this.peekValue();
897
+ this.skipBalanced(open, open === '{' ? '}' : ']');
898
+ if (this.eat('='))
899
+ this.parseExprUntil(new Set([',', ';']), stack);
900
+ }
901
+ else if (this.peekKind() === 'id') {
902
+ const nameTok = this.next();
903
+ const name = nameTok.value;
904
+ this.eat('?');
905
+ if (this.eat(':'))
906
+ this.skipType();
907
+ if (this.eat('=')) {
908
+ const nextStack = [...stack, name];
909
+ const arrow = kind !== 'var' && this.looksLikeArrow();
910
+ this.parseExprUntil(new Set([',', ';']), nextStack);
911
+ if (arrow && name === this.symbol && this.ambient === 0) {
912
+ this.hits.push({
913
+ path: this.rel,
914
+ qualified_name: nextStack.join('.'),
915
+ kind: 'arrow-function',
916
+ start_line: nameTok.line,
917
+ end_line: this.lastLine,
918
+ });
919
+ }
920
+ }
921
+ }
922
+ else {
923
+ throw new StructError();
924
+ }
925
+ if (!this.eat(','))
926
+ break;
927
+ }
928
+ this.eat(';');
929
+ }
930
+ /**
931
+ * 초기화식 앞에서만 화살표를 본다. 본문 검색이 아니라 `=>` 토큰이 있는 형태만
932
+ * const/let 정의로 승격한다.
933
+ *
934
+ * @returns {boolean} 다음에 화살표 함수가 오면 true
935
+ */
936
+ looksLikeArrow() {
937
+ const savedI = this.i;
938
+ const savedLine = this.lastLine;
939
+ const savedVal = this.lastValue;
940
+ try {
941
+ this.eatId('async');
942
+ if (this.peekValue() === '<')
943
+ this.skipBalanced('<', '>');
944
+ if (this.peekKind() === 'id') {
945
+ this.next();
946
+ return this.peekValue() === '=>';
947
+ }
948
+ if (this.peekValue() !== '(')
949
+ return false;
950
+ this.skipBalanced('(', ')');
951
+ if (this.eat(':'))
952
+ this.skipType();
953
+ return this.peekValue() === '=>';
954
+ }
955
+ catch (error) {
956
+ // 잘린 화살표 헤더는 이 바인딩을 정의로 올리지 않는다. 파일 전체 실패는
957
+ // 본문 parseExprUntil 이 같은 불일치를 다시 본다.
958
+ if (error instanceof StructError)
959
+ return false;
960
+ throw error;
961
+ }
962
+ finally {
963
+ this.i = savedI;
964
+ this.lastLine = savedLine;
965
+ this.lastValue = savedVal;
966
+ }
967
+ }
968
+ parseFunction(opts) {
969
+ this.eatId('async');
970
+ if (!this.eatId('function'))
971
+ throw new StructError();
972
+ this.eat('*');
973
+ let name = null;
974
+ if (this.peekKind() === 'id')
975
+ name = this.next().value;
976
+ if (this.peekValue() !== '(' && this.peekValue() !== '<')
977
+ throw new StructError();
978
+ if (this.peekValue() === '<')
979
+ this.skipBalanced('<', '>');
980
+ const nextStack = name ? [...opts.stack, name] : opts.stack;
981
+ this.parseParamList(nextStack);
982
+ if (this.eat(':'))
983
+ this.skipType();
984
+ if (this.eat('{')) {
985
+ this.parseStmts(nextStack);
986
+ const end = this.expect('}');
987
+ if (!opts.expression && !this.ambient && name === this.symbol) {
988
+ this.hits.push({
989
+ path: this.rel,
990
+ qualified_name: nextStack.join('.'),
991
+ kind: opts.exported ? 'exported-function' : 'function-declaration',
992
+ start_line: opts.startLine,
993
+ end_line: end.line,
994
+ });
995
+ }
996
+ return;
997
+ }
998
+ this.eat(';');
999
+ }
1000
+ parseParamList(stack) {
1001
+ this.expect('(');
1002
+ while (this.peekValue() !== ')' && this.peek().kind !== 'eof') {
1003
+ while (MEMBER_MODS.has(this.peekValue()))
1004
+ this.next();
1005
+ this.eat('...');
1006
+ if (this.peekValue() === '{' || this.peekValue() === '[') {
1007
+ const open = this.peekValue();
1008
+ this.skipBalanced(open, open === '{' ? '}' : ']');
1009
+ }
1010
+ else if (this.peekKind() === 'id' || this.peekKind() === 'str') {
1011
+ this.next();
1012
+ if (this.eat('.')) {
1013
+ if (this.peekKind() === 'id')
1014
+ this.next();
1015
+ }
1016
+ }
1017
+ this.eat('?');
1018
+ if (this.eat(':'))
1019
+ this.skipType();
1020
+ if (this.eat('='))
1021
+ this.parseExprUntil(new Set([',', ')']), stack);
1022
+ if (!this.eat(','))
1023
+ break;
1024
+ }
1025
+ this.expect(')');
1026
+ }
1027
+ parseClass(stack) {
1028
+ this.eatId('class');
1029
+ let className = null;
1030
+ if (this.peekKind() === 'id'
1031
+ && this.peekValue() !== 'extends'
1032
+ && this.peekValue() !== 'implements') {
1033
+ className = this.next().value;
1034
+ }
1035
+ if (this.peekValue() === '<')
1036
+ this.skipBalanced('<', '>');
1037
+ while (this.peekValue() === 'extends' || this.peekValue() === 'implements') {
1038
+ this.next();
1039
+ this.parseExprUntil(new Set(['{', 'extends', 'implements']), stack);
1040
+ }
1041
+ const nextStack = className ? [...stack, className] : stack;
1042
+ this.expect('{');
1043
+ this.parseMembers(nextStack, 'class');
1044
+ this.expect('}');
1045
+ }
1046
+ parseObject(stack) {
1047
+ this.expect('{');
1048
+ this.parseMembers(stack, 'object');
1049
+ this.expect('}');
1050
+ }
1051
+ /**
1052
+ * 클래스·객체 멤버를 같은 헤더 규칙으로 읽는다. 계산된 이름·getter·constructor·
1053
+ * 필드 화살표는 지원 형태가 아니라서 본문만 걷고 후보로 올리지 않는다.
1054
+ *
1055
+ * @param {string[]} stack - 바깥 이름
1056
+ * @param {'class' | 'object'} kind - 멤버 kind
1057
+ */
1058
+ parseMembers(stack, kind) {
1059
+ while (this.peekValue() !== '}' && this.peek().kind !== 'eof') {
1060
+ while (this.eat(',') || this.eat(';')) { /* empty */ }
1061
+ if (this.peekValue() === '}')
1062
+ return;
1063
+ this.skipDecorators();
1064
+ if (kind === 'class' && this.peekValue() === 'static' && this.peek(1).value === '{') {
1065
+ this.next();
1066
+ this.parseBlock(stack);
1067
+ continue;
1068
+ }
1069
+ if (this.eat('...')) {
1070
+ this.parseExprUntil(new Set([',', '}']), stack);
1071
+ continue;
1072
+ }
1073
+ const startLine = this.peek().line;
1074
+ let isGetSet = false;
1075
+ let privateName = false;
1076
+ while (true) {
1077
+ const v = this.peekValue();
1078
+ if (v === '*' || v === '#') {
1079
+ if (v === '#')
1080
+ privateName = true;
1081
+ this.next();
1082
+ continue;
1083
+ }
1084
+ if (this.peekKind() === 'id' && MEMBER_MODS.has(v) && !this.isNameFollowedByCall()) {
1085
+ this.next();
1086
+ continue;
1087
+ }
1088
+ if (this.peekKind() === 'id'
1089
+ && (v === 'get' || v === 'set')
1090
+ && !this.isNameFollowedByCall()) {
1091
+ isGetSet = true;
1092
+ this.next();
1093
+ continue;
1094
+ }
1095
+ break;
1096
+ }
1097
+ let computed = false;
1098
+ let name = null;
1099
+ if (this.peekValue() === '[') {
1100
+ computed = true;
1101
+ this.skipBalanced('[', ']');
1102
+ }
1103
+ else if (this.peekKind() === 'id' || this.peekKind() === 'str' || this.peekKind() === 'num') {
1104
+ name = this.next().value;
1105
+ }
1106
+ else {
1107
+ throw new StructError();
1108
+ }
1109
+ if (this.peekValue() === '<')
1110
+ this.skipBalanced('<', '>');
1111
+ this.eat('?');
1112
+ const nextStack = name ? [...stack, name] : stack;
1113
+ if (this.peekValue() === '(') {
1114
+ this.parseParamList(nextStack);
1115
+ if (this.eat(':'))
1116
+ this.skipType();
1117
+ let endLine = this.lastLine;
1118
+ let hasBody = false;
1119
+ if (this.eat('{')) {
1120
+ hasBody = true;
1121
+ this.parseStmts(nextStack);
1122
+ endLine = this.expect('}').line;
1123
+ }
1124
+ else {
1125
+ this.eat(';');
1126
+ }
1127
+ const skipCtor = kind === 'class' && name === 'constructor';
1128
+ if (hasBody && !computed && !isGetSet && !privateName && !skipCtor
1129
+ && this.ambient === 0 && name === this.symbol) {
1130
+ this.hits.push({
1131
+ path: this.rel,
1132
+ qualified_name: nextStack.join('.'),
1133
+ kind: kind === 'class' ? 'class-method' : 'object-method',
1134
+ start_line: startLine,
1135
+ end_line: endLine,
1136
+ });
1137
+ }
1138
+ continue;
1139
+ }
1140
+ if (this.eat(':')) {
1141
+ const asMethod = this.looksLikeArrow() || this.isFunctionStart();
1142
+ this.parseExprUntil(new Set([',', '}']), nextStack);
1143
+ if (kind === 'object' && asMethod && !computed && !isGetSet && this.ambient === 0
1144
+ && name === this.symbol) {
1145
+ this.hits.push({
1146
+ path: this.rel,
1147
+ qualified_name: nextStack.join('.'),
1148
+ kind: 'object-method',
1149
+ start_line: startLine,
1150
+ end_line: this.lastLine,
1151
+ });
1152
+ }
1153
+ continue;
1154
+ }
1155
+ if (this.eat('=')) {
1156
+ this.parseExprUntil(new Set([',', ';', '}']), nextStack);
1157
+ this.eat(';');
1158
+ continue;
1159
+ }
1160
+ }
1161
+ }
1162
+ /**
1163
+ * 식만 걷는다. `{` 는 객체, `=> {` 는 블록이다. 개행 뒤 문 시작 키워드는
1164
+ * 세미콜론 삽입으로 보고 식을 끊어서 `function` 선언을 식 안으로 삼키지 않는다.
1165
+ *
1166
+ * @param {Set<string>} stops - 깊이 0에서 남길 종결 토큰
1167
+ * @param {string[]} stack - 바깥 이름
1168
+ */
1169
+ parseExprUntil(stops, stack) {
1170
+ let saw = false;
1171
+ while (this.peek().kind !== 'eof') {
1172
+ const t = this.peek();
1173
+ if (stops.has(t.value))
1174
+ return;
1175
+ // `{` 는 parseObject 가 짝을 맞추므로, 여기에 남은 `}` 는 바깥 본문 닫힘이다.
1176
+ // 세미콜론이 없어도 본문 `}` 를 식에 삼키면 파일 전체가 unparseable 이 된다.
1177
+ if (t.value === '}')
1178
+ return;
1179
+ if (saw && t.line > this.lastLine && !EXPR_CONTINUES.has(this.lastValue)
1180
+ && this.startsStatement()) {
1181
+ return;
1182
+ }
1183
+ if (t.value === '=>') {
1184
+ this.next();
1185
+ if (this.peekValue() === '{')
1186
+ this.parseBlock(stack);
1187
+ saw = true;
1188
+ continue;
1189
+ }
1190
+ if (this.isFunctionStart()) {
1191
+ this.parseFunction({
1192
+ exported: false,
1193
+ expression: true,
1194
+ stack,
1195
+ startLine: t.line,
1196
+ });
1197
+ saw = true;
1198
+ continue;
1199
+ }
1200
+ if (t.kind === 'id' && t.value === 'class') {
1201
+ this.parseClass(stack);
1202
+ saw = true;
1203
+ continue;
1204
+ }
1205
+ if (t.value === '{') {
1206
+ this.parseObject(stack);
1207
+ saw = true;
1208
+ continue;
1209
+ }
1210
+ if (t.value === '(') {
1211
+ this.next();
1212
+ this.parseExprUntil(new Set([')']), stack);
1213
+ this.expect(')');
1214
+ saw = true;
1215
+ continue;
1216
+ }
1217
+ if (t.value === '[') {
1218
+ this.next();
1219
+ this.parseExprUntil(new Set([']']), stack);
1220
+ this.expect(']');
1221
+ saw = true;
1222
+ continue;
1223
+ }
1224
+ this.next();
1225
+ saw = true;
1226
+ }
1227
+ }
1228
+ skipBalanced(open, close) {
1229
+ this.expect(open);
1230
+ let depth = 1;
1231
+ while (depth > 0) {
1232
+ const t = this.peek();
1233
+ if (t.kind === 'eof')
1234
+ throw new StructError();
1235
+ if (t.value === open)
1236
+ depth += 1;
1237
+ else if (t.value === close)
1238
+ depth -= 1;
1239
+ this.next();
1240
+ }
1241
+ }
1242
+ /**
1243
+ * 타입 자리는 정의가 아니므로 토큰만 건너뛴다. 함수 본문 `{` 는 호출 쪽이
1244
+ * 남겨 두도록, 이미 한 원자를 본 뒤의 `{` 는 객체 타입으로 삼키지 않는다.
1245
+ */
1246
+ skipType() {
1247
+ this.skipTypePrefix();
1248
+ this.skipTypeAtom();
1249
+ // 반환 타입의 type predicate(`v is T`, `this is T`). `asserts` 접두사는
1250
+ // skipTypePrefix가 이미 먹는다. 원자만 건너뛰면 남는 `is`를 본문 `{`로
1251
+ // 오인해 파일 전체를 버리므로, 여기서 `is`와 오른쪽 타입을 이어서 삼킨다.
1252
+ // 값 식·문자열의 `is`는 skipType 밖이라 정의로 승격되지 않는다.
1253
+ if (this.eatId('is'))
1254
+ this.skipType();
1255
+ for (;;) {
1256
+ if (this.eat('|') || this.eat('&')) {
1257
+ this.skipTypeAtom();
1258
+ continue;
1259
+ }
1260
+ if (this.peekValue() === '[') {
1261
+ this.skipBalanced('[', ']');
1262
+ continue;
1263
+ }
1264
+ if (this.eatId('extends')) {
1265
+ this.skipType();
1266
+ if (this.eat('?')) {
1267
+ this.skipType();
1268
+ if (this.eat(':'))
1269
+ this.skipType();
1270
+ }
1271
+ continue;
1272
+ }
1273
+ break;
1274
+ }
1275
+ }
1276
+ skipTypePrefix() {
1277
+ while (this.eatId('readonly') || this.eatId('unique') || this.eatId('typeof')
1278
+ || this.eatId('keyof') || this.eatId('infer') || this.eatId('asserts')
1279
+ || this.eatId('new') || this.eatId('abstract')) { /* empty */ }
1280
+ }
1281
+ skipTypeAtom() {
1282
+ if (this.peekValue() === '{') {
1283
+ this.skipBalanced('{', '}');
1284
+ return;
1285
+ }
1286
+ if (this.peekValue() === '[') {
1287
+ this.skipBalanced('[', ']');
1288
+ return;
1289
+ }
1290
+ if (this.peekValue() === '(') {
1291
+ this.skipBalanced('(', ')');
1292
+ if (this.eat('=>'))
1293
+ this.skipType();
1294
+ return;
1295
+ }
1296
+ if (this.peekValue() === '<') {
1297
+ this.skipBalanced('<', '>');
1298
+ this.skipTypeAtom();
1299
+ return;
1300
+ }
1301
+ if (this.peekKind() === 'id' || this.peekKind() === 'str' || this.peekKind() === 'num') {
1302
+ this.next();
1303
+ while (this.eat('.')) {
1304
+ if (this.peekKind() === 'id')
1305
+ this.next();
1306
+ else
1307
+ break;
1308
+ }
1309
+ if (this.peekValue() === '<')
1310
+ this.skipBalanced('<', '>');
1311
+ if (this.peekValue() === '(')
1312
+ this.skipBalanced('(', ')');
1313
+ // `=>` 는 괄호 함수 타입 `(x) => y` 만 이어 붙인다. 식별자 뒤 `=>` 는
1314
+ // `const f: number = () =>` 의 값 화살표이므로 여기서 삼키지 않는다.
1315
+ }
1316
+ }
1317
+ }
1318
+ /**
1319
+ * generated/test/vendor를 먼저 적용하고 남은 것만 source로 둔다.
1320
+ * 확장자나 `src` 경로만으로 source를 단정하지 않는다. package dist와 legacy
1321
+ * scripts/lib emit은 대응 source가 없어도 generated다.
1322
+ *
1323
+ * @param {string} relPosix - 저장소 상대 posix 경로
1324
+ * @returns {SymbolRole} vendor, test, generated, source 중 하나
1325
+ */
1326
+ function classifyRole(relPosix) {
1327
+ if (hasDirPrefix(relPosix, 'vendor')
1328
+ || hasDirPrefix(relPosix, 'packages/cli/vendor')
1329
+ || hasDirPrefix(relPosix, 'scripts/vendor')
1330
+ || hasDirPrefix(relPosix, 'node_modules')) {
1331
+ return 'vendor';
1332
+ }
1333
+ if (hasDirPrefix(relPosix, 'test') || hasDirPrefix(relPosix, 'tests') || isTestFileName(relPosix)) {
1334
+ return 'test';
1335
+ }
1336
+ if (hasDirPrefix(relPosix, 'packages/cli/dist') || hasDirPrefix(relPosix, 'scripts/lib')) {
1337
+ return 'generated';
1338
+ }
1339
+ return 'source';
1340
+ }
1341
+ function hasDirPrefix(relPosix, dir) {
1342
+ return relPosix === dir || relPosix.startsWith(`${dir}/`);
1343
+ }
1344
+ function isTestFileName(relPosix) {
1345
+ const base = relPosix.split('/').pop() || '';
1346
+ return /\.(?:test|spec)\./.test(base);
1347
+ }
1348
+ /**
1349
+ * path를 재선택 계약으로 노출하지 않는 안정 ref. 같은 현재 좌표만 같은 hex가 된다.
1350
+ *
1351
+ * @param {SymbolRef} symbolRef - path·이름·kind·line 좌표
1352
+ * @returns {string} slash가 없는 sha256 hex
1353
+ */
1354
+ function makeCandidateRef(symbolRef) {
1355
+ const payload = [
1356
+ symbolRef.path,
1357
+ symbolRef.qualified_name,
1358
+ symbolRef.kind,
1359
+ String(symbolRef.start_line),
1360
+ String(symbolRef.end_line),
1361
+ ].join('\0');
1362
+ return createHash('sha256').update(payload).digest('hex');
1363
+ }
1364
+ /**
1365
+ * `git hash-object`와 같은 blob SHA. `-w`를 쓰지 않고 직접 해시해 객체 DB를
1366
+ * 건드리지 않는다.
1367
+ *
1368
+ * @param {Buffer} body - 워킹트리 바이트
1369
+ * @returns {string} 40자리 hex
1370
+ */
1371
+ function gitBlobSha(body) {
1372
+ const header = Buffer.from(`blob ${body.length}\0`);
1373
+ return createHash('sha1').update(header).update(body).digest('hex');
1374
+ }
1375
+ function compareCandidates(left, right) {
1376
+ if (left.symbol_ref.path !== right.symbol_ref.path) {
1377
+ return left.symbol_ref.path < right.symbol_ref.path ? -1 : 1;
1378
+ }
1379
+ if (left.symbol_ref.start_line !== right.symbol_ref.start_line) {
1380
+ return left.symbol_ref.start_line - right.symbol_ref.start_line;
1381
+ }
1382
+ if (left.symbol_ref.qualified_name !== right.symbol_ref.qualified_name) {
1383
+ return left.symbol_ref.qualified_name < right.symbol_ref.qualified_name ? -1 : 1;
1384
+ }
1385
+ if (left.symbol_ref.kind !== right.symbol_ref.kind) {
1386
+ return left.symbol_ref.kind < right.symbol_ref.kind ? -1 : 1;
1387
+ }
1388
+ return 0;
1389
+ }
1390
+ module.exports = { resolveSymbol };