@simplysm/sd-cli 14.0.46 → 14.0.48

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 (134) hide show
  1. package/README.md +782 -0
  2. package/dist/angular/ngtsc-build-core.js +2 -2
  3. package/dist/angular/ngtsc-build-core.js.map +1 -1
  4. package/dist/angular/vite-angular-plugin.d.ts.map +1 -1
  5. package/dist/angular/vite-angular-plugin.js +3 -2
  6. package/dist/angular/vite-angular-plugin.js.map +1 -1
  7. package/dist/capacitor/capacitor-android.js +2 -2
  8. package/dist/capacitor/capacitor-android.js.map +1 -1
  9. package/dist/capacitor/capacitor-build.d.ts.map +1 -1
  10. package/dist/capacitor/capacitor-build.js +2 -1
  11. package/dist/capacitor/capacitor-build.js.map +1 -1
  12. package/dist/capacitor/capacitor-icon.d.ts.map +1 -1
  13. package/dist/capacitor/capacitor-icon.js +2 -1
  14. package/dist/capacitor/capacitor-icon.js.map +1 -1
  15. package/dist/capacitor/capacitor-npm-config.d.ts.map +1 -1
  16. package/dist/capacitor/capacitor-npm-config.js +2 -1
  17. package/dist/capacitor/capacitor-npm-config.js.map +1 -1
  18. package/dist/capacitor/capacitor.d.ts.map +1 -1
  19. package/dist/capacitor/capacitor.js +2 -1
  20. package/dist/capacitor/capacitor.js.map +1 -1
  21. package/dist/commands/device.js +2 -2
  22. package/dist/commands/device.js.map +1 -1
  23. package/dist/commands/publish/git-phase.js +1 -1
  24. package/dist/commands/publish/git-phase.js.map +1 -1
  25. package/dist/commands/replace-deps.js +2 -2
  26. package/dist/commands/replace-deps.js.map +1 -1
  27. package/dist/deps/replace-deps/collect-deps.js +2 -2
  28. package/dist/deps/replace-deps/collect-deps.js.map +1 -1
  29. package/dist/deps/replace-deps/replace-deps.d.ts.map +1 -1
  30. package/dist/deps/replace-deps/replace-deps.js +108 -81
  31. package/dist/deps/replace-deps/replace-deps.js.map +1 -1
  32. package/dist/deps/server-externals/server-production-files.js +2 -2
  33. package/dist/deps/server-externals/server-production-files.js.map +1 -1
  34. package/dist/electron/electron.d.ts.map +1 -1
  35. package/dist/electron/electron.js +2 -1
  36. package/dist/electron/electron.js.map +1 -1
  37. package/dist/engines/BaseEngine.d.ts.map +1 -1
  38. package/dist/engines/BaseEngine.js +2 -2
  39. package/dist/engines/BaseEngine.js.map +1 -1
  40. package/dist/engines/EsbuildClientEngine.d.ts.map +1 -1
  41. package/dist/engines/EsbuildClientEngine.js +2 -2
  42. package/dist/engines/EsbuildClientEngine.js.map +1 -1
  43. package/dist/engines/NgtscEngine.js +2 -2
  44. package/dist/engines/NgtscEngine.js.map +1 -1
  45. package/dist/engines/ServerEsbuildEngine.js +2 -2
  46. package/dist/engines/ServerEsbuildEngine.js.map +1 -1
  47. package/dist/engines/TscEngine.js +2 -2
  48. package/dist/engines/TscEngine.js.map +1 -1
  49. package/dist/engines/engine-factory.d.ts.map +1 -1
  50. package/dist/engines/engine-factory.js +2 -2
  51. package/dist/engines/engine-factory.js.map +1 -1
  52. package/dist/esbuild/esbuild-angular-compiler-plugin.d.ts.map +1 -1
  53. package/dist/esbuild/esbuild-angular-compiler-plugin.js +52 -20
  54. package/dist/esbuild/esbuild-angular-compiler-plugin.js.map +1 -1
  55. package/dist/esbuild/esbuild-config.js +2 -2
  56. package/dist/esbuild/esbuild-config.js.map +1 -1
  57. package/dist/esbuild/esbuild-worker-plugin.d.ts +14 -2
  58. package/dist/esbuild/esbuild-worker-plugin.d.ts.map +1 -1
  59. package/dist/esbuild/esbuild-worker-plugin.js +13 -5
  60. package/dist/esbuild/esbuild-worker-plugin.js.map +1 -1
  61. package/dist/lint/lint-with-program.js +2 -2
  62. package/dist/lint/lint-with-program.js.map +1 -1
  63. package/dist/runtime/lazy-logger.d.ts +14 -0
  64. package/dist/runtime/lazy-logger.d.ts.map +1 -0
  65. package/dist/runtime/lazy-logger.js +23 -0
  66. package/dist/runtime/lazy-logger.js.map +1 -0
  67. package/dist/sd-cli-entry.js +2 -2
  68. package/dist/sd-cli-entry.js.map +1 -1
  69. package/dist/sd-cli.js +2 -2
  70. package/dist/sd-cli.js.map +1 -1
  71. package/dist/ts-compiler/SdTsCompiler.d.ts +11 -0
  72. package/dist/ts-compiler/SdTsCompiler.d.ts.map +1 -1
  73. package/dist/ts-compiler/SdTsCompiler.js +223 -116
  74. package/dist/ts-compiler/SdTsCompiler.js.map +1 -1
  75. package/dist/typecheck/typecheck-non-package.js +2 -2
  76. package/dist/typecheck/typecheck-non-package.js.map +1 -1
  77. package/dist/typecheck/typecheck-serialization.d.ts +31 -9
  78. package/dist/typecheck/typecheck-serialization.d.ts.map +1 -1
  79. package/dist/typecheck/typecheck-serialization.js +62 -22
  80. package/dist/typecheck/typecheck-serialization.js.map +1 -1
  81. package/dist/utils/output-utils.js +2 -2
  82. package/dist/utils/output-utils.js.map +1 -1
  83. package/dist/utils/package-classify.js +2 -2
  84. package/dist/utils/package-classify.js.map +1 -1
  85. package/dist/utils/package-utils.js +2 -2
  86. package/dist/utils/package-utils.js.map +1 -1
  87. package/dist/utils/sd-config.js +2 -2
  88. package/dist/utils/sd-config.js.map +1 -1
  89. package/dist/utils/tsconfig.d.ts.map +1 -1
  90. package/dist/utils/tsconfig.js +3 -5
  91. package/dist/utils/tsconfig.js.map +1 -1
  92. package/package.json +8 -8
  93. package/src/angular/ngtsc-build-core.ts +3 -3
  94. package/src/angular/vite-angular-plugin.ts +3 -2
  95. package/src/capacitor/capacitor-android.ts +2 -2
  96. package/src/capacitor/capacitor-build.ts +2 -1
  97. package/src/capacitor/capacitor-icon.ts +2 -1
  98. package/src/capacitor/capacitor-npm-config.ts +2 -1
  99. package/src/capacitor/capacitor.ts +2 -1
  100. package/src/commands/device.ts +2 -2
  101. package/src/commands/publish/git-phase.ts +1 -1
  102. package/src/commands/replace-deps.ts +2 -2
  103. package/src/deps/replace-deps/collect-deps.ts +2 -2
  104. package/src/deps/replace-deps/replace-deps.ts +119 -85
  105. package/src/deps/server-externals/server-production-files.ts +2 -2
  106. package/src/electron/electron.ts +2 -1
  107. package/src/engines/BaseEngine.ts +2 -2
  108. package/src/engines/EsbuildClientEngine.ts +2 -2
  109. package/src/engines/NgtscEngine.ts +2 -2
  110. package/src/engines/ServerEsbuildEngine.ts +2 -2
  111. package/src/engines/TscEngine.ts +2 -2
  112. package/src/engines/engine-factory.ts +2 -2
  113. package/src/esbuild/esbuild-angular-compiler-plugin.ts +66 -21
  114. package/src/esbuild/esbuild-config.ts +2 -2
  115. package/src/esbuild/esbuild-worker-plugin.ts +24 -4
  116. package/src/lint/lint-with-program.ts +2 -2
  117. package/src/runtime/lazy-logger.ts +23 -0
  118. package/src/sd-cli-entry.ts +2 -2
  119. package/src/sd-cli.ts +2 -2
  120. package/src/ts-compiler/SdTsCompiler.ts +280 -138
  121. package/src/typecheck/typecheck-non-package.ts +2 -2
  122. package/src/typecheck/typecheck-serialization.ts +100 -26
  123. package/src/utils/output-utils.ts +2 -2
  124. package/src/utils/package-classify.ts +2 -2
  125. package/src/utils/package-utils.ts +2 -2
  126. package/src/utils/sd-config.ts +2 -2
  127. package/src/utils/tsconfig.ts +3 -4
  128. package/tests/esbuild/esbuild-worker-plugin.spec.ts +245 -0
  129. package/tests/utils/replace-deps-watch.acc.spec.ts +85 -0
  130. package/tests/utils/replace-deps-watch.spec.ts +198 -1
  131. package/dist/sd-cli/tests/angular/fixtures/packages/basic-app/tests/test.fixture.d.ts +0 -3
  132. package/dist/sd-cli/tests/angular/fixtures/packages/basic-app/tests/test.fixture.d.ts.map +0 -1
  133. package/dist/sd-cli/tests/angular/fixtures/packages/basic-app/tests/test.fixture.js +0 -9
  134. package/dist/sd-cli/tests/angular/fixtures/packages/basic-app/tests/test.fixture.js.map +0 -1
