@simplysm/sd-cli 14.0.43 → 14.0.44

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 (56) hide show
  1. package/dist/angular/ngtsc-build-core.d.ts +12 -3
  2. package/dist/angular/ngtsc-build-core.d.ts.map +1 -1
  3. package/dist/angular/ngtsc-build-core.js +70 -6
  4. package/dist/angular/ngtsc-build-core.js.map +1 -1
  5. package/dist/commands/publish/version-upgrade.d.ts.map +1 -1
  6. package/dist/commands/publish/version-upgrade.js +15 -12
  7. package/dist/commands/publish/version-upgrade.js.map +1 -1
  8. package/dist/deps/replace-deps/replace-deps-resolve.d.ts.map +1 -1
  9. package/dist/deps/replace-deps/replace-deps-resolve.js +6 -7
  10. package/dist/deps/replace-deps/replace-deps-resolve.js.map +1 -1
  11. package/dist/deps/replace-deps/replace-deps.d.ts.map +1 -1
  12. package/dist/deps/replace-deps/replace-deps.js +79 -15
  13. package/dist/deps/replace-deps/replace-deps.js.map +1 -1
  14. package/dist/esbuild/esbuild-postcss-plugin.d.ts.map +1 -1
  15. package/dist/esbuild/esbuild-postcss-plugin.js +9 -6
  16. package/dist/esbuild/esbuild-postcss-plugin.js.map +1 -1
  17. package/dist/workers/client.worker.d.ts.map +1 -1
  18. package/dist/workers/client.worker.js +4 -25
  19. package/dist/workers/client.worker.js.map +1 -1
  20. package/dist/workers/incremental-mtime-tracker.d.ts +13 -0
  21. package/dist/workers/incremental-mtime-tracker.d.ts.map +1 -0
  22. package/dist/workers/incremental-mtime-tracker.js +65 -0
  23. package/dist/workers/incremental-mtime-tracker.js.map +1 -0
  24. package/dist/workers/library-build.worker.d.ts.map +1 -1
  25. package/dist/workers/library-build.worker.js +37 -15
  26. package/dist/workers/library-build.worker.js.map +1 -1
  27. package/package.json +4 -4
  28. package/src/angular/ngtsc-build-core.ts +73 -5
  29. package/src/commands/publish/version-upgrade.ts +43 -34
  30. package/src/deps/replace-deps/replace-deps-resolve.ts +12 -7
  31. package/src/deps/replace-deps/replace-deps.ts +90 -16
  32. package/src/esbuild/esbuild-postcss-plugin.ts +9 -6
  33. package/src/workers/client.worker.ts +4 -23
  34. package/src/workers/incremental-mtime-tracker.ts +68 -0
  35. package/src/workers/library-build.worker.ts +41 -14
  36. package/tests/angular/ngtsc-build-core.acc.spec.ts +210 -0
  37. package/tests/angular/ngtsc-build-core.spec.ts +52 -0
  38. package/tests/commands/version-upgrade.acc.spec.ts +210 -0
  39. package/tests/commands/version-upgrade.spec.ts +148 -0
  40. package/tests/deps/replace-deps/replace-deps-perf.verify.md +15 -0
  41. package/tests/deps/replace-deps/replace-deps-resolve.acc.spec.ts +124 -0
  42. package/tests/esbuild/esbuild-postcss-plugin-chunking.verify.md +17 -0
  43. package/tests/esbuild/esbuild-postcss-plugin.acc.spec.ts +152 -0
  44. package/tests/utils/ngtsc-build-core-write-emit.spec.ts +124 -0
  45. package/tests/workers/client-worker-mtime-incremental.verify.md +10 -0
  46. package/tests/workers/incremental-mtime-tracker.acc.spec.ts +144 -0
  47. package/tests/workers/incremental-mtime-tracker.spec.ts +102 -0
  48. package/tests/workers/library-build-worker.spec.ts +4 -0
  49. package/tests/ts-compiler/fixtures/non-angular-pkg/dist/index.d.ts +0 -2
  50. package/tests/ts-compiler/fixtures/non-angular-pkg/dist/index.d.ts.map +0 -1
  51. package/tests/ts-compiler/fixtures/non-angular-pkg/dist/index.js +0 -4
  52. package/tests/ts-compiler/fixtures/non-angular-pkg/dist/index.js.map +0 -1
  53. package/tests/ts-compiler/fixtures/non-angular-pkg/dist/util.d.ts +0 -2
  54. package/tests/ts-compiler/fixtures/non-angular-pkg/dist/util.d.ts.map +0 -1
  55. package/tests/ts-compiler/fixtures/non-angular-pkg/dist/util.js +0 -4
  56. package/tests/ts-compiler/fixtures/non-angular-pkg/dist/util.js.map +0 -1
