isthmus-cli 0.5.0 → 0.7.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 (146) hide show
  1. package/README.ko.md +170 -45
  2. package/README.md +178 -36
  3. package/Skills/isthmus/SKILL.md +104 -5
  4. package/compatibility.json +10 -0
  5. package/dist/cli/check-command.d.ts +2 -2
  6. package/dist/cli/check-command.js +22 -9
  7. package/dist/cli/check-command.js.map +1 -1
  8. package/dist/cli/command-support.d.ts +20 -2
  9. package/dist/cli/command-support.js +38 -9
  10. package/dist/cli/command-support.js.map +1 -1
  11. package/dist/cli/diff-command.js +4 -3
  12. package/dist/cli/diff-command.js.map +1 -1
  13. package/dist/cli/doctor-command.d.ts +11 -0
  14. package/dist/cli/doctor-command.js +128 -0
  15. package/dist/cli/doctor-command.js.map +1 -0
  16. package/dist/cli/extract-js-command.d.ts +33 -0
  17. package/dist/cli/extract-js-command.js +233 -0
  18. package/dist/cli/extract-js-command.js.map +1 -0
  19. package/dist/cli/graph-command.js +13 -6
  20. package/dist/cli/graph-command.js.map +1 -1
  21. package/dist/cli/impact-command.d.ts +5 -0
  22. package/dist/cli/impact-command.js +111 -0
  23. package/dist/cli/impact-command.js.map +1 -0
  24. package/dist/cli/init-command.d.ts +11 -0
  25. package/dist/cli/init-command.js +115 -0
  26. package/dist/cli/init-command.js.map +1 -0
  27. package/dist/cli/main.js +67 -1
  28. package/dist/cli/main.js.map +1 -1
  29. package/dist/cli/mcp-server.d.ts +16 -0
  30. package/dist/cli/mcp-server.js +534 -0
  31. package/dist/cli/mcp-server.js.map +1 -0
  32. package/dist/cli/preflight-command.d.ts +5 -0
  33. package/dist/cli/preflight-command.js +83 -0
  34. package/dist/cli/preflight-command.js.map +1 -0
  35. package/dist/cli/query-command.js +13 -6
  36. package/dist/cli/query-command.js.map +1 -1
  37. package/dist/cli/retentions-command.js +14 -7
  38. package/dist/cli/retentions-command.js.map +1 -1
  39. package/dist/cli/runtime-command.d.ts +5 -0
  40. package/dist/cli/runtime-command.js +42 -0
  41. package/dist/cli/runtime-command.js.map +1 -0
  42. package/dist/cli/runtime-json-reader.d.ts +12 -0
  43. package/dist/cli/runtime-json-reader.js +37 -0
  44. package/dist/cli/runtime-json-reader.js.map +1 -0
  45. package/dist/cli/serve-command.d.ts +13 -0
  46. package/dist/cli/serve-command.js +43 -0
  47. package/dist/cli/serve-command.js.map +1 -0
  48. package/dist/exchange/capture-config.d.ts +18 -0
  49. package/dist/exchange/capture-config.js +100 -0
  50. package/dist/exchange/capture-config.js.map +1 -0
  51. package/dist/exchange/impact-selection.d.ts +13 -0
  52. package/dist/exchange/impact-selection.js +35 -0
  53. package/dist/exchange/impact-selection.js.map +1 -0
  54. package/dist/exchange/kartograph-impact.d.ts +4 -0
  55. package/dist/exchange/kartograph-impact.js +169 -0
  56. package/dist/exchange/kartograph-impact.js.map +1 -0
  57. package/dist/exchange/messages.d.ts +36 -0
  58. package/dist/exchange/messages.js +96 -0
  59. package/dist/exchange/messages.js.map +1 -0
  60. package/dist/exchange/parse.d.ts +69 -0
  61. package/dist/exchange/parse.js +127 -20
  62. package/dist/exchange/parse.js.map +1 -1
  63. package/dist/exchange/preflight-context.d.ts +71 -0
  64. package/dist/exchange/preflight-context.js +317 -0
  65. package/dist/exchange/preflight-context.js.map +1 -0
  66. package/dist/exchange/producer-impact.d.ts +17 -0
  67. package/dist/exchange/producer-impact.js +226 -0
  68. package/dist/exchange/producer-impact.js.map +1 -0
  69. package/dist/exchange/runtime.d.ts +69 -0
  70. package/dist/exchange/runtime.js +160 -0
  71. package/dist/exchange/runtime.js.map +1 -0
  72. package/dist/extract/js-document.d.ts +22 -0
  73. package/dist/extract/js-document.js +230 -0
  74. package/dist/extract/js-document.js.map +1 -0
  75. package/dist/extract/js-scan.d.ts +93 -0
  76. package/dist/extract/js-scan.js +1205 -0
  77. package/dist/extract/js-scan.js.map +1 -0
  78. package/dist/extract/lexer.d.ts +26 -0
  79. package/dist/extract/lexer.js +316 -0
  80. package/dist/extract/lexer.js.map +1 -0
  81. package/dist/join/join.d.ts +66 -1
  82. package/dist/join/join.js +171 -1
  83. package/dist/join/join.js.map +1 -1
  84. package/dist/join/message-address.d.ts +7 -0
  85. package/dist/join/message-address.js +37 -0
  86. package/dist/join/message-address.js.map +1 -0
  87. package/dist/join/messages.d.ts +26 -0
  88. package/dist/join/messages.js +87 -0
  89. package/dist/join/messages.js.map +1 -0
  90. package/dist/report/check-report.d.ts +20 -3
  91. package/dist/report/check-report.js +240 -8
  92. package/dist/report/check-report.js.map +1 -1
  93. package/dist/report/codequality.d.ts +45 -0
  94. package/dist/report/codequality.js +74 -0
  95. package/dist/report/codequality.js.map +1 -0
  96. package/dist/report/diff.d.ts +25 -3
  97. package/dist/report/diff.js +91 -10
  98. package/dist/report/diff.js.map +1 -1
  99. package/dist/report/graph.d.ts +10 -4
  100. package/dist/report/graph.js +60 -6
  101. package/dist/report/graph.js.map +1 -1
  102. package/dist/report/impact.d.ts +62 -0
  103. package/dist/report/impact.js +168 -0
  104. package/dist/report/impact.js.map +1 -0
  105. package/dist/report/preflight-runtime.d.ts +42 -0
  106. package/dist/report/preflight-runtime.js +193 -0
  107. package/dist/report/preflight-runtime.js.map +1 -0
  108. package/dist/report/preflight-view.d.ts +146 -0
  109. package/dist/report/preflight-view.js +216 -0
  110. package/dist/report/preflight-view.js.map +1 -0
  111. package/dist/report/preflight.d.ts +104 -0
  112. package/dist/report/preflight.js +331 -0
  113. package/dist/report/preflight.js.map +1 -0
  114. package/dist/report/query.d.ts +11 -4
  115. package/dist/report/query.js +84 -17
  116. package/dist/report/query.js.map +1 -1
  117. package/dist/report/retentions.d.ts +17 -5
  118. package/dist/report/retentions.js +85 -8
  119. package/dist/report/retentions.js.map +1 -1
  120. package/dist/report/rules.d.ts +16 -0
  121. package/dist/report/rules.js +30 -0
  122. package/dist/report/rules.js.map +1 -0
  123. package/dist/report/runtime-impact.d.ts +38 -0
  124. package/dist/report/runtime-impact.js +94 -0
  125. package/dist/report/runtime-impact.js.map +1 -0
  126. package/dist/report/runtime.d.ts +54 -0
  127. package/dist/report/runtime.js +137 -0
  128. package/dist/report/runtime.js.map +1 -0
  129. package/dist/report/sarif.d.ts +2 -8
  130. package/dist/report/sarif.js +2 -10
  131. package/dist/report/sarif.js.map +1 -1
  132. package/dist/report/sorted-json.d.ts +1 -1
  133. package/dist/report/sorted-json.js +2 -2
  134. package/dist/report/sorted-json.js.map +1 -1
  135. package/docs/BRIDGE-EVENTS.md +87 -0
  136. package/docs/BRIDGE-MESSAGES.md +117 -0
  137. package/docs/GRAPH-EXCHANGE.md +382 -0
  138. package/docs/IMPACT.md +96 -0
  139. package/docs/MCP.md +62 -0
  140. package/docs/PREFLIGHT.md +311 -0
  141. package/docs/RUNTIME.md +172 -0
  142. package/docs/TOOLCHAIN.md +101 -0
  143. package/package.json +14 -2
  144. package/scripts/build-preflight-toolchain.mjs +186 -0
  145. package/scripts/capture-preflight.mjs +380 -0
  146. package/scripts/run-child.mjs +17 -0