@@ -2,38 +2,87 @@ import ts from "typescript";
2
2
  import { fsx } from "@simplysm/core-node";
3
3
 
4
4
  /**
5
- * Worker로 전달할 있는 직렬화된 Diagnostic
5
+ * DiagnosticMessageChain을 worker 경계로 넘기기 위한 직렬화 구조.
6
+ * (chain 구조를 그대로 보존하여 formatter가 원본 들여쓰기/순서대로 출력하게 함)
7
+ */
8
+ export interface SerializedMessageChain {
9
+ messageText: string;
10
+ category: number;
11
+ code: number;
12
+ next?: SerializedMessageChain[];
13
+ }
14
+
15
+ /**
16
+ * Worker로 전달할 수 있는 직렬화된 Diagnostic.
17
+ * ts.Diagnostic의 모든 사용자 가시 필드(detail/relatedInformation/flag)를 보존한다.
6
18
  */
7
19
  export interface SerializedDiagnostic {
8
20
  category: number;
9
21
  code: number;
10
- messageText: string;
22
+ /** messageText chain(overload 에러 등)이거나 단순 문자열. chain이면 구조 그대로 보존 */
23
+ messageText: string | SerializedMessageChain;
11
24
  file?: {
12
25
  fileName: string;
13
26
  };
14
27
  start?: number;
15
28
  length?: number;
29
+ relatedInformation?: SerializedDiagnosticRelatedInformation[];
30
+ /** true 또는 ts가 넘기는 (빈) 객체. formatter가 인식. */
31
+ reportsUnnecessary?: boolean;
32
+ reportsDeprecated?: boolean;
33
+ source?: string;
34
+ }
35
+
36
+ /** ts.DiagnosticRelatedInformation에 대응. file/start/length와 messageText만 가진 축약 구조. */
37
+ export interface SerializedDiagnosticRelatedInformation {
38
+ category: number;
39
+ code: number;
40
+ messageText: string | SerializedMessageChain;
41
+ file?: { fileName: string };
42
+ start?: number;
43
+ length?: number;
44
+ }
45
+
46
+ function serializeMessageChain(chain: ts.DiagnosticMessageChain): SerializedMessageChain {
47
+ return {
48
+ messageText: chain.messageText,
49
+ category: chain.category,
50
+ code: chain.code,
51
+ next: chain.next?.map(serializeMessageChain),
52
+ };
53
+ }
54
+
55
+ function serializeMessageText(
56
+ messageText: string | ts.DiagnosticMessageChain,
57
+ ): string | SerializedMessageChain {
58
+ if (typeof messageText === "string") return messageText;
59
+ return serializeMessageChain(messageText);
16
60
  }
