@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
@@ -0,0 +1,124 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import os from "os";
5
+ import { pathx } from "@simplysm/core-node";
6
+ import { resolveAllReplaceDepEntries } from "../../../src/deps/replace-deps/replace-deps-resolve";
7
+ import { consola } from "consola";
8
+
9
+ describe("resolveAllReplaceDepEntries", () => {
10
+ let tmpDir: string;
11
+ let projectRoot: string;
12
+ const logger = consola.withTag("test");
13
+
14
+ beforeEach(async () => {
15
+ tmpDir = pathx.posix(await fs.promises.mkdtemp(path.join(os.tmpdir(), "sd-resolve-deps-")));
16
+ projectRoot = pathx.posix(path.join(tmpDir, "project"));
17
+ await fs.promises.mkdir(projectRoot, { recursive: true });
18
+ await fs.promises.writeFile(
19
+ pathx.posix(path.join(projectRoot, "pnpm-workspace.yaml")),
20
+ "packages:\n",
21
+ );
22
+ });
23
+
24
+ afterEach(async () => {
25
+ await fs.promises.rm(tmpDir, { recursive: true, force: true });
26
+ });
27
+
28
+ /**
29
+ * node_modules에 패키지 디렉토리를 생성하는 헬퍼
30
+ */
31
+ async function createNodeModulesPkg(
32
+ nodeModulesDir: string,
33
+ pkgName: string,
34
+ ): Promise<string> {
35
+ const pkgDir = pathx.posix(path.join(nodeModulesDir, pkgName));
36
+ await fs.promises.mkdir(pkgDir, { recursive: true });
37
+ await fs.promises.writeFile(
38
+ pathx.posix(path.join(pkgDir, "package.json")),
39
+ JSON.stringify({ name: pkgName }),
40
+ );
41
+ return pkgDir;
42
+ }
43
+
44
+ /**
45
+ * 소스 패키지를 생성하는 헬퍼
46
+ */
47
+ async function createSourcePkg(name: string): Promise<string> {
48
+ const sourcePath = pathx.posix(path.join(tmpDir, name));
49
+ await fs.promises.mkdir(sourcePath, { recursive: true });
50
+ await fs.promises.writeFile(
51
+ pathx.posix(path.join(sourcePath, "package.json")),
52
+ JSON.stringify({ name, files: ["dist"] }),
53
+ );
54
+ await fs.promises.mkdir(pathx.posix(path.join(sourcePath, "dist")), { recursive: true });
55
+ await fs.promises.writeFile(
56
+ pathx.posix(path.join(sourcePath, "dist", "index.js")),
57
+ "module.exports = {};",
58
+ );
59
+ return sourcePath;
60
+ }
61
+
62
+ it("동일 actualTargetPath를 가진 항목은 중복 등록되지 않는다", async () => {
63
+ // Given: 루트 node_modules와 workspace pkg의 node_modules에 동일 패키지가 존재하고
64
+ // 둘 다 같은 실제 경로(symlink 해석 후)를 가리킨다
65
+ const sourcePath = await createSourcePkg("@test/pkg");
66
+ const nodeModulesDir = pathx.posix(path.join(projectRoot, "node_modules"));
67
+ await createNodeModulesPkg(nodeModulesDir, "@test/pkg");
68
+
69
+ // workspace 패키지 설정 (pnpm-workspace.yaml에 추가)
70
+ const workspacePkgDir = pathx.posix(path.join(projectRoot, "packages", "my-app"));
71
+ await fs.promises.mkdir(workspacePkgDir, { recursive: true });
72
+ await fs.promises.writeFile(
73
+ pathx.posix(path.join(projectRoot, "pnpm-workspace.yaml")),
74
+ "packages:\n - packages/*\n",
75
+ );
76
+
77
+ // workspace 패키지의 node_modules에도 동일 패키지 생성
78
+ const wsPkgNodeModules = pathx.posix(path.join(workspacePkgDir, "node_modules"));
79
+ await createNodeModulesPkg(wsPkgNodeModules, "@test/pkg");
80
+
81
+ // When
82
+ const entries = await resolveAllReplaceDepEntries(
83
+ projectRoot,
84
+ { "@test/pkg": sourcePath },
85
+ logger,
86
+ );
87
+
88
+ // Then: actualTargetPath가 다르므로 2개가 반환되어야 한다
89
+ // (symlink가 아닌 실제 디렉토리이므로 actualTargetPath가 각각 다름)
90
+ // 중복 방지 로직은 동일 actualTargetPath일 때만 작동한다
91
+ const actualTargetPaths = entries.map((e) => e.actualTargetPath);
92
+ const uniqueActualTargetPaths = new Set(actualTargetPaths);
93
+ expect(actualTargetPaths.length).toBe(uniqueActualTargetPaths.size);
94
+ });
95
+
96
+ it("여러 replaceDeps 패턴이 올바르게 매칭되어 결과를 반환한다", async () => {
97
+ // Given: 2개의 다른 패턴과 각각의 소스 패키지
98
+ const sourceA = await createSourcePkg("@test/pkg-a");
99
+ const sourceB = await createSourcePkg("@other/lib");
100
+ const nodeModulesDir = pathx.posix(path.join(projectRoot, "node_modules"));
101
+ await createNodeModulesPkg(nodeModulesDir, "@test/pkg-a");
102
+ await createNodeModulesPkg(nodeModulesDir, "@other/lib");
103
+
104
+ // When: 2개 패턴으로 호출 (glob이 병렬 실행되어야 함)
105
+ const entries = await resolveAllReplaceDepEntries(
106
+ projectRoot,
107
+ {
108
+ "@test/pkg-a": sourceA,
109
+ "@other/lib": sourceB,
110
+ },
111
+ logger,
112
+ );
113
+
114
+ // Then: 두 패턴 모두 매칭되어 2개 entries 반환
115
+ expect(entries).toHaveLength(2);
116
+ const targetNames = entries.map((e) => e.targetName).sort();
117
+ expect(targetNames).toEqual(["@other/lib", "@test/pkg-a"]);
118
+ });
119
+
120
+ it("빈 replaceDeps 설정이면 빈 배열을 반환한다", async () => {
121
+ const entries = await resolveAllReplaceDepEntries(projectRoot, {}, logger);
122
+ expect(entries).toEqual([]);
123
+ });
124
+ });
@@ -0,0 +1,17 @@
1
+ # PostCSS 문자열 교체 청크화 — LLM 검증
2
+
3
+ ## 검증 항목
4
+
5
+ - [x] 정방향 청크 배열 + join이 역순 slice와 수학적으로 동일한 결과를 생성하는가:
6
+ 역순 slice 방식은 `code[0..start_n] + rep_n + code[end_n..start_{n-1}] + rep_{n-1} + ... + code[end_1..]` 순서로 최종 문자열을 구성한다.
7
+ 정방향 청크 방식은 `code[0..start_1] + rep_1 + code[end_1..start_2] + rep_2 + ... + code[end_n..]` 순서로 청크를 수집하고 join한다.
8
+ 두 방식 모두 원본 텍스트의 비교체 구간과 PostCSS 처리된 교체 텍스트를 동일한 순서로 결합하므로, replacements가 비중첩(acorn AST 노드 보장)인 한 결과가 동일하다.
9
+ 검증 결과: **동일성 보장됨**
10
+
11
+ - [x] cursor 초기값 0과 마지막 `code.slice(cursor)` 호출이 파일 전체를 커버하는가:
12
+ cursor는 0에서 시작하여 각 replacement의 end로 갱신된다. 마지막 `chunks.push(code.slice(cursor))`가 마지막 replacement 이후의 원본 텍스트를 수집한다. replacements가 0건이면 `code.slice(0)` = 전체 코드가 되지만, 이 경우 `replacements.length === 0`에서 continue되므로 도달하지 않는다.
13
+ 검증 결과: **전체 커버됨**
14
+
15
+ - [x] `replacements.sort()`가 원본 배열을 변경하는 것이 문제되지 않는가:
16
+ `replacements`는 각 파일 처리 시 새로 생성되는 로컬 배열이므로 in-place sort가 안전하다. 기존 코드도 동일하게 in-place sort를 사용했다.
17
+ 검증 결과: **문제 없음**
@@ -0,0 +1,152 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import type esbuild from "esbuild";
3
+ import fs from "fs";
4
+ import path from "path";
5
+ import os from "os";
6
+ import type { AcceptedPlugin } from "postcss";
7
+ import { createPostcssPlugin } from "../../src/esbuild/esbuild-postcss-plugin";
8
+
9
+ /** esbuild 플러그인 lifecycle을 시뮬레이션하는 헬퍼 */
10
+ function setupPlugin(plugin: esbuild.Plugin) {
11
+ let onEndCb:
12
+ | ((
13
+ result: esbuild.BuildResult,
14
+ ) => esbuild.OnEndResult | null | void | Promise<esbuild.OnEndResult | null | void>)
15
+ | undefined;
16
+
17
+ const mockBuild = {
18
+ onEnd(cb: typeof onEndCb) {
19
+ onEndCb = cb;
20
+ },
21
+ } as unknown as esbuild.PluginBuild;
22
+
23
+ void plugin.setup(mockBuild);
24
+
25
+ return {
26
+ async invokeOnEnd(result: Partial<esbuild.BuildResult>) {
27
+ return (
28
+ (await onEndCb?.({
29
+ errors: [],
30
+ warnings: [],
31
+ mangleCache: {},
32
+ outputFiles: [],
33
+ metafile: { inputs: {}, outputs: {} },
34
+ ...result,
35
+ } as esbuild.BuildResult)) ?? null
36
+ );
37
+ },
38
+ };
39
+ }
40
+
41
+ /** 테스트용 PostCSS 플러그인: 모든 CSS 앞에 주석을 추가 */
42
+ function createTestPostcssPlugin(): AcceptedPlugin {
43
+ return {
44
+ postcssPlugin: "test-prefix",
45
+ Once(root) {
46
+ root.prepend({ text: "processed" });
47
+ },
48
+ };
49
+ }
50
+
51
+ /** ɵɵdefineComponent styles 배열을 포함하는 JS 코드를 생성 (실제 Angular 컴파일 출력 패턴) */
52
+ function generateJsWithStyles(styles: string[]): string {
53
+ const stylesLiteral = styles.map((s) => JSON.stringify(s)).join(", ");
54
+ return [
55
+ `import * as i0 from "@angular/core";`,
56
+ `class MyComponent {}`,
57
+ `MyComponent.\u0275cmp = /*@__PURE__*/ i0.\u0275\u0275defineComponent({`,
58
+ ` type: MyComponent,`,
59
+ ` styles: [${stylesLiteral}],`,
60
+ ` template: function() {}`,
61
+ `});`,
62
+ `export { MyComponent };`,
63
+ ].join("\n");
64
+ }
65
+
66
+ describe("createPostcssPlugin — Acceptance Tests", () => {
67
+ let tmpDir: string;
68
+
69
+ beforeEach(() => {
70
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "postcss-test-"));
71
+ });
72
+
73
+ afterEach(() => {
74
+ fs.rmSync(tmpDir, { recursive: true, force: true });
75
+ });
76
+
77
+ it("replacements가 0건이면 파일 내용이 변경되지 않음", async () => {
78
+ // Given: ɵɵdefineComponent styles 배열이 없는 JS 파일
79
+ const jsContent = `const x = 1;\nexport default x;\n`;
80
+ const jsFile = path.join(tmpDir, "no-styles.js");
81
+ fs.writeFileSync(jsFile, jsContent, "utf-8");
82
+
83
+ const plugin = createPostcssPlugin({ plugins: [createTestPostcssPlugin()] });
84
+ const lifecycle = setupPlugin(plugin);
85
+
86
+ // When: PostCSS 처리를 시도
87
+ await lifecycle.invokeOnEnd({
88
+ metafile: { inputs: {}, outputs: { [jsFile]: {} as any } },
89
+ });
90
+
91
+ // Then: 파일 내용이 변경되지 않음
92
+ const result = fs.readFileSync(jsFile, "utf-8");
93
+ expect(result).toBe(jsContent);
94
+ });
95
+
96
+ it("replacements가 1건이면 PostCSS 처리된 CSS로 교체됨", async () => {
97
+ // Given: ɵɵdefineComponent styles 배열에 문자열 리터럴이 1개
98
+ const originalCss = ".host { color: red; }";
99
+ const jsContent = generateJsWithStyles([originalCss]);
100
+ const jsFile = path.join(tmpDir, "one-style.js");
101
+ fs.writeFileSync(jsFile, jsContent, "utf-8");
102
+
103
+ const plugin = createPostcssPlugin({ plugins: [createTestPostcssPlugin()] });
104
+ const lifecycle = setupPlugin(plugin);
105
+
106
+ // When: PostCSS 처리 수행
107
+ await lifecycle.invokeOnEnd({
108
+ metafile: { inputs: {}, outputs: { [jsFile]: {} as any } },
109
+ });
110
+
111
+ // Then: PostCSS 처리된 CSS가 파일에 반영됨
112
+ const result = fs.readFileSync(jsFile, "utf-8");
113
+ expect(result).toContain("/* processed */");
114
+ expect(result).toContain(".host { color: red; }");
115
+ // 파일 앞뒤의 JS 구조가 유지됨
116
+ expect(result).toContain("import");
117
+ expect(result).toContain("export");
118
+ });
119
+
120
+ it("replacements가 3건이면 모든 CSS가 PostCSS 처리됨", async () => {
121
+ // Given: ɵɵdefineComponent styles 배열에 문자열 리터럴이 3개
122
+ const styles = [
123
+ ".a { color: red; }",
124
+ ".b { color: blue; }",
125
+ ".c { color: green; }",
126
+ ];
127
+ const jsContent = generateJsWithStyles(styles);
128
+ const jsFile = path.join(tmpDir, "three-styles.js");
129
+ fs.writeFileSync(jsFile, jsContent, "utf-8");
130
+
131
+ const plugin = createPostcssPlugin({ plugins: [createTestPostcssPlugin()] });
132
+ const lifecycle = setupPlugin(plugin);
133
+
134
+ // When: PostCSS 처리 수행
135
+ await lifecycle.invokeOnEnd({
136
+ metafile: { inputs: {}, outputs: { [jsFile]: {} as any } },
137
+ });
138
+
139
+ // Then: 3개 모두 PostCSS 처리됨
140
+ const result = fs.readFileSync(jsFile, "utf-8");
141
+ // 3번의 "/* processed */" 주석이 삽입됨
142
+ const processedCount = (result.match(/\/\* processed \*\//g) ?? []).length;
143
+ expect(processedCount).toBe(3);
144
+ // 원본 CSS 내용이 모두 유지됨
145
+ expect(result).toContain("color: red");
146
+ expect(result).toContain("color: blue");
147
+ expect(result).toContain("color: green");
148
+ // JS 구조가 유지됨
149
+ expect(result).toContain("import");
150
+ expect(result).toContain("export");
151
+ });
152
+ });
@@ -428,6 +428,130 @@ describe("writeEmitResults with registry", () => {
428
428
  });