@@ -0,0 +1,1205 @@
1
+ /**
2
+ * extract-js의 파일 한 개 스캐너다.
3
+ *
4
+ * 토큰 열 위에서 코어 RN과 Expo Modules의 브리지 호출 형태를 찾는다.
5
+ * 모듈·컴포넌트 이름 경계 사실은 즉시 확정하고, 멤버 호출(`M.foo()`)은
6
+ * 바인딩 정보와 함께 문서 조립 단계로 넘겨 같은 파일·가져오기 해석을 거친다.
7
+ *
8
+ * 이 스캐너는 이름을 정적으로 확정할 수 있는 형태만 fact로 낸다. 비리터럴
9
+ * 인자는 `dynamic: true`에 원문 표현식을 실어 한계 계수에 맡긴다.
10
+ */
11
+ import { isSafeNonEmptyString } from "../exchange/parse.js";
12
+ import { tokenizeJsSource } from "./lexer.js";
13
+ /** `requireNativeModule` 계열 — 모듈을 부르는 호출 측 API 이름이다. */
14
+ const moduleImportCalls = new Set([
15
+ 'requireNativeModule',
16
+ 'requireOptionalNativeModule',
17
+ ]);
18
+ /** 모듈 부재를 허용하는 호출 측 API다 — 부재 시 던지지 않고 `null`을 돌려준다. */
19
+ const optionalModuleCalls = new Set(['requireOptionalNativeModule']);
20
+ /** TurboModuleRegistry의 이름 인자를 받는 조회 메서드다. */
21
+ const turboRegistryMethods = new Set(['get', 'getEnforcing', 'getNullable']);
22
+ /** 부재 시 `null`을 돌려주는 레지스트리 조회다 — `getEnforcing`만 던진다. */
23
+ const optionalRegistryMethods = new Set(['get', 'getNullable']);
24
+ /** 컴포넌트를 요구하는 호출 측 API 이름이다. */
25
+ const componentRequireCalls = new Set([
26
+ 'requireNativeComponent',
27
+ 'codegenNativeComponent',
28
+ 'requireNativeViewManager',
29
+ ]);
30
+ /**
31
+ * Expo SDK(`expo`·`expo-modules-core`)에만 있는 호출 측 API 이름이다.
32
+ * 코어 RN에는 같은 이름의 진입점이 없어 이름 자체가 Expo 증거다.
33
+ */
34
+ const expoApiCalls = new Set([
35
+ 'requireNativeModule',
36
+ 'requireOptionalNativeModule',
37
+ 'requireNativeViewManager',
38
+ ]);
39
+ /** Expo API를 실어 나르는 패키지 specifier다. */
40
+ const expoSpecifierPattern = /^expo(?:-modules-core)?(?:\/|$)/u;
41
+ /** 한 소스 텍스트를 스캔해 사실 후보·바인딩·계수를 모은다. */
42
+ export function scanJsSource(source) {
43
+ const tokens = tokenizeJsSource(source);
44
+ const context = {
45
+ source,
46
+ tokens,
47
+ facts: [],
48
+ memberCalls: [],
49
+ bindings: new Map(),
50
+ constStrings: new Map(),
51
+ imports: [],
52
+ namedExports: new Map(),
53
+ reexports: new Map(),
54
+ counts: {
55
+ dynamicModuleNames: 0,
56
+ dynamicComponentNames: 0,
57
+ dynamicMethodNames: 0,
58
+ },
59
+ paramShadows: collectParamShadows(tokens),
60
+ localNames: new Set(),
61
+ requireSpecifiers: new Map(),
62
+ unstableAliasNames: new Set(tokens.filter((token, index) => token.kind === 'identifier' && tokens[index - 1]?.text !== 'as' &&
63
+ (!['(', '<'].includes(tokens[index + 1]?.text ?? '') ||
64
+ ['function', '*'].includes(tokens[index - 1]?.text ?? '')))
65
+ .map((token) => token.text)),
66
+ };
67
+ collectBindings(context);
68
+ collectCalls(context);
69
+ return {
70
+ facts: context.facts,
71
+ memberCalls: context.memberCalls,
72
+ bindings: context.bindings,
73
+ constStrings: context.constStrings,
74
+ imports: context.imports,
75
+ exports: {
76
+ ...(context.defaultExport === undefined
77
+ ? {}
78
+ : { defaultName: context.defaultExport }),
79
+ named: context.namedExports,
80
+ reexported: context.reexports,
81
+ },
82
+ counts: context.counts,
83
+ };
84
+ }
85
+ /**
86
+ * 첫 번째 순회: 바인딩·상수·import·export를 모은다.
87
+ *
88
+ * 호출 해석보다 선언 수집을 먼저 끝내야 선언보다 앞선 호출 위치도 같은
89
+ * 바인딩으로 해석된다(TDZ 오류 코드는 실행 시 죽으므로 정적 탐지엔 무해하다).
90
+ */
91
+ function collectBindings(context) {
92
+ const { tokens } = context;
93
+ for (let index = 0; index < tokens.length; index++) {
94
+ const token = tokens[index];
95
+ if (token.kind === 'keyword' && token.text === 'import') {
96
+ index = collectImport(context, index);
97
+ continue;
98
+ }
99
+ if (token.kind === 'keyword' && token.text === 'export') {
100
+ index = collectExport(context, index);
101
+ continue;
102
+ }
103
+ if (token.kind === 'keyword' &&
104
+ (token.text === 'const' || token.text === 'let' || token.text === 'var')) {
105
+ index = collectDeclaration(context, index);
106
+ continue;
107
+ }
108
+ // `function NAME`·`class NAME` 선언 — 본문도 계속 스캔한다.
109
+ if (token.kind === 'keyword' &&
110
+ (token.text === 'function' || token.text === 'class')) {
111
+ let cursor = index + 1;
112
+ if (tokens[cursor]?.text === '*')
113
+ cursor++;
114
+ const name = tokens[cursor];
115
+ if (name !== undefined &&
116
+ (name.kind === 'identifier' || name.kind === 'keyword')) {
117
+ context.localNames.add(name.text);
118
+ }
119
+ continue;
120
+ }
121
+ // 모듈 객체의 재대입은 바인딩을 무효화한다 — 해석 이름이 바뀌었을 수 있다.
122
+ // `obj.prop = v`의 prop은 로컬 바인딩이 아니라 속성명이므로 제외한다.
123
+ if (token.kind === 'identifier' && tokens[index + 1]?.text === '=' &&
124
+ tokens[index - 1]?.text !== '.' && tokens[index - 1]?.text !== '?.') {
125
+ context.bindings.delete(token.text);
126
+ context.constStrings.delete(token.text);
127
+ }
128
+ // CommonJS `module.exports = <expr>`는 default export와 같다.
129
+ if (token.kind === 'identifier' && token.text === 'module' &&
130
+ tokens[index + 1]?.text === '.' &&
131
+ tokens[index + 2]?.text === 'exports' &&
132
+ tokens[index + 3]?.text === '=') {
133
+ const bound = readModuleExpression(context, index + 4);
134
+ if (bound !== undefined && endsExpression(tokens, bound.endIndex)) {
135
+ context.defaultExport = bound.value;
136
+ index = bound.endIndex;
137
+ }
138
+ continue;
139
+ }
140
+ // `exports.X = <expr>`는 named export다.
141
+ if (token.kind === 'identifier' && token.text === 'exports' &&
142
+ tokens[index + 1]?.text === '.' &&
143
+ tokens[index + 2]?.kind === 'identifier' &&
144
+ tokens[index + 3]?.text === '=') {
145
+ const bound = readModuleExpression(context, index + 4);
146
+ if (bound !== undefined && endsExpression(tokens, bound.endIndex)) {
147
+ context.namedExports.set(tokens[index + 2].text, bound.value);
148
+ index = bound.endIndex;
149
+ }
150
+ }
151
+ }
152
+ }
153
+ /** `import ... from 'spec'` 절의 로컬 바인딩을 기록한다. */
154
+ function collectImport(context, start) {
155
+ const { tokens } = context;
156
+ let index = start + 1;
157
+ const clause = [];
158
+ while (index < tokens.length) {
159
+ const token = tokens[index];
160
+ // `import type ...`는 문 전체가 타입 전용 — 런타임 바인딩이 아니다.
161
+ // 키워드만 건너뛰면 절 이름이 그대로 수집되므로 specifier까지 건너뛴다.
162
+ if (token.text === 'type' && index === start + 1) {
163
+ let cursor = index + 1;
164
+ while (cursor < tokens.length &&
165
+ tokens[cursor].kind !== 'string' && tokens[cursor].text !== ';') {
166
+ cursor++;
167
+ }
168
+ return cursor;
169
+ }
170
+ // `import { type A, B }`의 인라인 타입 지정은 그 이름만 바인딩에서 뺀다.
171
+ if (token.text === 'type' &&
172
+ inBraceClause(tokens, start + 1, index)) {
173
+ const after = tokens[index + 1];
174
+ if ((after?.kind === 'identifier' || after?.kind === 'keyword') &&
175
+ after.text !== 'as') {
176
+ index += 2;
177
+ continue;
178
+ }
179
+ }
180
+ if (token.kind === 'string') {
181
+ // `import 'spec'` 부작용 전용이거나 절 뒤의 specifier다.
182
+ if (clause.length > 0)
183
+ return recordImport(context, clause, token, index);
184
+ return index;
185
+ }
186
+ if (token.text === 'from') {
187
+ const specifier = tokens[index + 1];
188
+ return specifier?.kind === 'string'
189
+ ? recordImport(context, clause, specifier, index + 1)
190
+ : index;
191
+ }
192
+ if (token.text === 'as') {
193
+ const alias = tokens[index + 1];
194
+ const last = clause.at(-1);
195
+ if (last !== undefined &&
196
+ (alias?.kind === 'identifier' || alias?.kind === 'keyword')) {
197
+ clause[clause.length - 1] = { name: alias.text, exported: last.exported };
198
+ }
199
+ index += 2;
200
+ continue;
201
+ }
202
+ if (token.kind === 'identifier' || token.kind === 'keyword') {
203
+ // 중괄호 안이면 named export, 아니면 default다.
204
+ clause.push({
205
+ name: token.text,
206
+ exported: inBraceClause(tokens, start + 1, index)
207
+ ? token.text
208
+ : 'default',
209
+ });
210
+ index++;
211
+ continue;
212
+ }
213
+ index++;
214
+ }
215
+ return index;
216
+ }
217
+ /** 수집한 import 절을 specifier와 함께 기록한다. */
218
+ function recordImport(context, clause, specifier, endIndex) {
219
+ for (const entry of clause) {
220
+ context.imports.push({
221
+ localName: entry.name,
222
+ specifier: specifier.value ?? specifier.text,
223
+ exportedName: entry.exported,
224
+ token: specifier,
225
+ });
226
+ }
227
+ return endIndex;
228
+ }
229
+ /** 토큰이 `{` 절 안에 있는지 판정한다(named import 구분용). */
230
+ function inBraceClause(tokens, from, to) {
231
+ let depth = 0;
232
+ for (let index = from; index < to; index++) {
233
+ if (tokens[index].text === '{')
234
+ depth++;
235
+ if (tokens[index].text === '}')
236
+ depth--;
237
+ }
238
+ return depth > 0;
239
+ }
240
+ /** `export` 뒤의 default·named 모듈 바인딩을 수집한다. */
241
+ function collectExport(context, start) {
242
+ const { tokens } = context;
243
+ const next = tokens[start + 1];
244
+ if (next === undefined)
245
+ return start;
246
+ if (next.kind === 'keyword' && next.text === 'default') {
247
+ const bound = readModuleExpression(context, start + 2);
248
+ if (bound !== undefined && endsExpression(tokens, bound.endIndex)) {
249
+ context.defaultExport = bound.value;
250
+ return bound.endIndex;
251
+ }
252
+ // `export default M` — 로컬 바인딩을 거치는 형태다.
253
+ const ident = tokens[start + 2];
254
+ if (ident?.kind === 'identifier') {
255
+ const resolved = context.bindings.get(ident.text);
256
+ if (resolved !== undefined)
257
+ context.defaultExport = resolved;
258
+ return start + 2;
259
+ }
260
+ return start + 1;
261
+ }
262
+ if (next.kind === 'keyword' &&
263
+ (next.text === 'const' || next.text === 'let' || next.text === 'var')) {
264
+ // `export const X = <moduleExpr>` — 이 선언이 새로 만든 바인딩만 named export다.
265
+ const before = new Set(context.bindings.keys());
266
+ const declEnd = collectDeclaration(context, start + 1);
267
+ for (const name of context.bindings.keys()) {
268
+ if (!before.has(name)) {
269
+ context.namedExports.set(name, context.bindings.get(name));
270
+ }
271
+ }
272
+ return declEnd;
273
+ }
274
+ if (next.text === '{') {
275
+ // `export { A, B as C }`(로컬)와 `export { A as B } from 'spec'`(배럴) 둘 다다.
276
+ const close = findMatching(tokens, start + 1, '{', '}');
277
+ if (close === undefined)
278
+ return start;
279
+ const pairs = [];
280
+ for (let index = start + 2; index < close; index++) {
281
+ const token = tokens[index];
282
+ if (token.kind === 'identifier' || token.kind === 'keyword') {
283
+ if (token.text === 'as') {
284
+ const alias = tokens[index + 1];
285
+ const last = pairs.at(-1);
286
+ if (alias !== undefined &&
287
+ (alias.kind === 'identifier' || alias.kind === 'keyword') &&
288
+ last !== undefined) {
289
+ pairs[pairs.length - 1] = { source: last.source, exported: alias.text };
290
+ index++;
291
+ }
292
+ continue;
293
+ }
294
+ pairs.push({ source: token.text, exported: token.text });
295
+ }
296
+ }
297
+ // `from 'spec'`이 뒤따르면 배럴 재수출이다.
298
+ const from = tokens[close + 1];
299
+ const specifier = tokens[close + 2];
300
+ if (from?.text === 'from' && specifier?.kind === 'string') {
301
+ for (const pair of pairs) {
302
+ context.reexports.set(pair.exported, {
303
+ specifier: specifier.value ?? specifier.text,
304
+ exportedName: pair.source === 'default' ? 'default' : pair.source,
305
+ });
306
+ }
307
+ return close + 2;
308
+ }
309
+ for (const pair of pairs) {
310
+ const bound = context.bindings.get(pair.source);
311
+ if (bound !== undefined)
312
+ context.namedExports.set(pair.exported, bound);
313
+ }
314
+ return close;
315
+ }
316
+ return start;
317
+ }
318
+ /**
319
+ * `const/let/var` 선언의 바인딩을 수집한다 — 식별자와 구조 분해 둘 다.
320
+ *
321
+ * 선언자는 선언 키워드 직후나 같은 깊이의 `,` 뒤에만 온다 — 이 조건이 없으면
322
+ * 세미콜론 없는 코드에서 뒤 문장의 `state.handler = Cam` 같은 대입이
323
+ * 선언자로 오인돼 바인딩이 파일 전역으로 새어 나간다.
324
+ */
325
+ function collectDeclaration(context, start) {
326
+ const { tokens } = context;
327
+ let index = start + 1;
328
+ // 선언자의 `,`는 깊이 0에만 온다 — `f(x, y = z)` 인자 안의 `,`는 무관하다.
329
+ let depth = 0;
330
+ while (index < tokens.length) {
331
+ const token = tokens[index];
332
+ if (token.text === ';' && depth === 0)
333
+ return index;
334
+ if (token.text === '(' || token.text === '[') {
335
+ depth++;
336
+ index++;
337
+ continue;
338
+ }
339
+ if (token.text === ')' || token.text === ']') {
340
+ depth--;
341
+ index++;
342
+ continue;
343
+ }
344
+ if (token.kind === 'identifier') {
345
+ const declarator = index === start + 1 ||
346
+ (depth === 0 && tokens[index - 1]?.text === ',');
347
+ // 바인딩이 풀리든 안 풀리든 선언된 이름이다 — Expo API와 동명인
348
+ // 로컬 선언이면 그 호출은 Expo API가 아니므로 mechanism 표시를 뺀다.
349
+ if (declarator)
350
+ context.localNames.add(token.text);
351
+ let cursor = index + 1;
352
+ // `const M: TurboModule = …`의 타입 주석은 건너뛰고 `=`를 찾는다.
353
+ if (declarator && tokens[cursor]?.text === ':') {
354
+ cursor = skipTypeAnnotation(tokens, cursor + 1);
355
+ }
356
+ if (declarator && tokens[cursor]?.text === '=') {
357
+ // `const X = require('spec')…`의 specifier를 기록한다.
358
+ const required = readRequireSpecifier(tokens, cursor + 1);
359
+ if (required !== undefined) {
360
+ context.requireSpecifiers.set(token.text, required);
361
+ }
362
+ const bound = readModuleExpression(context, cursor + 1);
363
+ if (bound !== undefined && endsExpression(tokens, bound.endIndex)) {
364
+ context.bindings.set(token.text, bound.value);
365
+ // 초기값 식 내부의 괄호는 depth에 반영되지 않았으니 마지막 토큰까지
366
+ // 건너뛴다 — `)`를 다시 처리하면 depth가 음수로 내려간다.
367
+ index = bound.endIndex + 1;
368
+ continue;
369
+ }
370
+ const literal = readLiteralExpression(context, cursor + 1);
371
+ if (literal !== undefined && endsExpression(tokens, literal.endIndex)) {
372
+ context.constStrings.set(token.text, literal.value);
373
+ index = literal.endIndex + 1;
374
+ continue;
375
+ }
376
+ }
377
+ index++;
378
+ continue;
379
+ }
380
+ if (token.text === '{') {
381
+ const close = findMatching(tokens, index, '{', '}');
382
+ if (close === undefined)
383
+ return index;
384
+ // `const { A, ... } = NativeModules`인지 확인한다.
385
+ const source = tokens[close + 2];
386
+ const isNativeModules = tokens[close + 1]?.text === '=' &&
387
+ source?.kind === 'identifier' && source.text === 'NativeModules';
388
+ if (isNativeModules) {
389
+ collectDestructuredBindings(context, index, close);
390
+ index = close + 1;
391
+ continue;
392
+ }
393
+ // 그 외 원천의 구조 분해도 로컬 이름을 선언한다 — `require('spec')`
394
+ // 원천이면 이름별 specifier까지 남겨 mechanism 판정에 쓴다.
395
+ const required = tokens[close + 1]?.text === '='
396
+ ? readRequireSpecifier(tokens, close + 2)
397
+ : undefined;
398
+ recordDestructuredLocals(context, index, close, required);
399
+ index = close + 1;
400
+ continue;
401
+ }
402
+ index++;
403
+ }
404
+ return index;
405
+ }
406
+ /**
407
+ * 초기값 식이 `endIndex`에서 끝나는지 확인한다.
408
+ *
409
+ * `'Hel' + 'lo'`처럼 리터럴 뒤에 식이 계속되면 첫 토큰만 바인딩해 잘못된
410
+ * 정적 이름이 되므로, 같은 줄에서는 `,`·`;`·닫는 괄호만 끝으로 인정한다.
411
+ * 줄이 바뀌면 ASI로 문장이 끝날 수 있지만 `[`·`(`·이항 연산자 등은 여전히
412
+ * 식을 이으므로 계속 토큰 집합에 있으면 끝이 아니다.
413
+ */
414
+ function endsExpression(tokens, endIndex) {
415
+ const next = tokens[endIndex + 1];
416
+ if (next === undefined)
417
+ return true;
418
+ const sameLineEnd = next.text === ',' || next.text === ';' ||
419
+ next.text === ')' || next.text === ']' || next.text === '}';
420
+ if (next.line === tokens[endIndex].line)
421
+ return sameLineEnd;
422
+ return !expressionContinuations.has(next.text);
423
+ }
424
+ /** 줄이 바뀌어도 앞 식을 잇는 토큰이다 — ASI로 끝나지 않는 연속부다. */
425
+ const expressionContinuations = new Set([
426
+ '.', '?.', '(', '[', '`', '?', ':',
427
+ '+', '-', '*', '/', '%', '**', '&', '|', '^', '<', '>', '=',
428
+ '&&', '||', '??', '=>', '==', '===', '!=', '!==', '<=', '>=',
429
+ 'instanceof', 'in', 'as',
430
+ ]);
431
+ /**
432
+ * 타입 주석 구간을 건너뛰어 `=`·`,`·`;` 위치를 돌려준다.
433
+ *
434
+ * 타입 안의 `=`(예: 제네릭 기본값 `<T = X>`)는 괄호 깊이 안에 있으므로
435
+ * 깊이 0의 `=`·`,`·`;`에서 멈춘다.
436
+ */
437
+ function skipTypeAnnotation(tokens, start) {
438
+ let depth = 0;
439
+ let index = start;
440
+ while (index < tokens.length) {
441
+ const text = tokens[index].text;
442
+ if (text === '(' || text === '[' || text === '{' || text === '<') {
443
+ depth++;
444
+ }
445
+ else if (text === ')' || text === ']' || text === '}' || text === '>') {
446
+ depth--;
447
+ if (depth < 0)
448
+ return index;
449
+ }
450
+ else if (depth === 0 &&
451
+ (text === '=' || text === ',' || text === ';')) {
452
+ return index;
453
+ }
454
+ index++;
455
+ }
456
+ return index;
457
+ }
458
+ /**
459
+ * `NativeModules` 구조 분해의 각 속성을 module-import로 확정하고 바인딩한다.
460
+ * `...rest`는 이름을 열거할 수 없어 원문을 실은 동적 사실로 남긴다.
461
+ */
462
+ function collectDestructuredBindings(context, open, close) {
463
+ const { tokens } = context;
464
+ for (let index = open + 1; index < close; index++) {
465
+ const token = tokens[index];
466
+ if (token.text === ',')
467
+ continue;
468
+ if (token.text === '...') {
469
+ // open·close는 토큰 인덱스다 — 문자 오프셋으로 환산해 원문을 뗀다.
470
+ const begin = tokens[open].offset;
471
+ const end = tokens[close].offset + tokens[close].text.length;
472
+ context.facts.push({
473
+ kind: 'module-import',
474
+ channel: sanitizeExpression(context.source.slice(begin, end)),
475
+ dynamic: true,
476
+ token,
477
+ });
478
+ context.counts.dynamicModuleNames++;
479
+ break;
480
+ }
481
+ if (token.kind === 'identifier' || token.kind === 'keyword') {
482
+ const name = token.text;
483
+ let localName = name;
484
+ let cursor = index;
485
+ // `A: alias` 형태와 기본값 `A = expr`를 구분한다.
486
+ if (tokens[index + 1]?.text === ':') {
487
+ const alias = tokens[index + 2];
488
+ if (alias?.kind === 'identifier' || alias?.kind === 'keyword') {
489
+ localName = alias.text;
490
+ cursor = index + 2;
491
+ }
492
+ }
493
+ while (cursor < close && tokens[cursor].text !== ',')
494
+ cursor++;
495
+ index = cursor - 1;
496
+ context.facts.push({
497
+ kind: 'module-import',
498
+ channel: name,
499
+ dynamic: false,
500
+ token,
501
+ });
502
+ context.bindings.set(localName, { name });
503
+ context.localNames.add(localName);
504
+ }
505
+ }
506
+ }
507
+ /**
508
+ * `require('spec')`의 specifier를 읽는다 — `require` 리터럴 호출만 본다.
509
+ * `require('spec').member`처럼 뒤에 멤버 접근이 붙어도 specifier는 같다.
510
+ */
511
+ function readRequireSpecifier(tokens, start) {
512
+ if (tokens[start]?.kind !== 'identifier' ||
513
+ tokens[start].text !== 'require' ||
514
+ tokens[start + 1]?.text !== '(') {
515
+ return undefined;
516
+ }
517
+ const specifier = tokens[start + 2];
518
+ if (specifier?.kind !== 'string' || specifier.value === undefined ||
519
+ tokens[start + 3]?.text !== ')') {
520
+ return undefined;
521
+ }
522
+ return specifier.value;
523
+ }
524
+ /**
525
+ * `const { A, B: alias, ...rest } = <expr>` 구조 분해의 로컬 이름을 기록한다.
526
+ * 원천이 `require('spec')`이면 이름별 specifier도 남긴다 — CJS로 가져온
527
+ * Expo API를 ES import와 같은 규칙으로 판정하기 위해서다.
528
+ */
529
+ function recordDestructuredLocals(context, open, close, specifier) {
530
+ const { tokens } = context;
531
+ for (let index = open + 1; index < close; index++) {
532
+ const token = tokens[index];
533
+ if (token.kind !== 'identifier' && token.kind !== 'keyword')
534
+ continue;
535
+ let localName = token.text;
536
+ // `A: alias`의 로컬 이름은 alias다 — 속성명은 선언되지 않는다.
537
+ if (tokens[index + 1]?.text === ':') {
538
+ const alias = tokens[index + 2];
539
+ if (alias?.kind === 'identifier' || alias?.kind === 'keyword') {
540
+ localName = alias.text;
541
+ }
542
+ }
543
+ context.localNames.add(localName);
544
+ if (specifier !== undefined) {
545
+ context.requireSpecifiers.set(localName, specifier);
546
+ }
547
+ // 기본값·중첩 패턴까지 포함해 다음 `,`까지 건너뛴다.
548
+ let cursor = index + 1;
549
+ while (cursor < close && tokens[cursor].text !== ',')
550
+ cursor++;
551
+ index = cursor - 1;
552
+ }
553
+ }
554
+ /**
555
+ * 위치 `start`에서 모듈 식(`NativeModules.N`, `TurboModuleRegistry.get*('N')`,
556
+ * `requireNativeModule('N')` 등)을 읽어 이름·동적 표현식을 돌려준다.
557
+ */
558
+ function readModuleExpression(context, start) {
559
+ const { tokens } = context;
560
+ const token = tokens[start];
561
+ if (token === undefined)
562
+ return undefined;
563
+ if (token.kind === 'identifier' && token.text === 'NativeModules') {
564
+ const next = tokens[start + 1];
565
+ if (next?.text === '.' || next?.text === '?.') {
566
+ const name = tokens[start + 2];
567
+ if (name?.kind === 'identifier' || name?.kind === 'keyword') {
568
+ return { value: { name: name.text }, endIndex: start + 2 };
569
+ }
570
+ return undefined;
571
+ }
572
+ if (next?.text === '[') {
573
+ const arg = readArgument(context, start + 2);
574
+ if (arg === undefined)
575
+ return undefined;
576
+ // endIndex는 식 전체의 끝 — `NativeModules['X'].foo`의 `.foo`까지
577
+ // 바인딩하면 속성값을 모듈로 오인한다.
578
+ const close = findMatching(tokens, start + 1, '[', ']');
579
+ return {
580
+ value: arg.value === undefined
581
+ ? { dynamicExpression: arg.expression }
582
+ : { name: arg.value },
583
+ endIndex: close ?? arg.endIndex,
584
+ };
585
+ }
586
+ return undefined;
587
+ }
588
+ if (token.kind === 'identifier' && token.text === 'TurboModuleRegistry') {
589
+ if (tokens[start + 1]?.text !== '.')
590
+ return undefined;
591
+ const method = tokens[start + 2];
592
+ if (!turboRegistryMethods.has(method?.text ?? ''))
593
+ return undefined;
594
+ const call = readNamedCall(context, start + 3);
595
+ return call === undefined
596
+ ? undefined
597
+ : { value: call.value, endIndex: call.endIndex };
598
+ }
599
+ const isExpoModuleAlias = token.kind === 'identifier' &&
600
+ !isShadowed(context, token.text, start) &&
601
+ !context.unstableAliasNames.has(token.text) &&
602
+ context.imports.some((entry) => entry.localName === token.text &&
603
+ moduleImportCalls.has(entry.exportedName) &&
604
+ expoSpecifierPattern.test(entry.specifier));
605
+ if (moduleImportCalls.has(token.text) || componentRequireCalls.has(token.text) ||
606
+ isExpoModuleAlias) {
607
+ const call = readNamedCall(context, start + 1);
608
+ return call === undefined
609
+ ? undefined
610
+ : { value: call.value, endIndex: call.endIndex };
611
+ }
612
+ // 바인딩된 식별자의 추적(`const N = M`)은 한 단계만 허용한다.
613
+ // 같은 이름의 매개변수가 가리는 구간 안에서는 파일 바인딩을 적용하지 않는다.
614
+ if (token.kind === 'identifier' && !isShadowed(context, token.text, start)) {
615
+ const bound = context.bindings.get(token.text);
616
+ return bound === undefined
617
+ ? undefined
618
+ : { value: bound, endIndex: start };
619
+ }
620
+ return undefined;
621
+ }
622
+ /**
623
+ * 호출의 첫 인자 이름을 읽는다 — `ident(` 또는 `ident<T>(` 다음의 첫 인자.
624
+ * 이름이 문자열 리터럴이거나 한 단계 상수면 값을, 아니면 원문 표현식을 돌려준다.
625
+ */
626
+ function readNamedCall(context, start) {
627
+ const { tokens } = context;
628
+ let index = start;
629
+ if (tokens[index]?.text === '<') {
630
+ const close = skipTypeArguments(tokens, index);
631
+ if (close === undefined)
632
+ return undefined;
633
+ index = close + 1;
634
+ }
635
+ if (tokens[index]?.text !== '(')
636
+ return undefined;
637
+ const arg = readArgument(context, index + 1);
638
+ if (arg === undefined)
639
+ return undefined;
640
+ // endIndex는 호출의 닫는 `)` — `f('A').x`를 `f('A')`까지만 읽은 것처럼
641
+ // 끝내면 `.x` 속성 접근이 모듈 바인딩으로 오인된다.
642
+ const close = findMatching(tokens, index, '(', ')');
643
+ return {
644
+ value: arg.value === undefined
645
+ ? { dynamicExpression: arg.expression }
646
+ : { name: arg.value },
647
+ endIndex: close ?? arg.endIndex,
648
+ };
649
+ }
650
+ /** 한 단계 문자열 리터럴(`'lit'`·보간 없는 백틱)만 읽는다. */
651
+ function readLiteralExpression(context, start) {
652
+ const token = context.tokens[start];
653
+ if ((token?.kind === 'string' || token?.kind === 'template') &&
654
+ token.value !== undefined) {
655
+ return { value: token.value, endIndex: start };
656
+ }
657
+ return undefined;
658
+ }
659
+ /** 호출 인자의 첫 표현식을 읽는다. */
660
+ function readArgument(context, start) {
661
+ const { tokens } = context;
662
+ const token = tokens[start];
663
+ if (token === undefined || token.text === ')' || token.text === ',') {
664
+ return undefined;
665
+ }
666
+ const end = tokens[start + 1];
667
+ // `]`는 `NativeModules['N']`·`M['m']()`의 인자 종결이다 — 빠뜨리면
668
+ // 리터럴이 다토큰 표현식으로 읽혀 동적으로 오분류된다.
669
+ const singleToken = end === undefined ||
670
+ end.text === ',' || end.text === ')' || end.text === ']';
671
+ // 한 단계 상수 추적 — `const X = 'lit'` 같은 파일 선언만 본다.
672
+ if (token.kind === 'identifier' && singleToken) {
673
+ const literal = isShadowed(context, token.text, start)
674
+ ? undefined
675
+ : context.constStrings.get(token.text);
676
+ return literal === undefined || !isSafeNonEmptyString(literal)
677
+ ? { expression: sanitizeExpression(token.text), endIndex: start }
678
+ : { value: literal, expression: token.text, endIndex: start };
679
+ }
680
+ if (singleToken &&
681
+ (token.kind === 'string' || token.kind === 'template') &&
682
+ token.value !== undefined) {
683
+ // 계약이 허용하지 않는 이름(빈 값·제어 문자·짝 없는 서러게이트)은 정적
684
+ // 사실이 될 수 없다 — 원문을 실은 동적 사실로 내려 증거를 보존한다.
685
+ return isSafeNonEmptyString(token.value)
686
+ ? { value: token.value, expression: token.text, endIndex: start }
687
+ : { expression: sanitizeExpression(token.text), endIndex: start };
688
+ }
689
+ // 그 외 표현식은 닫는 `)`나 같은 깊이의 `,`까지 원문을 보존한다.
690
+ let depth = 0;
691
+ let index = start;
692
+ while (index < tokens.length) {
693
+ const current = tokens[index];
694
+ if (current.text === '(' || current.text === '[' || current.text === '{') {
695
+ depth++;
696
+ }
697
+ else if (current.text === ')' || current.text === ']' || current.text === '}') {
698
+ if (depth === 0)
699
+ break;
700
+ depth--;
701
+ }
702
+ else if (current.text === ',' && depth === 0) {
703
+ break;
704
+ }
705
+ index++;
706
+ }
707
+ const last = tokens[index - 1];
708
+ if (last === undefined || index === start)
709
+ return undefined;
710
+ return {
711
+ expression: sanitizeExpression(context.source.slice(token.offset, last.offset + last.text.length)),
712
+ endIndex: index - 1,
713
+ };
714
+ }
715
+ /** `<...>` 타입 인수 구간을 건너뛰어 닫는 `>`의 인덱스를 돌려준다. */
716
+ function skipTypeArguments(tokens, start) {
717
+ let depth = 0;
718
+ for (let index = start; index < tokens.length; index++) {
719
+ const text = tokens[index].text;
720
+ if (text === '<')
721
+ depth++;
722
+ else if (text === '>') {
723
+ depth--;
724
+ if (depth === 0)
725
+ return index;
726
+ }
727
+ else if (depth === 0) {
728
+ return undefined;
729
+ }
730
+ }
731
+ return undefined;
732
+ }
733
+ /** 두 번째 순회: 브리지 API 호출·멤버 호출을 사실로 모은다. */
734
+ function collectCalls(context) {
735
+ const { tokens } = context;
736
+ for (let index = 0; index < tokens.length; index++) {
737
+ const token = tokens[index];
738
+ if (token.kind !== 'identifier')
739
+ continue;
740
+ if (token.text === 'NativeModules') {
741
+ index = collectNativeModulesAccess(context, index);
742
+ continue;
743
+ }
744
+ if (token.text === 'TurboModuleRegistry') {
745
+ index = collectRegistryCall(context, index);
746
+ continue;
747
+ }
748
+ // `function NAME(...)`·`function* NAME(...)`의 선언부는 호출이 아니다 —
749
+ // 이름 기반 탐지가 선언 자리를 경계 호출로 오인하지 않게 뺀다.
750
+ const declarationSite = tokens[index - 1]?.text === 'function' ||
751
+ (tokens[index - 1]?.text === '*' &&
752
+ tokens[index - 2]?.text === 'function');
753
+ if (declarationSite)
754
+ continue;
755
+ if (moduleImportCalls.has(token.text)) {
756
+ index = collectBoundaryCall(context, index, 'module-import', token.text);
757
+ continue;
758
+ }
759
+ if (componentRequireCalls.has(token.text)) {
760
+ index = collectBoundaryCall(context, index, 'component-require', token.text);
761
+ continue;
762
+ }
763
+ // `import { api as alias }`로 들어온 경계 API — 사실의 mechanism 판정은
764
+ // 로컬 별칭이 아니라 원본 export 이름을 기준으로 한다. 같은 이름의
765
+ // 매개변수가 가리는 구간에서는 import 바인딩이 적용되지 않는다.
766
+ const aliasedApi = isShadowed(context, token.text, index)
767
+ ? undefined
768
+ : boundaryAliasOf(context, token.text);
769
+ if (aliasedApi !== undefined && boundaryCallOpens(context, index)) {
770
+ index = collectBoundaryCall(context, index, moduleImportCalls.has(aliasedApi) ? 'module-import' : 'component-require', aliasedApi);
771
+ continue;
772
+ }
773
+ const next = tokens[index + 1];
774
+ if (next?.text === '.' || next?.text === '?.' || next?.text === '[') {
775
+ index = collectBoundMemberCall(context, index);
776
+ }
777
+ }
778
+ }
779
+ /**
780
+ * `NativeModules.N` 멤버 접근과 뒤따르는 메서드 호출을 처리한다.
781
+ * 레지스트리 자체가 값으로 쓰이는 베어 `NativeModules`는 어느 모듈인지
782
+ * 알 수 없어 원문을 실은 동적 사실로 남긴다.
783
+ */
784
+ function collectNativeModulesAccess(context, start) {
785
+ const { tokens } = context;
786
+ const member = tokens[start + 1];
787
+ if (member?.text === '.' || member?.text === '?.') {
788
+ const name = tokens[start + 2];
789
+ if (name?.kind !== 'identifier' && name?.kind !== 'keyword')
790
+ return start;
791
+ context.facts.push({
792
+ kind: 'module-import',
793
+ channel: name.text,
794
+ dynamic: false,
795
+ token: name,
796
+ });
797
+ return collectChainedCall(context, { name: name.text }, start + 2)
798
+ ?? start + 2;
799
+ }
800
+ if (member?.text === '[') {
801
+ const arg = readArgument(context, start + 2);
802
+ if (arg === undefined)
803
+ return start;
804
+ const bound = arg.value === undefined
805
+ ? { dynamicExpression: arg.expression }
806
+ : { name: arg.value };
807
+ const dynamic = arg.value === undefined;
808
+ if (dynamic)
809
+ context.counts.dynamicModuleNames++;
810
+ context.facts.push({
811
+ kind: 'module-import',
812
+ channel: boundChannel(bound),
813
+ dynamic,
814
+ token: member,
815
+ });
816
+ const close = findMatching(tokens, start + 1, '[', ']');
817
+ if (close === undefined)
818
+ return arg.endIndex;
819
+ return collectChainedCall(context, bound, close) ?? close;
820
+ }
821
+ // 앞 토큰이 `.`·키워드면 큰 식의 일부다 — 베어 접근만 세어 중복을 피한다.
822
+ const previous = tokens[start - 1];
823
+ // `const { A } = NativeModules`의 레지스트리는 구조 분해 원천이다 —
824
+ // 각 이름이 이미 module-import를 냈으므로 베어 사실을 추가하지 않는다.
825
+ const destructured = previous?.text === '=' &&
826
+ tokens[start - 2]?.text === '}';
827
+ if (previous !== undefined && previous.text !== '.' &&
828
+ previous.text !== '?.' && !destructured) {
829
+ context.facts.push({
830
+ kind: 'module-import',
831
+ channel: 'NativeModules',
832
+ dynamic: true,
833
+ token: tokens[start],
834
+ });
835
+ context.counts.dynamicModuleNames++;
836
+ }
837
+ return start;
838
+ }
839
+ /** `TurboModuleRegistry.get*('N')` 호출과 뒤따르는 메서드 호출을 처리한다. */
840
+ function collectRegistryCall(context, start) {
841
+ const { tokens } = context;
842
+ if (tokens[start + 1]?.text !== '.')
843
+ return start;
844
+ const method = tokens[start + 2];
845
+ if (method === undefined || !turboRegistryMethods.has(method.text)) {
846
+ return start;
847
+ }
848
+ // skipTypeArguments는 닫는 `>`의 위치를 주므로 `(`는 그 다음 토큰이다.
849
+ let open = start + 3;
850
+ if (tokens[open]?.text === '<') {
851
+ const closeAngle = skipTypeArguments(tokens, open);
852
+ if (closeAngle === undefined)
853
+ return start;
854
+ open = closeAngle + 1;
855
+ }
856
+ if (tokens[open]?.text !== '(')
857
+ return start;
858
+ const arg = readArgument(context, open + 1);
859
+ if (arg === undefined)
860
+ return start;
861
+ const dynamic = arg.value === undefined;
862
+ if (dynamic)
863
+ context.counts.dynamicModuleNames++;
864
+ const bound = dynamic
865
+ ? { dynamicExpression: arg.expression }
866
+ : { name: arg.value };
867
+ context.facts.push({
868
+ kind: 'module-import',
869
+ channel: boundChannel(bound),
870
+ ...(optionalRegistryMethods.has(method.text) ? { optional: true } : {}),
871
+ dynamic,
872
+ token: method,
873
+ });
874
+ const close = findMatching(tokens, open, '(', ')');
875
+ if (close === undefined)
876
+ return arg.endIndex;
877
+ return collectChainedCall(context, bound, close) ?? close;
878
+ }
879
+ /**
880
+ * 식별자가 `import { api as local }`로 들어온 경계 API 별칭이면 원본 export
881
+ * 이름을 돌려준다 — 별칭 호출의 mechanism은 원본 이름 기준으로 판정한다.
882
+ */
883
+ function boundaryAliasOf(context, localName) {
884
+ for (const entry of context.imports) {
885
+ if (entry.localName === localName &&
886
+ (moduleImportCalls.has(entry.exportedName) ||
887
+ componentRequireCalls.has(entry.exportedName))) {
888
+ return entry.exportedName;
889
+ }
890
+ }
891
+ return undefined;
892
+ }
893
+ /** 식별자 뒤가 호출(`(` 또는 타입 인수 뒤 `(`)인지 본다. */
894
+ function boundaryCallOpens(context, start) {
895
+ const { tokens } = context;
896
+ let open = start + 1;
897
+ if (tokens[open]?.text === '<') {
898
+ const close = skipTypeArguments(tokens, open);
899
+ if (close === undefined)
900
+ return false;
901
+ open = close + 1;
902
+ }
903
+ return tokens[open]?.text === '(';
904
+ }
905
+ /** `requireNativeModule('N')` 등 단독 호출의 경계 사실과 메서드 호출을 처리한다. */
906
+ function collectBoundaryCall(context, start, kind, apiName) {
907
+ const { tokens } = context;
908
+ let open = start + 1;
909
+ if (tokens[open]?.text === '<') {
910
+ const closeAngle = skipTypeArguments(tokens, open);
911
+ if (closeAngle === undefined)
912
+ return start;
913
+ open = closeAngle + 1;
914
+ }
915
+ if (tokens[open]?.text !== '(')
916
+ return start;
917
+ const arg = readArgument(context, open + 1);
918
+ if (arg === undefined)
919
+ return start;
920
+ const dynamic = arg.value === undefined;
921
+ if (dynamic) {
922
+ if (kind === 'module-import')
923
+ context.counts.dynamicModuleNames++;
924
+ else
925
+ context.counts.dynamicComponentNames++;
926
+ }
927
+ const bound = dynamic
928
+ ? { dynamicExpression: arg.expression }
929
+ : { name: arg.value };
930
+ const mechanism = mechanismOf(context, apiName, tokens[start].text, start);
931
+ context.facts.push({
932
+ kind,
933
+ channel: boundChannel(bound),
934
+ ...(mechanism === undefined ? {} : { mechanism }),
935
+ ...(optionalModuleCalls.has(apiName) ? { optional: true } : {}),
936
+ dynamic,
937
+ token: tokens[start],
938
+ });
939
+ if (kind !== 'module-import')
940
+ return arg.endIndex;
941
+ const close = findMatching(tokens, open, '(', ')');
942
+ if (close === undefined)
943
+ return arg.endIndex;
944
+ return collectChainedCall(context, bound, close) ?? close;
945
+ }
946
+ /**
947
+ * 단독 호출의 해석 경로(mechanism)를 정한다.
948
+ *
949
+ * `requireNativeModule`·`requireOptionalNativeModule`·`requireNativeViewManager`는
950
+ * Expo SDK에만 있는 공개 API다 — Expo 패키지 import·CJS `require`로
951
+ * 확인되거나 어떤 가져오기·로컬 선언도 없이 호출되면 `expo`로 표시한다.
952
+ * 같은 이름이 로컬에 선언됐거나(같은 파일 래퍼·쉼) Expo가 아닌 specifier에서
953
+ * 가져온 동명 래퍼면 어느 경로로 해석되는지 알 수 없어 mechanism을 생략해
954
+ * 추측하지 않는다 — 틀린 `expo` 표시는 조인을 끊거나 거짓 매치를 만든다.
955
+ */
956
+ function mechanismOf(context, apiName, localName, start) {
957
+ if (!expoApiCalls.has(apiName))
958
+ return undefined;
959
+ // 같은 이름의 매개변수가 가리는 구간에서는 import·require·선언 해석이
960
+ // 이 호출에 적용되지 않는다 — mechanism을 생략해 추측하지 않는다.
961
+ if (isShadowed(context, localName, start))
962
+ return undefined;
963
+ const specifiers = context.imports
964
+ .filter((entry) => entry.localName === localName)
965
+ .map((entry) => entry.specifier);
966
+ if (specifiers.length > 0) {
967
+ return specifiers.some((specifier) => expoSpecifierPattern.test(specifier))
968
+ ? 'expo'
969
+ : undefined;
970
+ }
971
+ const required = context.requireSpecifiers.get(localName);
972
+ if (required !== undefined) {
973
+ return expoSpecifierPattern.test(required) ? 'expo' : undefined;
974
+ }
975
+ if (context.localNames.has(localName))
976
+ return undefined;
977
+ return 'expo';
978
+ }
979
+ /**
980
+ * `<moduleExpr>.method(...)` 연속 호출을 method-invoke 사실로 기록한다.
981
+ * 베이스는 이미 확정된 모듈 식이라 식별자 해석을 거치지 않는다.
982
+ */
983
+ function collectChainedCall(context, bound, endIndex) {
984
+ const { tokens } = context;
985
+ const dot = tokens[endIndex + 1];
986
+ if (dot?.text !== '.' && dot?.text !== '?.')
987
+ return undefined;
988
+ const method = tokens[endIndex + 2];
989
+ if (method === undefined)
990
+ return endIndex;
991
+ if (method.kind === 'identifier' || method.kind === 'keyword') {
992
+ // `NativeModules.N.m` 참조만 있고 호출이 없으면 사실이 아니다.
993
+ return isCallAt(tokens, endIndex + 3)
994
+ ? recordDirectMethodCall(context, bound, method, false, endIndex + 3)
995
+ : endIndex + 2;
996
+ }
997
+ if (method.text === '[') {
998
+ const arg = readArgument(context, endIndex + 3);
999
+ if (arg === undefined)
1000
+ return endIndex + 2;
1001
+ const bracketClose = findMatching(tokens, endIndex + 2, '[', ']');
1002
+ if (bracketClose === undefined || !isCallAt(tokens, bracketClose + 1)) {
1003
+ return arg.endIndex;
1004
+ }
1005
+ const dynamicMethod = arg.value === undefined;
1006
+ if (dynamicMethod)
1007
+ context.counts.dynamicMethodNames++;
1008
+ return recordDirectMethodCall(context, bound, method, dynamicMethod, bracketClose + 1, dynamicMethod ? arg.expression : arg.value);
1009
+ }
1010
+ return endIndex;
1011
+ }
1012
+ /** 확정된 모듈 식 위의 메서드 호출을 사실로 기록한다. */
1013
+ function recordDirectMethodCall(context, bound, token, dynamicMethod, callIndex, methodText) {
1014
+ const name = 'name' in bound ? bound.name : bound.dynamicExpression;
1015
+ const dynamic = !('name' in bound);
1016
+ context.facts.push({
1017
+ kind: 'method-invoke',
1018
+ channel: name,
1019
+ method: methodText ?? token.text,
1020
+ dynamic: dynamic || dynamicMethod,
1021
+ token,
1022
+ });
1023
+ return callIndex;
1024
+ }
1025
+ /**
1026
+ * 바인딩 식별자의 `M.foo()`·`M[expr]()` 멤버 호출을 후보로 기록한다.
1027
+ * 로컬 바인딩이나 import 바인딩이 없는 식별자는 모듈 근거가 없어 건너뛴다.
1028
+ */
1029
+ function collectBoundMemberCall(context, start) {
1030
+ const { tokens } = context;
1031
+ const token = tokens[start];
1032
+ const bound = !isShadowed(context, token.text, start) &&
1033
+ (context.bindings.has(token.text) ||
1034
+ context.imports.some((entry) => entry.localName === token.text));
1035
+ const accessor = tokens[start + 1];
1036
+ // `M[expr]()` — 대괄호 접근은 식별자 바로 뒤에 온다.
1037
+ if (accessor?.text === '[') {
1038
+ const arg = readArgument(context, start + 2);
1039
+ if (arg === undefined)
1040
+ return start;
1041
+ const bracketClose = findMatching(tokens, start + 1, '[', ']');
1042
+ const called = bracketClose !== undefined &&
1043
+ isCallAt(tokens, bracketClose + 1);
1044
+ if (bound && called) {
1045
+ const dynamicMethod = arg.value === undefined;
1046
+ if (dynamicMethod)
1047
+ context.counts.dynamicMethodNames++;
1048
+ context.memberCalls.push({
1049
+ ident: token.text,
1050
+ method: dynamicMethod ? arg.expression : arg.value,
1051
+ dynamicMethod,
1052
+ token: accessor,
1053
+ });
1054
+ }
1055
+ return arg.endIndex;
1056
+ }
1057
+ // accessor는 `.`·`?.` — 뒤는 메서드 식별자 또는 `M?.[expr]`의 `[`다.
1058
+ const method = tokens[start + 2];
1059
+ if (method === undefined)
1060
+ return start;
1061
+ if (method.kind === 'identifier' || method.kind === 'keyword') {
1062
+ if (!isCallAt(tokens, start + 3))
1063
+ return start + 2;
1064
+ if (bound) {
1065
+ context.memberCalls.push({
1066
+ ident: token.text,
1067
+ method: method.text,
1068
+ dynamicMethod: false,
1069
+ token: method,
1070
+ });
1071
+ }
1072
+ return start + 2;
1073
+ }
1074
+ if (method.text === '[') {
1075
+ const arg = readArgument(context, start + 3);
1076
+ if (arg === undefined)
1077
+ return start;
1078
+ const bracketClose = findMatching(tokens, start + 2, '[', ']');
1079
+ const called = bracketClose !== undefined && isCallAt(tokens, bracketClose + 1);
1080
+ if (bound && called) {
1081
+ const dynamicMethod = arg.value === undefined;
1082
+ if (dynamicMethod)
1083
+ context.counts.dynamicMethodNames++;
1084
+ context.memberCalls.push({
1085
+ ident: token.text,
1086
+ method: dynamicMethod ? arg.expression : arg.value,
1087
+ dynamicMethod,
1088
+ token: method,
1089
+ });
1090
+ }
1091
+ return arg.endIndex;
1092
+ }
1093
+ return start;
1094
+ }
1095
+ /** `(` 또는 `<T>(`로 시작하는 호출인지 확인한다. */
1096
+ function isCallAt(tokens, start) {
1097
+ const token = tokens[start];
1098
+ if (token?.text === '(')
1099
+ return true;
1100
+ if (token?.text !== '<')
1101
+ return false;
1102
+ const close = skipTypeArguments(tokens, start);
1103
+ return close !== undefined && tokens[close + 1]?.text === '(';
1104
+ }
1105
+ /** BoundName을 사실의 channel 문자열로 바꾼다. */
1106
+ function boundChannel(bound) {
1107
+ return 'name' in bound ? bound.name : bound.dynamicExpression;
1108
+ }
1109
+ /**
1110
+ * 매개변수가 파일 전역 바인딩을 가리는 본문 구간을 모은다.
1111
+ *
1112
+ * 인식하는 형태는 `function f(M) {…}`·`f(M) {…}`(메서드 약칭)·`(M) => {…}`·
1113
+ * `M => {…}`다. `foo(x) {…}`처럼 호출 뒤 블록이 오는 문장도 매개변수 목록으로
1114
+ * 보이지만, 그 경우의 과도한 가림은 사실을 놓칠 뿐 거짓 사실을 만들지 않는다.
1115
+ * 본문이 `{`로 시작하지 않는 화살표(`M => M.x()`)는 끝을 알 수 없어 건너뛴다.
1116
+ */
1117
+ function collectParamShadows(tokens) {
1118
+ const shadows = [];
1119
+ for (let index = 0; index < tokens.length; index++) {
1120
+ const token = tokens[index];
1121
+ // `M => {…}` 단일 매개변수 화살표다.
1122
+ if ((token.kind === 'identifier' || token.kind === 'keyword') &&
1123
+ tokens[index + 1]?.text === '=>' && tokens[index + 2]?.text === '{') {
1124
+ const close = findMatching(tokens, index + 2, '{', '}');
1125
+ if (close !== undefined) {
1126
+ shadows.push({
1127
+ from: index + 2,
1128
+ to: close + 1,
1129
+ names: new Set([token.text]),
1130
+ });
1131
+ index = close;
1132
+ }
1133
+ continue;
1134
+ }
1135
+ if (token.text !== '(')
1136
+ continue;
1137
+ const previous = tokens[index - 1];
1138
+ // `function f(…)`·`function (…)`의 매개변수 목록이다.
1139
+ let isParams = previous?.text === 'function' ||
1140
+ (previous?.kind === 'identifier' &&
1141
+ tokens[index - 2]?.text === 'function');
1142
+ const close = findMatching(tokens, index, '(', ')');
1143
+ if (close === undefined)
1144
+ continue;
1145
+ if (!isParams) {
1146
+ const afterClose = tokens[close + 1];
1147
+ isParams = afterClose?.text === '=>' ||
1148
+ // `f(M) {…}` 메서드 약칭 — `(` 앞이 식별자일 때만 본다.
1149
+ // `if (M) {…}` 같은 키워드 조건절은 매개변수가 아니다.
1150
+ (previous?.kind === 'identifier' && afterClose?.text === '{');
1151
+ }
1152
+ if (!isParams)
1153
+ continue;
1154
+ let bodyIndex = close + 1;
1155
+ if (tokens[bodyIndex]?.text === '=>')
1156
+ bodyIndex++;
1157
+ if (tokens[bodyIndex]?.text !== '{')
1158
+ continue;
1159
+ const bodyClose = findMatching(tokens, bodyIndex, '{', '}');
1160
+ if (bodyClose === undefined)
1161
+ continue;
1162
+ const names = new Set();
1163
+ for (let cursor = index + 1; cursor < close; cursor++) {
1164
+ const param = tokens[cursor];
1165
+ if (param.kind === 'identifier' || param.kind === 'keyword') {
1166
+ names.add(param.text);
1167
+ }
1168
+ }
1169
+ shadows.push({ from: bodyIndex, to: bodyClose + 1, names });
1170
+ index = close;
1171
+ }
1172
+ return shadows;
1173
+ }
1174
+ /** 위치 `index`의 `name`이 매개변수에 가려졌는지 판정한다. */
1175
+ function isShadowed(context, name, index) {
1176
+ return context.paramShadows.some((shadow) => index >= shadow.from && index < shadow.to && shadow.names.has(name));
1177
+ }
1178
+ /** 짝 맞는 괄호 토큰을 찾는다. */
1179
+ function findMatching(tokens, open, openText, closeText) {
1180
+ let depth = 0;
1181
+ for (let index = open; index < tokens.length; index++) {
1182
+ const text = tokens[index].text;
1183
+ if (text === openText)
1184
+ depth++;
1185
+ else if (text === closeText) {
1186
+ depth--;
1187
+ if (depth === 0)
1188
+ return index;
1189
+ }
1190
+ }
1191
+ return undefined;
1192
+ }
1193
+ /**
1194
+ * 동적 표현식의 원문을 계약 필드가 허용하는 안전한 문자열로 바꾼다.
1195
+ *
1196
+ * 제어 문자는 공백으로 접고 길이를 제한해 원문 표현이 키 파싱을 깨지 않게 한다.
1197
+ */
1198
+ export function sanitizeExpression(expression) {
1199
+ const cleaned = expression
1200
+ .replace(/[\u0000-\u001F\u007F-\u009F]/g, ' ')
1201
+ .replace(/\s+/g, ' ')
1202
+ .trim();
1203
+ return cleaned.length > 200 ? `${cleaned.slice(0, 197)}...` : cleaned;
1204
+ }
1205
+ //# sourceMappingURL=js-scan.js.map