17
61
 
18
62
  /**
19
63
  * Diagnostic을 직렬화 가능한 형태로 변환한다.
20
64
  * (Worker 스레드 간 structured clone 통신을 위해 순환 참조/함수를 제거)
65
+ * messageText chain, relatedInformation, reportsUnnecessary/Deprecated, source 등 모든 detail 보존.
21
66
  */
22
67
  export function serializeDiagnostic(diagnostic: ts.Diagnostic): SerializedDiagnostic {
23
- // DiagnosticMessageChain인 경우 모든 컨텍스트 정보를 보존하기 위해 전체 체인을 평탄화
24
- const messageText = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n");
25
-
26
68
  return {
27
69
  category: diagnostic.category,
28
70
  code: diagnostic.code,
29
- messageText,
30
- file: diagnostic.file
31
- ? {
32
- fileName: diagnostic.file.fileName,
33
- }
34
- : undefined,
71
+ messageText: serializeMessageText(diagnostic.messageText),
72
+ file: diagnostic.file != null ? { fileName: diagnostic.file.fileName } : undefined,
35
73
  start: diagnostic.start,
36
74
  length: diagnostic.length,
75
+ relatedInformation: diagnostic.relatedInformation?.map((ri) => ({
76
+ category: ri.category,
77
+ code: ri.code,
78
+ messageText: serializeMessageText(ri.messageText),
79
+ file: ri.file != null ? { fileName: ri.file.fileName } : undefined,
80
+ start: ri.start,
81
+ length: ri.length,
82
+ })),
83
+ reportsUnnecessary: diagnostic.reportsUnnecessary != null ? true : undefined,
84
+ reportsDeprecated: diagnostic.reportsDeprecated != null ? true : undefined,
85
+ source: diagnostic.source,
37
86
  };
38
87
  }