429
429
  });
430
430
 
431
+ // Feature 1.1 (review fix): writeEmitResults에서 sideEffectScssDeps 갱신
432
+ describe("writeEmitResults with sideEffectScssDeps", () => {
433
+ let tmpDir: string;
434
+ let pkgDir: string;
435
+ let srcDir: string;
436
+
437
+ beforeEach(() => {
438
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "write-emit-se-deps-"));
439
+ pkgDir = path.join(tmpDir, "my-pkg");
440
+ srcDir = path.join(pkgDir, "src");
441
+ fs.mkdirSync(path.join(pkgDir, "dist"), { recursive: true });
442
+ fs.mkdirSync(path.join(srcDir, "ui", "layout"), { recursive: true });
443
+ });
444
+
445
+ afterEach(() => {
446
+ fs.rmSync(tmpDir, { recursive: true, force: true });
447
+ });
448
+
449
+ // Acceptance: 새 side-effect SCSS의 의존성이 sideEffectScssDeps에 기록된다
450
+ it("records side-effect SCSS dependencies in sideEffectScssDeps after compileScssFile", async () => {
451
+ const { writeEmitResults } = await import("../../src/angular/ngtsc-build-core");
452
+
453
+ // Create shared variables file
454
+ const scssLoadDir = path.join(pkgDir, "scss");
455
+ fs.mkdirSync(path.join(scssLoadDir, "commons"), { recursive: true });
456
+ fs.writeFileSync(
457
+ path.join(scssLoadDir, "commons", "_variables.scss"),
458
+ "$primary: #ff0000;",
459
+ "utf-8",
460
+ );
461
+
462
+ // Create source SCSS that uses @use
463
+ const scssPath = path.join(srcDir, "ui", "layout", "sd-card.scss");
464
+ fs.writeFileSync(
465
+ scssPath,
466
+ '@use "commons/variables" as vars;\n.sd-card { color: vars.$primary; }',
467
+ "utf-8",
468
+ );
469
+
470
+ const distDir = path.join(pkgDir, "dist");
471
+ const jsPath = path.join(distDir, "my-pkg", "src", "ui", "layout", "sd-card.directive.js");
472
+ const sourceFileName = path.join(srcDir, "ui", "layout", "sd-card.directive.ts");
473
+ const emitResults = [
474
+ {
475
+ filename: jsPath,
476
+ contents: 'import "./sd-card.scss";\nexport class SdCardDirective {}',
477
+ sourceFileName,
478
+ },
479
+ ];
480
+
481
+ const sideEffectScssDeps = new Map<string, Set<string>>();
482
+ const scss: SideEffectScssOptions = {
483
+ loadPaths: [scssLoadDir],
484
+ scssErrors: [],
485
+ scssDependencies: new Map(),
486
+ sideEffectScssDeps,
487
+ };
488
+ writeEmitResults(emitResults, pkgDir, scss);
489
+
490
+ // sideEffectScssDeps should have the SCSS entry with its dependencies
491
+ expect(sideEffectScssDeps.has(scssPath)).toBe(true);
492
+ const deps = sideEffectScssDeps.get(scssPath)!;
493
+ expect(deps.size).toBeGreaterThan(0);
494
+ expect([...deps].some((d) => d.includes("_variables.scss"))).toBe(true);
495
+ });
496
+
497
+ // Unit: sideEffectScssDeps 미제공 시 기존 동작 유지
498
+ it("does not crash when sideEffectScssDeps is not provided", async () => {
499
+ const { writeEmitResults } = await import("../../src/angular/ngtsc-build-core");
500
+
501
+ const scssPath = path.join(srcDir, "ui", "layout", "sd-flex.scss");
502
+ fs.writeFileSync(scssPath, ".sd-flex { display: flex; }", "utf-8");
503
+
504
+ const distDir = path.join(pkgDir, "dist");
505
+ const jsPath = path.join(distDir, "my-pkg", "src", "ui", "layout", "sd-flex.directive.js");
506
+ const emitResults = [
507
+ {
508
+ filename: jsPath,
509
+ contents: 'import "./sd-flex.scss";\nexport class SdFlexDirective {}',
510
+ sourceFileName: path.join(srcDir, "ui", "layout", "sd-flex.directive.ts"),
511
+ },
512
+ ];
513
+
514
+ const scss: SideEffectScssOptions = {
515
+ loadPaths: [],
516
+ scssErrors: [],
517
+ scssDependencies: new Map(),
518
+ };
519
+
520
+ // Should not throw
521
+ writeEmitResults(emitResults, pkgDir, scss);
522
+ expect(scss.scssErrors).toEqual([]);
523
+ });
524
+
525
+ // Unit: SCSS 컴파일 에러 시 sideEffectScssDeps에 기록하지 않는다
526
+ it("does not record in sideEffectScssDeps when SCSS compilation fails", async () => {
527
+ const { writeEmitResults } = await import("../../src/angular/ngtsc-build-core");
528
+
529
+ const distDir = path.join(pkgDir, "dist");
530
+ const jsPath = path.join(distDir, "my-pkg", "src", "ui", "layout", "missing.directive.js");
531
+ const emitResults = [
532
+ {
533
+ filename: jsPath,
534
+ contents: 'import "./missing.scss";\nexport class MissingDirective {}',
535
+ sourceFileName: path.join(srcDir, "ui", "layout", "missing.directive.ts"),
536
+ },
537
+ ];
538
+
539
+ const sideEffectScssDeps = new Map<string, Set<string>>();
540
+ const scss: SideEffectScssOptions = {
541
+ loadPaths: [],
542
+ scssErrors: [],
543
+ scssDependencies: new Map(),
544
+ sideEffectScssDeps,
545
+ };
546
+ writeEmitResults(emitResults, pkgDir, scss);
547
+
548
+ // Error should be reported
549
+ expect(scss.scssErrors.length).toBeGreaterThan(0);
550
+ // sideEffectScssDeps should be empty (no successful compilation)
551
+ expect(sideEffectScssDeps.size).toBe(0);
552
+ });
553
+ });
554
+
431
555
  // Feature 1.2: compileSideEffectScss