@@ -46,28 +46,35 @@ export async function upgradeVersion(
46
46
  await fsx.write(projPkgPath, json.stringify(projPkg, { space: 2 }) + "\n");
47
47
  changedFiles.push(projPkgPath);
48
48
 
49
- // 각 패키지의 package.json에 버전 설정
50
- for (const pkgPath of allPkgPaths) {
51
- const pkgJsonPath = path.resolve(pkgPath, "package.json");
52
- const pkgJson = await fsx.readJson<PackageJson>(pkgJsonPath);
53
- pkgJson.version = newVersion;
54
- await fsx.write(pkgJsonPath, json.stringify(pkgJson, { space: 2 }) + "\n");
55
- changedFiles.push(pkgJsonPath);
56
- }
49
+ // 각 패키지의 package.json에 버전 설정 (병렬)
50
+ const pkgChangedFiles = await Promise.all(
51
+ allPkgPaths.map(async (pkgPath) => {
52
+ const pkgJsonPath = path.resolve(pkgPath, "package.json");
53
+ const pkgJson = await fsx.readJson<PackageJson>(pkgJsonPath);
54
+ pkgJson.version = newVersion;
55
+ await fsx.write(pkgJsonPath, json.stringify(pkgJson, { space: 2 }) + "\n");
56
+ return pkgJsonPath;
57
+ }),
58
+ );
59
+ changedFiles.push(...pkgChangedFiles);
57
60
 
58
61
  // 템플릿 파일의 @simplysm 패키지 버전 동기화
59
62
  const templateFiles = await fsx.glob(path.resolve(cwd, "packages/sd-cli/templates/**/*.hbs"));
60
63
  const versionRegex = /("@simplysm\/[^"]+"\s*:\s*)"~[^"]+"/g;
61
64
 
62
- for (const templatePath of templateFiles) {
63
- const content = await fsx.read(templatePath);
64
- const newContent = content.replace(versionRegex, `$1"~${newVersion}"`);
65
+ const templateChangedFiles = await Promise.all(
66
+ templateFiles.map(async (templatePath) => {
67
+ const content = await fsx.read(templatePath);
68
+ const newContent = content.replace(versionRegex, `$1"~${newVersion}"`);
65
69
 
66
- if (content !== newContent) {
67
- await fsx.write(templatePath, newContent);
68
- changedFiles.push(templatePath);
69
- }
70
- }
70
+ if (content !== newContent) {
71
+ await fsx.write(templatePath, newContent);
72
+ return templatePath;
73
+ }
74
+ return undefined;
75
+ }),
76
+ );
77
+ changedFiles.push(...templateChangedFiles.filter((f) => f != null));
71
78
 
72
79
  return { version: newVersion, changedFiles };
73
80
  }
@@ -81,25 +88,27 @@ export async function computePublishLevels<T extends { name: string; path: strin
81
88
  ): Promise<T[][]> {
82
89
  const pkgNames = new Set(publishPkgs.map((p) => p.name));
83
90
 
84
- // 각 패키지의 워크스페이스 의존성 수집
85
- const depsMap = new Map<string, Set<string>>();
86
- for (const pkg of publishPkgs) {
87
- const pkgJson = await fsx.readJson<PackageJson>(path.resolve(pkg.path, "package.json"));
88
- const allDeps = {
89
- ...pkgJson.dependencies,
90
- ...pkgJson.peerDependencies,
91
- ...pkgJson.optionalDependencies,
92
- };
93
-
94
- const workspaceDeps = new Set<string>();
95
- for (const depName of Object.keys(allDeps)) {
96
- const shortName = depName.replace(/^@simplysm\//, "");
97
- if (shortName !== depName && pkgNames.has(shortName)) {
98
- workspaceDeps.add(shortName);
91
+ // 각 패키지의 워크스페이스 의존성 수집 (병렬)
92
+ const depsEntries = await Promise.all(
93
+ publishPkgs.map(async (pkg) => {
94
+ const pkgJson = await fsx.readJson<PackageJson>(path.resolve(pkg.path, "package.json"));
95
+ const allDeps = {
96
+ ...pkgJson.dependencies,
97
+ ...pkgJson.peerDependencies,
98
+ ...pkgJson.optionalDependencies,
99
+ };
100
+
101
+ const workspaceDeps = new Set<string>();
102
+ for (const depName of Object.keys(allDeps)) {
103
+ const shortName = depName.replace(/^@simplysm\//, "");
104
+ if (shortName !== depName && pkgNames.has(shortName)) {
105
+ workspaceDeps.add(shortName);
106
+ }
99
107
  }
100
- }
101
- depsMap.set(pkg.name, workspaceDeps);
102
- }
108
+ return [pkg.name, workspaceDeps] as const;
109
+ }),
110
+ );
111
+ const depsMap = new Map(depsEntries);
103
112
 
104
113
  // 위상 정렬로 레벨 분류
105
114
  const levels: T[][] = [];
@@ -140,6 +140,7 @@ export async function resolveAllReplaceDepEntries(
140
140
  logger: ReturnType<typeof consola.withTag>,
141
141
  ): Promise<ReplaceDepEntry[]> {
142
142
  const entries: ReplaceDepEntry[] = [];
143
+ const seenTargetPaths = new Set<string>();
143
144
  const searchedDirs = new Set<string>();
144
145
 
145
146
  // 초기 탐색 대상: 프로젝트 루트 + workspace 패키지들의 node_modules
@@ -162,12 +163,15 @@ export async function resolveAllReplaceDepEntries(
162
163
  continue;
163
164
  }
164
165
 
165
- // replaceDeps의 각 glob 패턴으로 node_modules 디렉토리 탐색
166
- const targetNames: string[] = [];
167
- for (const pattern of Object.keys(replaceDeps)) {
168
- const matches = await glob(pattern, { cwd: nodeModulesDir });
169
- targetNames.push(...matches.map((m) => m.replaceAll("\\", "/")));
170
- }
166
+ // replaceDeps의 각 glob 패턴으로 node_modules 디렉토리 탐색 (병렬)
167
+ const globResults = await Promise.all(
168
+ Object.keys(replaceDeps).map((pattern) =>
169
+ glob(pattern, { cwd: nodeModulesDir }),
170
+ ),
171
+ );
172
+ const targetNames = globResults.flatMap((matches) =>
173
+ matches.map((m) => m.replaceAll("\\", "/")),
174
+ );
171
175
 
172
176
  logger.debug(`[replace-deps] 탐색: ${nodeModulesDir} → ${targetNames.length}개 매칭 (${targetNames.join(", ")})`);
173
177
 
@@ -200,7 +204,8 @@ export async function resolveAllReplaceDepEntries(
200
204
  }
201
205
 
202
206
  // 동일 actualTargetPath가 이미 등록된 경우 건너뜀 (pnpm 중복 방지)
203
- if (entries.some((e) => e.actualTargetPath === actualTargetPath)) continue;
207
+ if (seenTargetPaths.has(actualTargetPath)) continue;
208
+ seenTargetPaths.add(actualTargetPath);
204
209
 
205
210
  entries.push({
206
211
  targetName,
@@ -7,6 +7,70 @@ import { promisify } from "util";
7
7
  import type { ReplaceDepEntry } from "./replace-deps-resolve";
8
8
  import { resolveAllReplaceDepEntries } from "./replace-deps-resolve";
9
9
 
10
+ /**
11
+ * 파일 내용이 동일한지 비교한다.
12
+ * mtime + size가 같으면 동일로 간주하고, 다르면 바이트 비교한다.
13
+ */
14
+ async function isFileContentSame(pathA: string, pathB: string): Promise<boolean> {
15
+ try {
16
+ const [statA, statB] = await Promise.all([
17
+ fs.promises.stat(pathA),
18
+ fs.promises.stat(pathB),
19
+ ]);
20
+ if (statA.size !== statB.size) return false;
21
+ if (statA.mtimeMs === statB.mtimeMs) return true;
22
+
23
+ const [bufA, bufB] = await Promise.all([
24
+ fs.promises.readFile(pathA),
25
+ fs.promises.readFile(pathB),
26
+ ]);
27
+ return bufA.equals(bufB);
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * pnpm hard link를 끊으면서 파일/디렉토리를 복사한다.
35
+ * 대상 파일을 먼저 unlink하여 글로벌 store의 hard link를 끊고 새 파일을 생성한다.
36
+ * 다른 프로젝트의 node_modules에 영향을 주지 않기 위함이다.
37
+ */
38
+ async function copyWithUnlink(
39
+ sourcePath: string,
40
+ targetPath: string,
41
+ filter?: (absolutePath: string) => boolean,
42
+ ): Promise<void> {
43
+ let stats: fs.Stats;
44
+ try {
45
+ stats = await fs.promises.lstat(sourcePath);
46
+ } catch {
47
+ return;
48
+ }
49
+
50
+ if (stats.isDirectory()) {
51
+ await fsx.mkdir(targetPath);
52
+ const names = await fs.promises.readdir(sourcePath);
53
+ await Promise.all(
54
+ names
55
+ .map((name) => path.resolve(sourcePath, name))
56
+ .filter((child) => filter == null || filter(child))
57
+ .map((child) => copyWithUnlink(
58
+ child,
59
+ path.join(targetPath, path.basename(child)),
60
+ )),
61
+ );
62
+ } else {
63
+ if (await isFileContentSame(sourcePath, targetPath)) return;
64
+ await fsx.mkdir(path.dirname(targetPath));
65
+ try {
66
+ await fs.promises.unlink(targetPath);
67
+ } catch {
68
+ // 대상이 없으면 무시
69
+ }
70
+ await fs.promises.copyFile(sourcePath, targetPath);
71
+ }
72
+ }
73
+
10
74
  /**
11
75
  * npm publish 시 files 필드와 무관하게 항상 포함되는 파일 패턴 (대소문자 무시)
12
76
  */
@@ -69,33 +133,31 @@ export async function setupReplaceDeps(
69
133
  replaceDeps: Record<string, string>,
70
134
  ): Promise<void> {
71
135
  const logger = consola.withTag("sd:cli:replace-deps");
72
- let setupCount = 0;
73
136
 
74
137
  logger.start("replace-deps 설정 중...");
75
138
 
76
139
  const entries = await resolveAllReplaceDepEntries(projectRoot, replaceDeps, logger);
77
- const copiedEntries: ReplaceDepEntry[] = [];
78
140
 
79
- for (const entry of entries) {
141
+ const results = await Promise.all(entries.map(async (entry) => {
80
142
  try {
81
- // 소스 패키지의 files 필드를 읽어 화이트리스트 구성
82
143
  const files = await loadFilesField(entry.resolvedSourcePath);
83
144
  if (files == null) {
84
145
  logger.warn(`[${entry.targetName}] package.json에 files 필드가 없어 건너뜀`);
85
- continue;
146
+ return undefined;
86
147
  }
87
148
 
88
149
  const filter = createCopyFilter(entry.resolvedSourcePath, new Set(files));
89
- await fsx.copy(entry.resolvedSourcePath, entry.actualTargetPath, filter);
90
-
91
- copiedEntries.push(entry);
92
- setupCount += 1;
150
+ await copyWithUnlink(entry.resolvedSourcePath, entry.actualTargetPath, filter);
151
+ return entry;
93
152
  } catch (err) {
94
153
  logger.error(`[${entry.targetName}] 복사 실패: ${err instanceof Error ? err.message : err}`);
154
+ return undefined;
95
155
  }
96
- }
156
+ }));
97
157
 
98
- logger.success(`replace-deps 설정 완료 (${setupCount}개 의존성 교체)`);
158
+ const copiedEntries = results.filter((e): e is ReplaceDepEntry => e != null);
159
+
160
+ logger.success(`replace-deps 설정 완료 (${copiedEntries.length}개 의존성 교체)`);
99
161
 
100
162
  // 교체된 패키지의 postinstall 스크립트 실행
101
163
  for (const { targetName, resolvedSourcePath, actualTargetPath } of copiedEntries) {
@@ -174,15 +236,20 @@ export async function watchReplaceDeps(
174
236
  // readdir 실패 시 npm 기본 파일 감시 생략
175
237
  }
176
238
 
239
+ // 이 소스 경로에 해당하는 entries만 사전 필터링하여 캡처
240
+ const sourceEntries = entries.filter(
241
+ (e) => e.resolvedSourcePath === entry.resolvedSourcePath,
242
+ );
243
+
177
244
  const watcher = await FsWatcher.watch(watchPaths, {
178
245
  followSymlinks: false,
179
246
  });
180
247
  watcher.onChange({ delay: 300 }, async (changeInfos) => {
181
- for (const { path: changedPath } of changeInfos) {
182
- // 이 소스 경로를 사용하는 모든 항목에 대해 복사
183
- for (const e of entries) {
184
- if (e.resolvedSourcePath !== entry.resolvedSourcePath) continue;
248
+ let hasActualCopy = false;
185
249
 
250
+ for (const { path: changedPath } of changeInfos) {
251
+ // 사전 필터링된 항목만 순회
252
+ for (const e of sourceEntries) {
186
253
  // 소스로부터의 상대 경로 계산
187
254
  const relativePath = pathx.posix(path.relative(e.resolvedSourcePath, changedPath));
188
255
  const destPath = pathx.posix(path.join(e.actualTargetPath, relativePath));
@@ -203,12 +270,16 @@ export async function watchReplaceDeps(
203
270
  if (stat.isDirectory()) {
204
271
  await fsx.mkdir(destPath);
205
272
  } else {
273
+ // 파일 내용이 동일하면 복사 건너뜀 (불필요한 리빌드 방지)
274
+ if (await isFileContentSame(changedPath, destPath)) continue;
206
275
  await fsx.mkdir(pathx.posix(path.dirname(destPath)));
207
276
  await fsx.copy(changedPath, destPath);
277
+ hasActualCopy = true;
208
278
  }
209
279
  } else {
210
280
  // 소스가 삭제됨 → 대상도 삭제
211
281
  await fsx.rm(destPath);
282
+ hasActualCopy = true;
212
283
  }
213
284
  } catch (err) {
214
285
  logger.error(
@@ -217,7 +288,10 @@ export async function watchReplaceDeps(
217
288
  }
218
289
  }
219
290
  }
220
- options?.onChanged?.();
291
+
292
+ if (hasActualCopy) {
293
+ options?.onChanged?.();
294
+ }
221
295
  });
222
296
 
223
297
  watchers.push(watcher);
@@ -88,16 +88,19 @@ export function createPostcssPlugin(options: CreatePostcssPluginOptions): esbuil
88
88
 
89
89
  if (replacements.length === 0) continue;
90
90
 
91
- // PostCSS 적용 역순 교체
92
- let modified = code;
93
- // 끝에서 시작 방향으로 교체하여 offset 유지
94
- const sorted = replacements.sort((a, b) => b.start - a.start);
91
+ // PostCSS 적용 정방향 청크 배열 + join
92
+ const sorted = replacements.sort((a, b) => a.start - b.start);
93
+ const chunks: string[] = [];
94
+ let cursor = 0;
95
95
 
96
96
  for (const rep of sorted) {
97
+ chunks.push(code.slice(cursor, rep.start));
97
98
  const processed = await processor.process(rep.text, { from: file });
98
- const escaped = JSON.stringify(processed.css);
99
- modified = modified.slice(0, rep.start) + escaped + modified.slice(rep.end);
99
+ chunks.push(JSON.stringify(processed.css));
100
+ cursor = rep.end;
100
101
  }
102
+ chunks.push(code.slice(cursor));
103
+ const modified = chunks.join("");
101
104
 
102
105
  await fs.promises.writeFile(file, modified);
103
106
  } catch (e: unknown) {
@@ -17,6 +17,7 @@ import { copyPublicFiles, watchPublicFiles } from "../utils/copy-public";
17
17
  import type { SdBrowserSupportConfig, SdPwaConfig } from "../sd-config.types";
18
18
  import type esbuild from "esbuild";
19
19
  import type { PartialMessage } from "esbuild";
20
+ import { IncrementalMtimeTracker } from "./incremental-mtime-tracker";
20
21
 
21
22
  //#region Types
22
23
 
@@ -194,32 +195,19 @@ function createSourceFileCachePlugin(): esbuild.Plugin {
194
195
  return {
195
196
  name: "sd-build-start",
196
197
  setup(pluginBuild: esbuild.PluginBuild) {
197
- const prevMtimes = new Map<string, number>();
198
+ const mtimeTracker = new IncrementalMtimeTracker();
198
199
 
199
200
  pluginBuild.onStart(() => {
200
201
  // sourceFileCache 무효화: 변경된 파일의 loadResultCache + TypeScript 소스 캐시 모두 제거
201
202
  if (esbuildResult != null) {
202
203
  const { loadResultCache, typeScriptFileCache } =
203
204
  esbuildResult.sourceFileCache;
204
- const changedFiles = new Set<string>();
205
205
  // JS 파일 (loadResultCache) + TS 파일 (typeScriptFileCache) 모두 감시
206
206
  const watchTargets = [
207
207
  ...loadResultCache.watchFiles,
208
208
  ...typeScriptFileCache.keys(),
209
209
  ];
210
- for (const file of watchTargets) {
211
- try {
212
- const mtime = fs.statSync(file).mtimeMs;
213
- const prev = prevMtimes.get(file);
214
- if (prev != null && prev !== mtime) {
215
- changedFiles.add(file);
216
- }
217
- } catch {
218
- if (prevMtimes.has(file)) {
219
- changedFiles.add(file);
220
- }
221
- }
222
- }
210
+ const changedFiles = mtimeTracker.detectChanges(watchTargets);
223
211
  if (changedFiles.size > 0) {
224
212
  esbuildResult.sourceFileCache.invalidate(changedFiles);
225
213
  }
@@ -232,19 +220,12 @@ function createSourceFileCachePlugin(): esbuild.Plugin {
232
220
 
233
221
  pluginBuild.onEnd(() => {
234
222
  if (esbuildResult == null) return;
235
- prevMtimes.clear();
236
223
  // JS 파일 (loadResultCache) + TS 파일 (typeScriptFileCache) 모두 기록
237
224
  const watchTargets = [
238
225
  ...esbuildResult.sourceFileCache.loadResultCache.watchFiles,
239
226
  ...esbuildResult.sourceFileCache.typeScriptFileCache.keys(),
240
227
  ];
241
- for (const file of watchTargets) {
242
- try {
243
- prevMtimes.set(file, fs.statSync(file).mtimeMs);
244
- } catch {
245
- // 삭제된 파일
246
- }
247
- }
228
+ mtimeTracker.updateMtimes(watchTargets);
248
229
  });
249
230
  },
250
231
  };
@@ -0,0 +1,68 @@
1
+ import fs from "node:fs";
2
+
3
+ /**
4
+ * 증분 방식으로 파일 mtime을 추적하여 변경 파일을 감지한다.
5
+ *
6
+ * - `detectChanges`: watchTargets 전체를 stat하여 이전 mtime과 비교, 변경 파일 Set 반환
7
+ * - `updateMtimes`: 변경/신규 파일만 stat하여 prevMtimes를 증분 갱신
8
+ */
9
+ export class IncrementalMtimeTracker {
10
+ private readonly _prevMtimes = new Map<string, number>();
11
+ private _lastChangedFiles = new Set<string>();
12
+
13
+ detectChanges(watchTargets: Iterable<string>): Set<string> {
14
+ const changedFiles = new Set<string>();
15
+ for (const file of watchTargets) {
16
+ try {
17
+ const mtime = fs.statSync(file).mtimeMs;
18
+ const prev = this._prevMtimes.get(file);
19
+ if (prev != null && prev !== mtime) {
20
+ changedFiles.add(file);
21
+ }
22
+ } catch {
23
+ if (this._prevMtimes.has(file)) {
24
+ changedFiles.add(file);
25
+ }
26
+ }
27
+ }
28
+ this._lastChangedFiles = changedFiles;
29
+ return changedFiles;
30
+ }
31
+
32
+ updateMtimes(currentWatchTargets: Iterable<string>): void {
33
+ const targetSet =
34
+ currentWatchTargets instanceof Set
35
+ ? (currentWatchTargets as Set<string>)
36
+ : new Set(currentWatchTargets);
37
+
38
+ // 1) 삭제된 파일 정리: prevMtimes에 있지만 watchTargets에 없는 파일 제거
39
+ for (const file of this._prevMtimes.keys()) {
40
+ if (!targetSet.has(file)) {
41
+ this._prevMtimes.delete(file);
42
+ }
43
+ }
44
+
45
+ // 2) 변경된 파일 mtime 재조회 (watchTargets에 있는 파일만)
46
+ for (const file of this._lastChangedFiles) {
47
+ if (!targetSet.has(file)) continue;
48
+ try {
49
+ this._prevMtimes.set(file, fs.statSync(file).mtimeMs);
50
+ } catch {
51
+ this._prevMtimes.delete(file);
52
+ }
53
+ }
54
+
55
+ // 3) 신규 파일만 stat (prevMtimes에 없는 파일)
56
+ for (const file of targetSet) {
57
+ if (!this._prevMtimes.has(file)) {
58
+ try {
59
+ this._prevMtimes.set(file, fs.statSync(file).mtimeMs);
60
+ } catch {
61
+ // 삭제된 파일 — 무시
62
+ }
63
+ }
64
+ }
65
+
66
+ this._lastChangedFiles = new Set();
67
+ }
68
+ }
@@ -7,7 +7,7 @@ import type { SerializedDiagnostic } from "../typecheck/typecheck-serialization"
7
7
  import type { LintWithProgramResult } from "../lint/lint-with-program";
8
8
  import { SdTsCompiler } from "../ts-compiler/SdTsCompiler";
9
9
  import type { ISdTsCompilerResult } from "../ts-compiler/sd-ts-compiler-result";
10
- import { writeEmitResults, compileSideEffectScss } from "../angular/ngtsc-build-core";
10
+ import { writeEmitResults, compileSideEffectScss, buildReverseDeps } from "../angular/ngtsc-build-core";
11
11
  import { setupWorkerLifecycle } from "./shared-worker-lifecycle";
12
12
  import { buildWatchPaths } from "./build-watch-paths";
13
13
  import { hasFileAddOrRemove, shouldSkipRebuild } from "./build-change-filter";
@@ -46,6 +46,9 @@ export interface LibraryBuildWorkerEvents extends Record<string, unknown> {
46
46
  let fsWatcher: FsWatcher | undefined;
47
47
  let compiler: SdTsCompiler | undefined;
48
48
  let combinedScssDeps = new Map<string, Set<string>>();
49
+ let reverseScssDeps = new Map<string, Set<string>>();
50
+ let registryReverseIndex = new Map<string, Set<string>>();
51
+ let sideEffectScssDeps = new Map<string, Set<string>>();
49
52
 
50
53
  async function cleanup(): Promise<void> {
51
54
  const watcherToClose = fsWatcher;
@@ -53,6 +56,9 @@ async function cleanup(): Promise<void> {
53
56
  compiler = undefined;
54
57
  lastSourceFilePaths = undefined;
55
58
  combinedScssDeps = new Map();
59
+ reverseScssDeps = new Map();
60
+ registryReverseIndex = new Map();
61
+ sideEffectScssDeps = new Map();
56
62
  await watcherToClose?.close();
57
63
  }
58
64
 
@@ -93,13 +99,17 @@ async function build(info: LibraryBuildInfo): Promise<LibraryBuildResult> {
93
99
  scssErrors: sideEffectScssErrors,
94
100
  scssDependencies: combinedScssDeps,
95
101
  registry: compiler.sideEffectScssRegistry,
102
+ registryReverseIndex,
103
+ sideEffectScssDeps,
96
104
  });
97
- // 초기 빌드: 등록된 side-effect SCSS 전체 컴파일
105
+ // 초기 빌드: 등록된 side-effect SCSS 전체 컴파일 (changedScssFiles 미전달)
98
106
  compileSideEffectScss(
99
107
  compiler.sideEffectScssRegistry,
100
108
  loadPaths,
101
109
  sideEffectScssErrors,
102
110
  combinedScssDeps,
111
+ undefined,
112
+ sideEffectScssDeps,
103
113
  );
104
114
  } else {
105
115
  writeEmitResults(filteredEmitResults, info.pkgDir);
@@ -147,12 +157,18 @@ function extractSourceFilePaths(program: ts.Program | undefined): Set<string> |
147
157
  return paths;
148
158
  }
149
159
 
150
- /** compile-time SCSS 의존성으로 combinedScssDeps를 갱신한다 */
160
+ /** compile-time SCSS 의존성으로 combinedScssDeps와 reverseScssDeps를 갱신한다 */
151
161
  function updateCombinedScssDeps(result: ISdTsCompilerResult): void {
152
162
  combinedScssDeps = new Map();
153
163
  for (const [file, deps] of result.scssDependencies) {
154
164
  combinedScssDeps.set(file, new Set(deps));
155
165
  }
166
+ rebuildReverseScssDeps();
167
+ }
168
+
169
+ /** combinedScssDeps에서 역방향 인덱스를 재구축한다 */
170
+ function rebuildReverseScssDeps(): void {
171
+ reverseScssDeps = buildReverseDeps(combinedScssDeps);
156
172
  }
157
173
 
158
174
  /**
@@ -162,7 +178,7 @@ function updateCombinedScssDeps(result: ISdTsCompilerResult): void {
162
178
  function buildWatchEvent(
163
179
  info: LibraryBuildInfo,
164
180
  result: ISdTsCompilerResult,
165
- hasScssChanges: boolean,
181
+ changedScssFiles?: ReadonlySet<string>,
166
182
  ): CombinedBuildEvent {
167
183
  const isAngular = info.output.globalScss === true;
168
184
 
@@ -182,17 +198,25 @@ function buildWatchEvent(
182
198
  scssErrors: sideEffectScssErrors,
183
199
  scssDependencies: combinedScssDeps,
184
200
  registry: compiler!.sideEffectScssRegistry,
201
+ registryReverseIndex,
202
+ sideEffectScssDeps,
185
203
  },
186
204
  );
187
205
 
188
- // SCSS 변경 시 side-effect SCSS 재컴파일
189
- if (hasScssChanges) {
206
+ // side-effect SCSS 재컴파일
207
+ // changedScssFiles 미제공 = 초기 빌드 → 전체 컴파일
208
+ // changedScssFiles 제공 + size > 0 → 증분 컴파일
209
+ if (changedScssFiles == null || changedScssFiles.size > 0) {
190
210
  compileSideEffectScss(
191
211
  compiler!.sideEffectScssRegistry,
192
212
  loadPaths,
193
213
  sideEffectScssErrors,
194
214
  combinedScssDeps,
215
+ changedScssFiles,
216
+ sideEffectScssDeps,
195
217
  );
218
+ // side-effect SCSS 의존성이 combinedScssDeps에 추가되었으므로 역방향 인덱스 재구축
219
+ rebuildReverseScssDeps();
196
220
  }
197
221
 
198
222
  // 모든 에러 통합
@@ -240,7 +264,7 @@ async function startWatch(info: LibraryBuildInfo): Promise<void> {
240
264
  if (isAngular) {
241
265
  updateCombinedScssDeps(initialResult);
242
266
  }
243
- const initialEvent = buildWatchEvent(info, initialResult, isAngular);
267
+ const initialEvent = buildWatchEvent(info, initialResult);
244
268
  sender.send("build", initialEvent);
245
269
 
246
270
  // workspace 의존성 경로 + replaceDeps 수집
@@ -265,12 +289,13 @@ async function startWatch(info: LibraryBuildInfo): Promise<void> {
265
289
  for (const f of changedFiles) {
266
290
  modifiedFiles.add(f.path);
267
291
 
268
- // Angular: SCSS 역방향 의존성 탐색
292
+ // Angular: SCSS 역방향 의존성 탐색 (O(1) 조회)
269
293
  if (isAngular && (f.path.endsWith(".scss") || f.path.endsWith(".css"))) {
270
294
  const normalizedPath = pathx.posix(f.path);
271
- for (const [ownerFile, deps] of combinedScssDeps) {
272
- if (deps.has(normalizedPath)) {
273
- modifiedFiles.add(ownerFile);
295
+ const owners = reverseScssDeps.get(normalizedPath);
296
+ if (owners != null) {
297
+ for (const owner of owners) {
298
+ modifiedFiles.add(owner);
274
299
  }
275
300
  }
276
301
  }
@@ -289,11 +314,13 @@ async function startWatch(info: LibraryBuildInfo): Promise<void> {
289
314
  updateCombinedScssDeps(result);
290
315
  }
291
316
 
292
- const hasScssChanges = changedFiles.some(
293
- (f) => f.path.endsWith(".scss") || f.path.endsWith(".css"),
317
+ const changedScssFiles = new Set(
318
+ changedFiles
319
+ .filter((f) => f.path.endsWith(".scss") || f.path.endsWith(".css"))
320
+ .map((f) => pathx.posix(f.path)),
294
321
  );
295
322
 
296
- const event = buildWatchEvent(info, result, hasScssChanges);
323
+ const event = buildWatchEvent(info, result, changedScssFiles);
297
324
  sender.send("build", event);
298
325
  } catch (err) {
299
326
  sender.send("error", { message: errNs.message(err) });