39
88
 
@@ -53,32 +102,57 @@ function getScriptKind(fileName: string): ts.ScriptKind {
53
102
  * @param fileCache - 파일 내용 캐시 (같은 파일의 중복 읽기 방지)
54
103
  * @returns 복원된 ts.Diagnostic 객체
55
104
  */
105
+ function deserializeMessageChain(chain: SerializedMessageChain): ts.DiagnosticMessageChain {
106
+ return {
107
+ messageText: chain.messageText,
108
+ category: chain.category,
109
+ code: chain.code,
110
+ next: chain.next?.map(deserializeMessageChain),
111
+ };
112
+ }
113
+
114
+ function deserializeMessageText(
115
+ messageText: string | SerializedMessageChain,
116
+ ): string | ts.DiagnosticMessageChain {
117
+ if (typeof messageText === "string") return messageText;
118
+ return deserializeMessageChain(messageText);
119
+ }
120
+
121
+ function loadFile(fileName: string, fileCache: Map<string, string>): ts.SourceFile {
122
+ if (!fileCache.has(fileName)) {
123
+ fileCache.set(fileName, fsx.existsSync(fileName) ? fsx.readSync(fileName) : "");
124
+ }
125
+ const content = fileCache.get(fileName)!;
126
+ const scriptKind = getScriptKind(fileName);
127
+ return ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, false, scriptKind);
128
+ }
129
+
56
130
  export function deserializeDiagnostic(
57
131
  serialized: SerializedDiagnostic,
58
132
  fileCache: Map<string, string>,
59
133
  ): ts.Diagnostic {
60
- let file: ts.SourceFile | undefined;
61
- if (serialized.file != null) {
62
- const fileName = serialized.file.fileName;
134
+ const file = serialized.file != null ? loadFile(serialized.file.fileName, fileCache) : undefined;
63
135
 
64
- // 캐시된 파일 내용 가져오기 (없으면 읽어서 캐시)
65
- // 파일이 삭제되었거나 접근 불가능하면 빈 내용으로 처리
66
- // (소스 코드 컨텍스트는 표시되지 않지만 진단 메시지는 정상 표시됨)
67
- if (!fileCache.has(fileName)) {
68
- fileCache.set(fileName, fsx.existsSync(fileName) ? fsx.readSync(fileName) : "");
69
- }
70
- const content = fileCache.get(fileName)!;
71
-
72
- const scriptKind = getScriptKind(fileName);
73
- file = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, false, scriptKind);
74
- }
136
+ const relatedInformation: ts.DiagnosticRelatedInformation[] | undefined =
137
+ serialized.relatedInformation?.map((ri) => ({
138
+ category: ri.category,
139
+ code: ri.code,
140
+ messageText: deserializeMessageText(ri.messageText),
141
+ file: ri.file != null ? loadFile(ri.file.fileName, fileCache) : undefined,
142
+ start: ri.start,
143
+ length: ri.length,
144
+ }));
75
145
 
76
146
  return {
77
147
  category: serialized.category,
78
148
  code: serialized.code,
79
- messageText: serialized.messageText,
149
+ messageText: deserializeMessageText(serialized.messageText),
80
150
  file,
81
151
  start: serialized.start,
82
152
  length: serialized.length,
153
+ relatedInformation,
154
+ reportsUnnecessary: serialized.reportsUnnecessary === true ? {} : undefined,
155
+ reportsDeprecated: serialized.reportsDeprecated === true ? {} : undefined,
156
+ source: serialized.source,
83
157
  };
84
158
  }
@@ -1,8 +1,8 @@
1
1
  import { formatMessagesSync, type PartialMessage } from "esbuild";
2
- import { consola } from "consola";
2
+ import { createLazyLogger } from "../runtime/lazy-logger";
3
3
  import type { BuildResult } from "../runtime/ResultCollector";
4
4
 
5
- const logger = consola.withTag("sd:cli:output");
5
+ const logger = createLazyLogger("sd:cli:output");
6
6
 