432
556
  describe("compileSideEffectScss", () => {
433
557
  let tmpDir: string;
@@ -0,0 +1,10 @@
1
+ # client.worker mtime 증분 추적 — LLM 검증
2
+
3
+ ## 검증 항목
4
+
5
+ - [x] createSourceFileCachePlugin이 IncrementalMtimeTracker를 사용하는지 확인: `client.worker.ts:198` — `const mtimeTracker = new IncrementalMtimeTracker();` 확인됨
6
+ - [x] onStart에서 detectChanges 결과로 invalidate가 호출되는지 확인: `client.worker.ts:210-213` — `mtimeTracker.detectChanges(watchTargets)` 결과를 `sourceFileCache.invalidate(changedFiles)`에 전달. 기존 로직과 동작 동일
7
+ - [x] onEnd에서 updateMtimes가 호출되는지 확인: `client.worker.ts:228` — `mtimeTracker.updateMtimes(watchTargets)` 호출 확인. `prevMtimes.clear()` + 전체 순회 제거됨
8
+ - [x] esbuildResult == null 가드가 유지되는지 확인: onStart의 `if (esbuildResult != null)` (라인 202), onEnd의 `if (esbuildResult == null) return;` (라인 222) 모두 유지됨
9
+ - [x] isInitialBuild 가드와 sender.send("buildStart")가 유지되는지 확인: `client.worker.ts:216-218` — `if (!isInitialBuild) { sender.send("buildStart", {}); }` 유지됨
10
+ - [x] import 경로에 .js 확장자가 없는지 확인: `client.worker.ts:20` — `from "./incremental-mtime-tracker"` (확장자 없음)
@@ -0,0 +1,144 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { IncrementalMtimeTracker } from "../../src/workers/incremental-mtime-tracker";
6
+
7
+ describe("IncrementalMtimeTracker — Acceptance", () => {
8
+ let tmpDir: string;
9
+
10
+ beforeEach(() => {
11
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mtime-acc-"));
12
+ });
13
+
14
+ afterEach(() => {
15
+ fs.rmSync(tmpDir, { recursive: true, force: true });
16
+ });
17
+
18
+ function createFile(name: string): string {
19
+ const filePath = path.join(tmpDir, name);
20
+ fs.writeFileSync(filePath, `content-${name}`, "utf-8");
21
+ return filePath;
22
+ }
23
+
24
+ function touchFile(filePath: string): void {
25
+ const stat = fs.statSync(filePath);
26
+ const newTime = stat.mtimeMs / 1000 + 2;
27
+ fs.utimesSync(filePath, newTime, newTime);
28
+ }
29
+
30
+ // Scenario: 변경된 파일만 mtime 갱신
31
+ it("onEnd에서 변경된 파일만 stat 호출한다", () => {
32
+ const tracker = new IncrementalMtimeTracker();
33
+ const fileA = createFile("a.ts");
34
+ const fileB = createFile("b.ts");
35
+ const fileC = createFile("c.ts");
36
+ const targets = [fileA, fileB, fileC];
37
+
38
+ // 초기 빌드: prevMtimes 채우기
39
+ tracker.updateMtimes(targets);
40
+
41
+ // 파일 A 변경
42
+ touchFile(fileA);
43
+
44
+ // onStart: 변경 감지
45
+ const changed = tracker.detectChanges(targets);
46
+ expect(changed.size).toBe(1);
47
+ expect(changed.has(fileA)).toBe(true);
48
+
49
+ // onEnd: 증분 갱신 — 변경된 A만 stat
50
+ const spy = vi.spyOn(fs, "statSync");
51
+ tracker.updateMtimes(targets);
52
+ const statPaths = spy.mock.calls.map((c) => c[0]);
53
+ expect(statPaths).toContain(fileA);
54
+ expect(statPaths).not.toContain(fileB);
55
+ expect(statPaths).not.toContain(fileC);
56
+ spy.mockRestore();
57
+ });
58
+
59
+ // Scenario: 새로 추가된 파일만 stat 호출
60
+ it("onEnd에서 새로 추가된 파일만 stat 호출한다", () => {
61
+ const tracker = new IncrementalMtimeTracker();
62
+ const fileA = createFile("a.ts");
63
+ const fileB = createFile("b.ts");
64
+
65
+ tracker.updateMtimes([fileA, fileB]);
66
+
67
+ const fileC = createFile("c.ts");
68
+ tracker.detectChanges([fileA, fileB, fileC]);
69
+
70
+ const spy = vi.spyOn(fs, "statSync");
71
+ tracker.updateMtimes([fileA, fileB, fileC]);
72
+ const statPaths = spy.mock.calls.map((c) => c[0]);
73
+ expect(statPaths).toEqual([fileC]);
74
+ spy.mockRestore();
75
+ });
76
+
77
+ // Scenario: 변경도 신규도 없으면 stat 호출 0회
78
+ it("변경도 신규도 없으면 onEnd에서 stat 호출이 0회이다", () => {
79
+ const tracker = new IncrementalMtimeTracker();
80
+ const fileA = createFile("a.ts");
81
+ const fileB = createFile("b.ts");
82
+
83
+ tracker.updateMtimes([fileA, fileB]);
84
+ tracker.detectChanges([fileA, fileB]);
85
+
86
+ const spy = vi.spyOn(fs, "statSync");
87
+ tracker.updateMtimes([fileA, fileB]);
88
+ expect(spy.mock.calls).toHaveLength(0);
89
+ spy.mockRestore();
90
+ });
91
+
92
+ // Scenario: 삭제된 파일이 prevMtimes에서 제거된다
93
+ it("삭제된 파일은 이후 빌드에서 변경으로 감지되지 않는다", () => {
94
+ const tracker = new IncrementalMtimeTracker();
95
+ const fileA = createFile("a.ts");
96
+ const fileB = createFile("b.ts");
97
+
98
+ tracker.updateMtimes([fileA, fileB]);
99
+
100
+ // fileB를 watchTargets에서 제거
101
+ tracker.detectChanges([fileA]);
102
+ tracker.updateMtimes([fileA]);
103
+
104
+ // fileB를 다시 추가 — "신규" 파일이므로 changedFiles에 포함되지 않음
105
+ const changed = tracker.detectChanges([fileA, fileB]);
106
+ expect(changed.has(fileB)).toBe(false);
107
+ });
108
+
109
+ // Scenario: mtime 변경된 파일이 changedFiles에 포함된다
110
+ it("mtime이 변경된 파일이 changedFiles에 포함된다", () => {
111
+ const tracker = new IncrementalMtimeTracker();
112
+ const fileA = createFile("a.ts");
113
+
114
+ tracker.updateMtimes([fileA]);
115
+ touchFile(fileA);
116
+
117
+ const changed = tracker.detectChanges([fileA]);
118
+ expect(changed.has(fileA)).toBe(true);
119
+ });
120
+
121
+ // Scenario: 삭제된 파일이 changedFiles에 포함된다
122
+ it("삭제된 파일이 changedFiles에 포함된다", () => {
123
+ const tracker = new IncrementalMtimeTracker();
124
+ const fileA = createFile("a.ts");
125
+
126
+ tracker.updateMtimes([fileA]);
127
+ fs.unlinkSync(fileA);
128
+
129
+ const changed = tracker.detectChanges([fileA]);
130
+ expect(changed.has(fileA)).toBe(true);
131
+ });
132
+
133
+ // Scenario: 신규 파일은 changedFiles에 포함되지 않는다
134
+ it("신규 파일은 changedFiles에 포함되지 않는다", () => {
135
+ const tracker = new IncrementalMtimeTracker();
136
+ const fileA = createFile("a.ts");
137
+
138
+ tracker.updateMtimes([fileA]);
139
+
140
+ const fileB = createFile("b.ts");
141
+ const changed = tracker.detectChanges([fileA, fileB]);
142
+ expect(changed.has(fileB)).toBe(false);
143
+ });
144
+ });
@@ -0,0 +1,102 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { IncrementalMtimeTracker } from "../../src/workers/incremental-mtime-tracker";
6
+
7
+ describe("IncrementalMtimeTracker", () => {
8
+ let tmpDir: string;
9
+
10
+ beforeEach(() => {
11
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mtime-unit-"));
12
+ });
13
+
14
+ afterEach(() => {
15
+ fs.rmSync(tmpDir, { recursive: true, force: true });
16
+ });
17
+
18
+ function createFile(name: string): string {
19
+ const filePath = path.join(tmpDir, name);
20
+ fs.writeFileSync(filePath, name, "utf-8");
21
+ return filePath;
22
+ }
23
+
24
+ it("빈 watchTargets로 detectChanges하면 빈 Set을 반환한다", () => {
25
+ const tracker = new IncrementalMtimeTracker();
26
+ const changed = tracker.detectChanges([]);
27
+ expect(changed.size).toBe(0);
28
+ });
29
+
30
+ it("빈 watchTargets로 updateMtimes하면 stat 호출이 없다", () => {
31
+ const tracker = new IncrementalMtimeTracker();
32
+ const spy = vi.spyOn(fs, "statSync");
33
+ tracker.updateMtimes([]);
34
+ expect(spy.mock.calls).toHaveLength(0);
35
+ spy.mockRestore();
36
+ });
37
+
38
+ it("모든 파일이 watchTargets에서 제거되면 이후 detectChanges에서 감지되지 않는다", () => {
39
+ const tracker = new IncrementalMtimeTracker();
40
+ const fileA = createFile("a.ts");
41
+ const fileB = createFile("b.ts");
42
+
43
+ tracker.updateMtimes([fileA, fileB]);
44
+
45
+ // 모든 파일 제거
46
+ tracker.detectChanges([]);
47
+ tracker.updateMtimes([]);
48
+
49
+ // 다시 추가 — "신규"로 취급
50
+ const changed = tracker.detectChanges([fileA]);
51
+ expect(changed.has(fileA)).toBe(false);
52
+ });
53
+
54
+ it("동일 파일이 watchTargets에 중복되어도 정상 동작한다", () => {
55
+ const tracker = new IncrementalMtimeTracker();
56
+ const fileA = createFile("a.ts");
57
+
58
+ tracker.updateMtimes([fileA, fileA]);
59
+
60
+ const changed = tracker.detectChanges([fileA]);
61
+ expect(changed.size).toBe(0);
62
+ });
63
+
64
+ it("detectChanges 없이 연속 updateMtimes를 호출하면 모두 신규로 처리된다", () => {
65
+ const tracker = new IncrementalMtimeTracker();
66
+ const fileA = createFile("a.ts");
67
+
68
+ const spy = vi.spyOn(fs, "statSync");
69
+ tracker.updateMtimes([fileA]);
70
+ expect(spy.mock.calls).toHaveLength(1);
71
+ spy.mockRestore();
72
+ });
73
+
74
+ it("존재하지 않는 파일은 detectChanges에서 prevMtimes에 없으면 무시된다", () => {
75
+ const tracker = new IncrementalMtimeTracker();
76
+ const nonExistent = path.join(tmpDir, "ghost.ts");
77
+
78
+ const changed = tracker.detectChanges([nonExistent]);
79
+ expect(changed.size).toBe(0);
80
+ });
81
+
82
+ it("updateMtimes 중 stat이 실패하는 변경 파일은 prevMtimes에서 제거된다", () => {
83
+ const tracker = new IncrementalMtimeTracker();
84
+ const fileA = createFile("a.ts");
85
+
86
+ tracker.updateMtimes([fileA]);
87
+
88
+ // 파일 삭제 후 detectChanges — changedFiles에 포함
89
+ fs.unlinkSync(fileA);
90
+ tracker.detectChanges([fileA]);
91
+
92
+ // updateMtimes — stat 실패 → prevMtimes에서 제거
93
+ tracker.updateMtimes([fileA]);
94
+
95
+ // 파일 재생성
96
+ const fileANew = createFile("a.ts");
97
+
98
+ // 다음 detectChanges — "신규"로 취급 (prevMtimes에 없으므로)
99
+ const changed = tracker.detectChanges([fileANew]);
100
+ expect(changed.has(fileANew)).toBe(false);
101
+ });
102
+ });