7
7
  /**
8
8
  * esbuild Message 배열을 포맷된 문자열 배열로 변환한다.
@@ -1,6 +1,6 @@
1
1
  import path from "path";
2
- import { consola } from "consola";
3
2
  import { pathx } from "@simplysm/core-node";
3
+ import { createLazyLogger } from "../runtime/lazy-logger";
4
4
  import type {
5
5
  BuildTarget,
6
6
  SdBuildPackageConfig,
@@ -10,7 +10,7 @@ import type {
10
10
  SdServerPackageConfig,
11
11
  } from "../sd-config.types";
12
12
 
13
- const logger = consola.withTag("sd:cli:package-classify");
13
+ const logger = createLazyLogger("sd:cli:package-classify");
14
14
 
15
15
  /**
16
16
  * 패키지 config를 순회하며 null 필터링 + target 필터링을 수행한다.
@@ -1,14 +1,14 @@
1
1
  import path from "path";
2
2
  import fs from "fs";
3
- import { consola } from "consola";
4
3
  import { SdError } from "@simplysm/core-common";
5
4
  import { pathx } from "@simplysm/core-node";
5
+ import { createLazyLogger } from "../runtime/lazy-logger";
6
6
  import type {
7
7
  SdBuildPackageConfig,
8
8
  SdPackageConfig,
9
9
  } from "../sd-config.types";
10
10
 
11
- const logger = consola.withTag("sd:cli:package-utils");
11
+ const logger = createLazyLogger("sd:cli:package-utils");
12
12
 
13
13
  /**
14
14
  * import.meta.dirname에서 위로 탐색하여 package.json을 찾고 패키지 루트를 반환한다.
@@ -1,10 +1,10 @@
1
1
  import { createJiti } from "jiti";
2
2
  import { SdError } from "@simplysm/core-common";
3
3
  import { fsx, pathx } from "@simplysm/core-node";
4
- import { consola } from "consola";
4
+ import { createLazyLogger } from "../runtime/lazy-logger";
5
5
  import type { SdConfig, SdConfigParams } from "../sd-config.types";
6
6
 
7
- const logger = consola.withTag("sd:cli:sd-config");
7
+ const logger = createLazyLogger("sd:cli:sd-config");
8
8
 
9
9
  /**
10
10
  * sd.config.ts를 로드한다.
@@ -2,9 +2,9 @@ import path from "path";
2
2
  import fs from "fs";
3
3
  import ts from "typescript";
4
4
  import { pathx } from "@simplysm/core-node";
5
- import { consola } from "consola";
5
+ import { createLazyLogger } from "../runtime/lazy-logger";
6
6
 
7
- const logger = consola.withTag("sd:cli:tsconfig");
7
+ const logger = createLazyLogger("sd:cli:tsconfig");
8
8
 
9
9
  //#region TypecheckEnv
10
10
 
@@ -110,10 +110,9 @@ export function getPackageSourceFiles(
110
110
  const srcDir = path.join(pkgDir, "src");
111
111
  const files = parsedConfig.fileNames.filter((f) => {
112
112
  if (pathx.isChildPath(f, srcDir)) return true;
113
- if (f.endsWith(".fixture.ts")) return true;
114
113
  return false;
115
114
  });
116
- logger.debug(`소스 파일 필터링: ${parsedConfig.fileNames.length}개 중 ${files.length}개 (src/ + fixtures)`);
115
+ logger.debug(`소스 파일 필터링: ${parsedConfig.fileNames.length}개 중 ${files.length}개 (src/ only)`);
117
116
  return files;
118
117
  }
119
118
 
@@ -28,6 +28,36 @@ function createMockBuild(overrides?: Partial<esbuild.BuildOptions>): esbuild.Plu
28
28
  } as unknown as esbuild.PluginBuild;
29
29
  }
30
30
 
31
+ /**
32
+ * transformSync 호출을 추적할 수 있도록 esbuild 네임스페이스를 wrap한다.
33
+ * vi.spyOn이 ESM namespace frozen property에서 실패하므로 대안으로 사용한다.
34
+ */
35
+ function createTrackedBuild(overrides?: Partial<esbuild.BuildOptions>): {
36
+ build: esbuild.PluginBuild;
37
+ transformSyncCalls: Array<Parameters<typeof esbuild.transformSync>>;
38
+ } {
39
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "worker-unit-"));
40
+ const transformSyncCalls: Array<Parameters<typeof esbuild.transformSync>> = [];
41
+ const trackedEsbuild = {
42
+ ...esbuild,
43
+ transformSync: (...args: Parameters<typeof esbuild.transformSync>) => {
44
+ transformSyncCalls.push(args);
45
+ return esbuild.transformSync(...args);
46
+ },
47
+ } as typeof esbuild;
48
+
49
+ const build = {
50
+ esbuild: trackedEsbuild,
51
+ initialOptions: {
52
+ outdir: tmpDir,
53
+ write: false,
54
+ ...overrides,
55
+ },
56
+ } as unknown as esbuild.PluginBuild;
57
+
58
+ return { build, transformSyncCalls };
59
+ }
60
+
31
61
  describe("transformWorkerPatterns — 패턴 감지", () => {
32
62
  it("Worker 패턴이 없는 content에 대해 undefined를 반환한다", () => {
33
63
  const result = transformWorkerPatterns(
@@ -446,6 +476,14 @@ const x = 1;`,
446
476
 
447
477
  expect(matches).toHaveLength(0);
448
478
  });
479
+
480
+ it("래퍼 함수를 통한 Worker 생성은 탐지하지 않는다", () => {
481
+ const matches = findWorkerPatterns(
482
+ `const w = createBrowserWorker(new URL("./worker.js", import.meta.url), { type: "module" });`,
483
+ );
484
+
485
+ expect(matches).toHaveLength(0);
486
+ });
449
487
  });
450
488
 
451
489
  describe("transformWorkerPatterns — TypeScript 파일 처리", () => {
@@ -604,3 +642,210 @@ const w = new Worker(new URL("./worker.js", import.meta.url));`,
604
642
  expect(result.contents).toMatch(/worker-[a-z0-9]+\.js/i);
605
643
  });
606
644
  });
645
+
646
+ describe("transformWorkerPatterns — skipTsTransform 옵션", () => {
647
+ it("skipTsTransform: true + .ts 경로 + JS content → transformSync 호출 없이 정상 치환", () => {
648
+ const entryPath = path.join(fixturesDir, "entry.ts");
649
+ const { build, transformSyncCalls } = createTrackedBuild();
650
+
651
+ // ngtsc emit 결과를 흉내: 파일 경로는 .ts이지만 content는 이미 JS
652
+ const result = transformWorkerPatterns(
653
+ `const w = new Worker(new URL("./worker.js", import.meta.url));`,
654
+ entryPath,
655
+ build,
656
+ { skipTsTransform: true },
657
+ );
658
+
659
+ expect(transformSyncCalls).toHaveLength(0);
660
+ expect(result).toBeDefined();
661
+ expect(result!.contents).toMatch(/worker-[a-z0-9]+\.js/i);
662
+ expect(result!.errors).toHaveLength(0);
663
+ });
664
+
665
+ it("skipTsTransform: true + .ts 경로 + 실제 TS 구문 → 계약 위반으로 undefined (조용한 누락)", () => {
666
+ const entryPath = path.join(fixturesDir, "entry.ts");
667
+ const { build, transformSyncCalls } = createTrackedBuild();
668
+
669
+ // 호출자가 계약을 위반하여 실제 TS 구문을 넘긴 경우:
670
+ // transformSync가 스킵되므로 acorn이 TS 구문 파싱 실패 → 빈 matches → undefined
671
+ const result = transformWorkerPatterns(
672
+ `import type { T } from "pkg";
673
+ const w = new Worker(new URL("./worker.js", import.meta.url));`,
674
+ entryPath,
675
+ build,
676
+ { skipTsTransform: true },
677
+ );
678
+
679
+ expect(transformSyncCalls).toHaveLength(0);
680
+ expect(result).toBeUndefined();
681
+ });
682
+
683
+ it("옵션 생략 + .ts + import type + Worker → transformSync 호출 후 정상 감지 (후방 호환)", () => {
684
+ const entryPath = path.join(fixturesDir, "entry.ts");
685
+ const { build, transformSyncCalls } = createTrackedBuild();
686
+
687
+ const result = transformWorkerPatterns(
688
+ `import type { T } from "pkg";
689
+ const w = new Worker(new URL("./worker.js", import.meta.url));`,
690
+ entryPath,
691
+ build,
692
+ );
693
+
694
+ expect(transformSyncCalls).toHaveLength(1);
695
+ expect(transformSyncCalls[0][1]).toMatchObject({ loader: "ts" });
696
+ expect(result).toBeDefined();
697
+ expect(result!.contents).toMatch(/worker-[a-z0-9]+\.js/i);
698
+ });
699
+
700
+ it("옵션 { skipTsTransform: false } → 기본 동작 (transformSync 호출)", () => {
701
+ const entryPath = path.join(fixturesDir, "entry.ts");
702
+ const { build, transformSyncCalls } = createTrackedBuild();
703
+
704
+ const result = transformWorkerPatterns(
705
+ `import type { T } from "pkg";
706
+ const w = new Worker(new URL("./worker.js", import.meta.url));`,
707
+ entryPath,
708
+ build,
709
+ { skipTsTransform: false },
710
+ );
711
+
712
+ expect(transformSyncCalls).toHaveLength(1);
713
+ expect(result).toBeDefined();
714
+ expect(result!.contents).toMatch(/worker-[a-z0-9]+\.js/i);
715
+ });
716
+
717
+ it("빈 객체 {} → skipTsTransform undefined → 기본 동작 (transformSync 호출)", () => {
718
+ const entryPath = path.join(fixturesDir, "entry.ts");
719
+ const { build, transformSyncCalls } = createTrackedBuild();
720
+
721
+ const result = transformWorkerPatterns(
722
+ `import type { T } from "pkg";
723
+ const w = new Worker(new URL("./worker.js", import.meta.url));`,
724
+ entryPath,
725
+ build,
726
+ {},
727
+ );
728
+
729
+ expect(transformSyncCalls).toHaveLength(1);
730
+ expect(result).toBeDefined();
731
+ });
732
+
733
+ it("skipTsTransform: true + .js 경로 → 동작 동일 (확장자 분기 진입 안 함)", () => {
734
+ const entryPath = path.join(fixturesDir, "entry.js");
735
+ const { build, transformSyncCalls } = createTrackedBuild();
736
+
737
+ const result = transformWorkerPatterns(
738
+ `const w = new Worker(new URL("./worker.js", import.meta.url));`,
739
+ entryPath,
740
+ build,
741
+ { skipTsTransform: true },
742
+ );
743
+
744
+ expect(transformSyncCalls).toHaveLength(0);
745
+ expect(result).toBeDefined();
746
+ expect(result!.contents).toMatch(/worker-[a-z0-9]+\.js/i);
747
+ });
748
+ });
749
+
750
+ describe("transformWorkerPatterns — 사전 필터 정규식 경계", () => {
751
+ it("타입 어노테이션만 등장한 Worker 키워드 → 사전 필터 차단", () => {
752
+ const { build, transformSyncCalls } = createTrackedBuild();
753
+
754
+ const result = transformWorkerPatterns(
755
+ `const x: Worker = 1 as any;`,
756
+ path.join(fixturesDir, "entry.ts"),
757
+ build,
758
+ );
759
+
760
+ expect(transformSyncCalls).toHaveLength(0);
761
+ expect(result).toBeUndefined();
762
+ });
763
+
764
+ it("interface 선언에만 등장한 Worker → 사전 필터 차단", () => {
765
+ const { build, transformSyncCalls } = createTrackedBuild();
766
+
767
+ const result = transformWorkerPatterns(
768
+ `interface WorkerLike { run(): void; }`,
769
+ path.join(fixturesDir, "entry.ts"),
770
+ build,
771
+ );
772
+
773
+ expect(transformSyncCalls).toHaveLength(0);
774
+ expect(result).toBeUndefined();
775
+ });
776
+
777
+ it("import type에만 등장한 Worker → 사전 필터 차단", () => {
778
+ const { build, transformSyncCalls } = createTrackedBuild();
779
+
780
+ const result = transformWorkerPatterns(
781
+ `import type { Worker } from "./types";`,
782
+ path.join(fixturesDir, "entry.ts"),
783
+ build,
784
+ );
785
+
786
+ expect(transformSyncCalls).toHaveLength(0);
787
+ expect(result).toBeUndefined();
788
+ });
789
+
790
+ it("new Worker 호출 → 사전 필터 통과, AST 감지", () => {
791
+ const entryPath = path.join(fixturesDir, "entry.js");
792
+ const result = transformWorkerPatterns(
793
+ `const w = new Worker(new URL("./worker.js", import.meta.url));`,
794
+ entryPath,
795
+ createMockBuild(),
796
+ );
797
+
798
+ expect(result).toBeDefined();
799
+ expect(result!.contents).toMatch(/worker-[a-z0-9]+\.js/i);
800
+ });
801
+
802
+ it("new SharedWorker 호출 → 사전 필터 통과, AST 감지", () => {
803
+ const entryPath = path.join(fixturesDir, "entry.js");
804
+ const result = transformWorkerPatterns(
805
+ `const sw = new SharedWorker(new URL("./shared-worker.js", import.meta.url));`,
806
+ entryPath,
807
+ createMockBuild(),
808
+ );
809
+
810
+ expect(result).toBeDefined();
811
+ expect(result!.contents).toMatch(/worker-[a-z0-9]+\.js/i);
812
+ });
813
+
814
+ it("import.meta.resolve 호출 → 사전 필터 통과, AST 감지", () => {
815
+ const entryPath = path.join(fixturesDir, "entry.js");
816
+ const result = transformWorkerPatterns(
817
+ `const p = import.meta.resolve("./node-worker.js");`,
818
+ entryPath,
819
+ createMockBuild({ platform: "node" }),
820
+ );
821
+
822
+ expect(result).toBeDefined();
823
+ expect(result!.contents).toMatch(
824
+ /new URL\("worker-[a-z0-9]+\.js", import\.meta\.url\)\.href/i,
825
+ );
826
+ });
827
+
828
+ it("new Worker (연속 공백) → 사전 필터 통과", () => {
829
+ const entryPath = path.join(fixturesDir, "entry.js");
830
+ const result = transformWorkerPatterns(
831
+ `const w = new Worker(new URL("./worker.js", import.meta.url));`,
832
+ entryPath,
833
+ createMockBuild(),
834
+ );
835
+
836
+ expect(result).toBeDefined();
837
+ });
838
+
839
+ it("WorkerSubClass 식별자 (단어 경계 위반) → 사전 필터 차단", () => {
840
+ const { build, transformSyncCalls } = createTrackedBuild();
841
+
842
+ const result = transformWorkerPatterns(
843
+ `class WorkerSubClass {}\nconst x = new WorkerSubClass();`,
844
+ path.join(fixturesDir, "entry.ts"),
845
+ build,
846
+ );
847
+
848
+ expect(transformSyncCalls).toHaveLength(0);
849
+ expect(result).toBeUndefined();
850
+ });
851
+ });
@@ -134,4 +134,89 @@ describe("watchReplaceDeps onChanged 콜백", () => {
134
134
  // 콜백 호출 대기
135
135
  await changedPromise;
136
136
  }, 10_000);
137
+
138
+ it("동일 source를 참조하는 복수 entry가 있으면 모든 target에 복사된다", async () => {
139
+ // 추가 target 디렉토리 생성 (@test/pkgB)
140
+ const targetDirB = path.join(tmpDir, "project", "node_modules", "@test", "pkgB", "src");
141
+ await fs.promises.mkdir(targetDirB, { recursive: true });
142
+ await fs.promises.writeFile(path.join(targetDirB, "index.ts"), "export const a = 1;");
143
+
144
+ const projectRoot = path.join(tmpDir, "project");
145
+ const sourcePath = path.join(tmpDir, "source-pkg");
146
+
147
+ // 두 패턴이 동일 sourcePath를 참조
148
+ const replaceDeps = {
149
+ "@test/pkg": sourcePath,
150
+ "@test/pkgB": sourcePath,
151
+ };
152
+
153
+ let callCount = 0;
154
+ watchResult = await watchReplaceDeps(projectRoot, replaceDeps, {
155
+ onChanged: () => {
156
+ callCount++;
157
+ },
158
+ });
159
+
160
+ // 소스 파일 변경
161
+ await fs.promises.writeFile(
162
+ path.join(sourcePath, "src", "index.ts"),
163
+ "export const a = 9;",
164
+ );
165
+
166
+ // 배칭 + 복사 완료 대기
167
+ await new Promise((r) => setTimeout(r, 1500));
168
+
169
+ // 두 target 모두 업데이트
170
+ const targetAContent = await fs.promises.readFile(
171
+ path.join(tmpDir, "project", "node_modules", "@test", "pkg", "src", "index.ts"),
172
+ "utf-8",
173
+ );
174
+ const targetBContent = await fs.promises.readFile(
175
+ path.join(targetDirB, "index.ts"),
176
+ "utf-8",
177
+ );
178
+ expect(targetAContent).toBe("export const a = 9;");
179
+ expect(targetBContent).toBe("export const a = 9;");
180
+
181
+ // 동일 source의 중복 watchPath는 하나로 통합되므로 이벤트가 1회만 배칭됨 → onChanged 1회
182
+ expect(callCount).toBe(1);
183
+ }, 10_000);
184
+
185
+ it("소스 파일이 삭제되면 대상 파일도 삭제된다", async () => {
186
+ const projectRoot = path.join(tmpDir, "project");
187
+ const sourcePath = path.join(tmpDir, "source-pkg");
188
+ const replaceDeps = { "@test/pkg": sourcePath };
189
+
190
+ let resolveChanged: () => void;
191
+ const changedPromise = new Promise<void>((resolve) => {
192
+ resolveChanged = resolve;
193
+ });
194
+
195
+ watchResult = await watchReplaceDeps(projectRoot, replaceDeps, {
196
+ onChanged: () => {
197
+ resolveChanged();
198
+ },
199
+ });
200
+
201
+ const destPath = path.join(
202
+ tmpDir, "project", "node_modules", "@test", "pkg", "src", "index.ts",
203
+ );
204
+
205
+ // 삭제 전 target 파일 존재 확인
206
+ await expect(fs.promises.access(destPath)).resolves.toBeUndefined();
207
+
208
+ // 소스 삭제
209
+ await fs.promises.unlink(path.join(sourcePath, "src", "index.ts"));
210
+
211
+ await changedPromise;
212
+
213
+ // 삭제 반영까지 여유 대기
214
+ await new Promise((r) => setTimeout(r, 500));
215
+
216
+ // target도 삭제됨
217
+ const targetExists = await fs.promises
218
+ .access(destPath)
219
+ .then(() => true, () => false);
220
+ expect(targetExists).toBe(false);
221
+ }, 10_000);
137
222
  });