@simplysm/sd-cli 14.0.45 → 14.0.46

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.
@@ -2,6 +2,7 @@ import type { ServerBuildInfo } from "../../workers/server-build.worker";
2
2
  import path from "path";
3
3
  import fs from "fs";
4
4
  import YAML from "yaml";
5
+ import TOML from "smol-toml";
5
6
  import { cpx } from "@simplysm/core-node";
6
7
  import { consola } from "consola";
7
8
  import { collectAllDependencyExternals } from "../../esbuild/esbuild-config";
@@ -110,9 +111,10 @@ export function generateProductionFiles(
110
111
  let nodeVersion = "20";
111
112
  if (fs.existsSync(rootMiseTomlPath)) {
112
113
  const miseContent = fs.readFileSync(rootMiseTomlPath, "utf-8");
113
- const match = /node\s*=\s*"([^"]+)"/.exec(miseContent);
114
- if (match != null) {
115
- nodeVersion = match[1];
114
+ // mise.toml은 저장소에서 관리되는 설정 파일이므로, 파싱 실패 시 폴백하지 않고 예외를 전파하여 설정 오류를 즉시 드러낸다.
115
+ const miseConfig = TOML.parse(miseContent) as { tools?: { node?: string } };
116
+ if (miseConfig.tools?.node != null) {
117
+ nodeVersion = miseConfig.tools.node;
116
118
  }
117
119
  }
118
120
  fs.writeFileSync(path.join(distDir, "mise.toml"), `[tools]\nnode = "${nodeVersion}"\n`);
@@ -4,6 +4,7 @@ import fs from "fs/promises";
4
4
  import { createRequire } from "module";
5
5
  import type esbuild from "esbuild";
6
6
  import { consola } from "consola";
7
+ import { addJsExtensionToImports } from "../utils/output-path-rewriter";
7
8
 
8
9
  const logger = consola.withTag("sd:cli:esbuild-config");
9
10
 
@@ -19,13 +20,7 @@ export async function writeChangedOutputFiles(outputFiles: esbuild.OutputFile[])
19
20
  await Promise.all(
20
21
  outputFiles.map(async (file) => {
21
22
  const finalText = file.path.endsWith(".js")
22
- ? file.text.replace(
23
- /((?:from|import)\s*["'])(\.\.?\/[^"']*?)(["'])/g,
24
- (_match, prefix: string, importPath: string, suffix: string) => {
25
- if (/\.(js|mjs|cjs|json|css|wasm|node)$/i.test(importPath)) return _match;
26
- return `${prefix}${importPath}.js${suffix}`;
27
- },
28
- )
23
+ ? addJsExtensionToImports(file.text)
29
24
  : file.text;
30
25
 
31
26
  try {
@@ -1,27 +1,144 @@
1
1
  import path from "path";
2
2
  import fs from "fs";
3
3
  import type esbuild from "esbuild";
4
+ import * as acorn from "acorn";
5
+ import * as walk from "acorn-walk";
6
+
7
+ //#region AST 기반 Worker 패턴 탐지
4
8
 
5
9
  /**
6
- * Worker/SharedWorker + new URL + import.meta.url 패턴을 감지하는 정규식.
7
- *
8
- * 캡처 그룹:
9
- * - Group 1: `Worker` 또는 `SharedWorker`
10
- * - Group 2: URL 경로 (예: `"./worker.ts"`)
11
- * - Group 3: 옵션 객체 (예: `{ type: "module" }`) — 없으면 undefined
10
+ * AST에서 탐지된 Worker 패턴 하나를 나타낸다.
12
11
  */
13
- const WORKER_PATTERN =
14
- /\bnew\s+(Worker|SharedWorker)\s*\(\s*new\s+URL\s*\(\s*["']([^"']+)["']\s*,\s*import\.meta\.url\s*\)\s*(?:,\s*(\{[^}]*\}))?\s*\)/g;
12
+ export interface WorkerMatch {
13
+ type: "browser" | "node";
14
+ /** 전체 표현식의 start/end (치환 범위) */
15
+ start: number;
16
+ end: number;
17
+ /** Worker/SharedWorker 이름 (browser만) */
18
+ workerType?: string;
19
+ /** URL 경로 문자열 값 */
20
+ urlPath: string;
21
+ /** 옵션 객체의 원본 소스 텍스트 (browser만, 없으면 undefined) */
22
+ existingOpts?: string;
23
+ }
24
+
25
+ /**
26
+ * MemberExpression이 import.meta.url인지 확인한다.
27
+ */
28
+ function isImportMetaUrl(node: any): boolean {
29
+ return (
30
+ node.type === "MemberExpression" &&
31
+ node.object.type === "MetaProperty" &&
32
+ node.object.meta.name === "import" &&
33
+ node.object.property.name === "meta" &&
34
+ node.property.type === "Identifier" &&
35
+ node.property.name === "url"
36
+ );
37
+ }
15
38
 
16
39
  /**
17
- * Node.js import.meta.resolve 패턴을 감지하는 정규식.
18
- * 상대 경로(./ 또는 ../)만 감지 — 절대 모듈 경로("some-package")는 무시.
40
+ * acorn AST를 사용하여 소스 코드에서 Worker/SharedWorker 및 import.meta.resolve 패턴을 탐지한다.
19
41
  *
20
- * 캡처 그룹:
21
- * - Group 1: 상대 경로 (예: `"../workers/service-protocol.worker"`)
42
+ * 정규식과 달리 주석, 문자열 리터럴 내부의 패턴을 오탐하지 않는다.
43
+ * 파싱 실패 배열을 반환한다.
22
44
  */
23
- const NODE_WORKER_PATTERN =
24
- /\bimport\.meta\.resolve\s*\(\s*["'](\.\.?\/[^"']+)["']\s*\)/g;
45
+ export function findWorkerPatterns(content: string): WorkerMatch[] {
46
+ let ast: acorn.Node;
47
+ try {
48
+ ast = acorn.parse(content, {
49
+ ecmaVersion: "latest",
50
+ sourceType: "module",
51
+ });
52
+ } catch {
53
+ return [];
54
+ }
55
+
56
+ const matches: WorkerMatch[] = [];
57
+
58
+ walk.simple(ast, {
59
+ NewExpression(node: any) {
60
+ // new Worker(new URL("path", import.meta.url), opts?)
61
+ // new SharedWorker(new URL("path", import.meta.url), opts?)
62
+ if (
63
+ node.callee.type !== "Identifier" ||
64
+ (node.callee.name !== "Worker" && node.callee.name !== "SharedWorker")
65
+ ) {
66
+ return;
67
+ }
68
+
69
+ const args = node.arguments;
70
+ if (args.length < 1) return;
71
+
72
+ const urlArg = args[0];
73
+ if (
74
+ urlArg.type !== "NewExpression" ||
75
+ urlArg.callee.type !== "Identifier" ||
76
+ urlArg.callee.name !== "URL"
77
+ ) {
78
+ return;
79
+ }
80
+
81
+ const urlArgs = urlArg.arguments;
82
+ if (urlArgs.length < 2) return;
83
+
84
+ // 첫 번째 인자: 문자열 리터럴 (경로)
85
+ if (urlArgs[0].type !== "Literal" || typeof urlArgs[0].value !== "string") return;
86
+
87
+ // 두 번째 인자: import.meta.url
88
+ if (!isImportMetaUrl(urlArgs[1])) return;
89
+
90
+ const match: WorkerMatch = {
91
+ type: "browser",
92
+ start: node.start,
93
+ end: node.end,
94
+ workerType: node.callee.name,
95
+ urlPath: urlArgs[0].value,
96
+ };
97
+
98
+ // 옵션 객체 (두 번째 인자)
99
+ if (args.length >= 2) {
100
+ match.existingOpts = content.slice(args[1].start, args[1].end);
101
+ }
102
+
103
+ matches.push(match);
104
+ },
105
+
106
+ CallExpression(node: any) {
107
+ // import.meta.resolve("./relative-path")
108
+ const callee = node.callee;
109
+ if (
110
+ callee.type !== "MemberExpression" ||
111
+ callee.object.type !== "MetaProperty" ||
112
+ callee.object.meta.name !== "import" ||
113
+ callee.object.property.name !== "meta" ||
114
+ callee.property.type !== "Identifier" ||
115
+ callee.property.name !== "resolve"
116
+ ) {
117
+ return;
118
+ }
119
+
120
+ const args = node.arguments;
121
+ if (args.length < 1) return;
122
+
123
+ if (args[0].type !== "Literal" || typeof args[0].value !== "string") return;
124
+
125
+ const urlPath = args[0].value as string;
126
+ // 상대 경로만 처리
127
+ if (!urlPath.startsWith("./") && !urlPath.startsWith("../")) return;
128
+
129
+ matches.push({
130
+ type: "node",
131
+ start: node.start,
132
+ end: node.end,
133
+ urlPath,
134
+ });
135
+ },
136
+ });
137
+
138
+ return matches.sort((a, b) => a.start - b.start);
139
+ }
140
+
141
+ //#endregion
25
142
 
26
143
  /**
27
144
  * Worker 번들 빌드 결과를 포함하는 transform 결과.
@@ -94,20 +211,46 @@ export function transformWorkerPatterns(
94
211
  filePath: string,
95
212
  build: esbuild.PluginBuild,
96
213
  ): TransformWorkerResult | undefined {
97
- // 빠른 사전 필터
98
- const hasBrowserWorker = content.includes("Worker") && WORKER_PATTERN.test(content);
99
- if (hasBrowserWorker) WORKER_PATTERN.lastIndex = 0;
100
-
101
- const hasNodeWorker =
102
- content.includes("import.meta.resolve") && NODE_WORKER_PATTERN.test(content);
103
- if (hasNodeWorker) NODE_WORKER_PATTERN.lastIndex = 0;
104
-
105
- if (!hasBrowserWorker && !hasNodeWorker) {
214
+ // 빠른 사전 필터 — AST 파싱 전에 키워드 존재 여부로 걸러냄 (원본 TS content 기준)
215
+ if (!content.includes("Worker") && !content.includes("import.meta.resolve")) {
106
216
  return undefined;
107
217
  }
108
218
 
109
219
  const errors: esbuild.PartialMessage[] = [];
110
220
  const warnings: esbuild.PartialMessage[] = [];
221
+
222
+ // TS(.ts/.cts/.mts)는 JS로 변환한 후 AST 파싱. acorn은 TS 구문을 처리하지 못하므로
223
+ // import type, 타입 어노테이션 등이 있으면 파싱 실패로 Worker 패턴이 조용히 누락된다.
224
+ let effectiveContent = content;
225
+ if (/\.[cm]?ts$/.test(filePath)) {
226
+ try {
227
+ const transformed = build.esbuild.transformSync(content, {
228
+ loader: "ts",
229
+ sourcemap: false,
230
+ });
231
+ effectiveContent = transformed.code;
232
+ warnings.push(...transformed.warnings);
233
+ } catch (e) {
234
+ const failure = e as {
235
+ errors?: esbuild.PartialMessage[];
236
+ warnings?: esbuild.PartialMessage[];
237
+ };
238
+ return {
239
+ contents: content,
240
+ errors: failure.errors ?? [
241
+ { text: `TS transform failed: ${String(e)}`, location: null },
242
+ ],
243
+ warnings: failure.warnings ?? [],
244
+ };
245
+ }
246
+ }
247
+
248
+ // AST 기반 패턴 탐지 (변환된 JS 기준)
249
+ const matches = findWorkerPatterns(effectiveContent);
250
+ if (matches.length === 0) {
251
+ return undefined;
252
+ }
253
+
111
254
  const allOutputFiles: esbuild.OutputFile[] = [];
112
255
  let mergedMetafile: esbuild.Metafile | undefined;
113
256
 
@@ -170,42 +313,49 @@ export function transformWorkerPatterns(
170
313
  return path.relative(outdir, workerCodeFile.path).replaceAll("\\", "/");
171
314
  }
172
315
 
173
- let transformed = content;
174
-
175
- // 1. 브라우저 Worker 패턴 처리 (new Worker(new URL("path", import.meta.url)))
176
- if (hasBrowserWorker) {
177
- transformed = transformed.replace(
178
- WORKER_PATTERN,
179
- (match, workerType: string, urlPath: string, existingOpts?: string) => {
180
- const fullWorkerPath = path.resolve(containingDir, urlPath);
181
- const workerCodePath = processWorkerBundle(fullWorkerPath, "browser");
182
- if (workerCodePath == null) return match;
183
-
184
- const optsStr = existingOpts != null ? existingOpts : '{ type: "module" }';
185
- return `new ${workerType}(new URL("${workerCodePath}", import.meta.url), ${optsStr})`;
186
- },
187
- );
316
+ // 정방향 chunks 패턴으로 치환 (esbuild-postcss-plugin.ts 동일 패턴)
317
+ const replacements: Array<{ start: number; end: number; text: string }> = [];
318
+
319
+ for (const match of matches) {
320
+ if (match.type === "browser") {
321
+ const fullWorkerPath = path.resolve(containingDir, match.urlPath);
322
+ const workerCodePath = processWorkerBundle(fullWorkerPath, "browser");
323
+ if (workerCodePath == null) continue;
324
+
325
+ const optsStr = match.existingOpts ?? '{ type: "module" }';
326
+ replacements.push({
327
+ start: match.start,
328
+ end: match.end,
329
+ text: `new ${match.workerType}(new URL("${workerCodePath}", import.meta.url), ${optsStr})`,
330
+ });
331
+ } else {
332
+ const fullWorkerPath = path.resolve(containingDir, match.urlPath);
333
+ const workerCodePath = processWorkerBundle(
334
+ fullWorkerPath,
335
+ build.initialOptions.platform ?? "browser",
336
+ );
337
+ if (workerCodePath == null) continue;
338
+
339
+ replacements.push({
340
+ start: match.start,
341
+ end: match.end,
342
+ text: `new URL("${workerCodePath}", import.meta.url).href`,
343
+ });
344
+ }
188
345
  }
189
346
 
190
- // 2. Node.js import.meta.resolve 패턴 처리
191
- if (hasNodeWorker) {
192
- transformed = transformed.replace(
193
- NODE_WORKER_PATTERN,
194
- (match, resolvePath: string) => {
195
- const fullWorkerPath = path.resolve(containingDir, resolvePath);
196
- const workerCodePath = processWorkerBundle(
197
- fullWorkerPath,
198
- build.initialOptions.platform ?? "browser",
199
- );
200
- if (workerCodePath == null) return match;
201
-
202
- return `new URL("${workerCodePath}", import.meta.url).href`;
203
- },
204
- );
347
+ // chunks 조립 (변환된 JS 기준 — 매치의 start/end는 effectiveContent 오프셋)
348
+ const chunks: string[] = [];
349
+ let cursor = 0;
350
+ for (const rep of replacements) {
351
+ chunks.push(effectiveContent.slice(cursor, rep.start));
352
+ chunks.push(rep.text);
353
+ cursor = rep.end;
205
354
  }
355
+ chunks.push(effectiveContent.slice(cursor));
206
356
 
207
357
  return {
208
- contents: transformed,
358
+ contents: chunks.join(""),
209
359
  errors,
210
360
  warnings,
211
361
  workerOutputFiles: allOutputFiles.length > 0 ? allOutputFiles : undefined,
@@ -241,9 +391,12 @@ export function createWorkerBundlePlugin(): esbuild.Plugin {
241
391
  });
242
392
  }
243
393
 
394
+ // TS(.ts/.cts/.mts)는 transformWorkerPatterns 내부에서 JS로 변환되어 반환되므로
395
+ // loader는 "js". .tsx/.jsx는 변환하지 않으므로 esbuild가 JSX를 처리하도록 "tsx".
396
+ const isJsx = /\.[cm]?tsx$/.test(args.path) || args.path.endsWith(".jsx");
244
397
  return {
245
398
  contents: result.contents,
246
- loader: /\.[cm]?tsx?$/.test(args.path) ? ("ts" as const) : ("js" as const),
399
+ loader: isJsx ? ("tsx" as const) : ("js" as const),
247
400
  errors: result.errors.length > 0 ? result.errors : undefined,
248
401
  warnings: result.warnings.length > 0 ? result.warnings : undefined,
249
402
  };
@@ -1,39 +1,87 @@
1
1
  import path from "path";
2
2
  import { pathx } from "@simplysm/core-node";
3
+ import { init, parse } from "es-module-lexer";
4
+
5
+ await init;
6
+
7
+ const KNOWN_JS_EXTENSIONS = /\.(js|mjs|cjs|json|css|scss|wasm|node)$/i;
8
+
9
+ /**
10
+ * es-module-lexer의 import에서 specifier 내용의 시작/끝 위치를 반환한다.
11
+ *
12
+ * static import (d === -1): s..e가 따옴표 없는 specifier 내용
13
+ * dynamic import (d >= 0): s..e가 따옴표를 포함한 문자열 리터럴
14
+ */
15
+ function getSpecifierRange(imp: { s: number; e: number; d: number }): [number, number] {
16
+ if (imp.d >= 0) {
17
+ return [imp.s + 1, imp.e - 1];
18
+ }
19
+ return [imp.s, imp.e];
20
+ }
3
21
 
4
22
  /**
5
23
  * ESM 출력에서 확장자가 없는 상대 import/export 경로에 .js 확장자를 추가한다.
6
24
  *
7
- * 매칭: from "./foo", import("./bar"), from "../baz"
25
+ * es-module-lexer를 사용하여 import/export specifier 위치를 정확히 파악한다.
26
+ * 주석, 문자열 리터럴 내부의 패턴은 무시된다.
27
+ *
28
+ * 매칭: from "./foo", import("./bar"), from "../baz", export { x } from "./qux"
8
29
  * 스킵: bare 지정자("lodash"), 이미 알려진 확장자로 끝나는 경로 (.js, .json, .css 등)
9
30
  */
10
31
  export function addJsExtensionToImports(text: string): string {
11
- return text.replace(
12
- /((?:from|import)\s*(?:\(\s*)?["'])(\.\.?\/[^"']*?)(["'](?:\s*\))?)/g,
13
- (_match, prefix: string, importPath: string, suffix: string) => {
14
- if (/\.(js|mjs|cjs|json|css|scss|wasm|node)$/i.test(importPath)) return _match;
15
- return `${prefix}${importPath}.js${suffix}`;
16
- },
17
- );
32
+ const [imports] = parse(text);
33
+ if (imports.length === 0) return text;
34
+
35
+ // 역순으로 치환하여 위치 밀림 방지
36
+ const sorted = [...imports].sort((a, b) => b.s - a.s);
37
+ let result = text;
38
+
39
+ for (const imp of sorted) {
40
+ const specifier = imp.n;
41
+ if (specifier == null) continue;
42
+ if (!specifier.startsWith("./") && !specifier.startsWith("../")) continue;
43
+ if (KNOWN_JS_EXTENSIONS.test(specifier)) continue;
44
+
45
+ const [start, end] = getSpecifierRange(imp);
46
+ result = result.slice(0, start) + specifier + ".js" + result.slice(end);
47
+ }
48
+
49
+ return result;
18
50
  }
19
51
 
20
52
  /**
21
53
  * emit된 JS 텍스트에서 상대 .scss import를 .css로 변환한다.
22
54
  * 변환된 텍스트와 원본 .scss import 경로 목록을 반환한다.
23
55
  *
24
- * 매칭: import "./foo.scss", import("./bar.scss"), from "../baz.scss"
56
+ * es-module-lexer를 사용하여 import specifier 위치를 정확히 파악한다.
25
57
  * 상대 import(./ 또는 ../ 시작)만 처리한다.
26
58
  */
27
59
  export function rewriteScssImports(text: string): { text: string; scssImports: string[] } {
60
+ const [imports] = parse(text);
61
+ if (imports.length === 0) return { text, scssImports: [] };
62
+
28
63
  const scssImports: string[] = [];
29
- const rewritten = text.replace(
30
- /((?:from|import)\s*(?:\(\s*)?["'])(\.\.?\/[^"']*?)(\.scss)(["'](?:\s*\))?)/g,
31
- (_match, prefix: string, importBase: string, _ext: string, suffix: string) => {
32
- scssImports.push(`${importBase}.scss`);
33
- return `${prefix}${importBase}.css${suffix}`;
34
- },
35
- );
36
- return { text: rewritten, scssImports };
64
+
65
+ // 역순으로 치환하여 위치 밀림 방지
66
+ const sorted = [...imports].sort((a, b) => b.s - a.s);
67
+ let result = text;
68
+
69
+ for (const imp of sorted) {
70
+ const specifier = imp.n;
71
+ if (specifier == null) continue;
72
+ if (!specifier.startsWith("./") && !specifier.startsWith("../")) continue;
73
+ if (!specifier.endsWith(".scss")) continue;
74
+
75
+ scssImports.push(specifier);
76
+ const newSpec = specifier.slice(0, -5) + ".css";
77
+ const [start, end] = getSpecifierRange(imp);
78
+ result = result.slice(0, start) + newSpec + result.slice(end);
79
+ }
80
+
81
+ // 역순으로 수집되었으므로 원래 순서로 복원
82
+ scssImports.reverse();
83
+
84
+ return { text: result, scssImports };
37
85
  }
38
86
 
39
87
  /**
@@ -0,0 +1,16 @@
1
+ # mise.toml 파싱 실패 시 명시적 크래시 의도 — LLM 검증
2
+
3
+ 대상: `packages/sd-cli/src/deps/server-externals/server-production-files.ts` `generateProductionFiles()` 내 `TOML.parse(miseContent)` 호출부
4
+
5
+ 근거: `.tasks/260417132441_review-regex-to-parser/1.3-toml-parse-intent-comment.md`
6
+
7
+ ## 검증 항목
8
+
9
+ - [x] **WHY 주석 존재**: `server-production-files.ts:114`에 `// mise.toml은 저장소에서 관리되는 설정 파일이므로, 파싱 실패 시 폴백하지 않고 예외를 전파하여 설정 오류를 즉시 드러낸다.` 주석이 `TOML.parse(miseContent)` 호출(`:115`) 직전에 존재함을 확인
10
+ - [x] **명시적 크래시 의도 서술**: 주석이 "저장소에서 관리되는 설정 파일", "폴백하지 않고", "예외를 전파", "설정 오류를 즉시 드러낸다" 표현을 모두 포함하여 wbs에 명시된 WHY를 서술
11
+ - [x] **WHAT 서술 지양**: "TOML을 파싱한다" 같은 코드 재서술이 아니라, 폴백을 의도적으로 하지 않는 이유(설정 오류 즉시 노출)를 설명
12
+ - [x] **try-catch 미추가**: `:109-121` 블록 전체에 try 키워드 없음을 확인 — 파싱 예외는 상위로 전파됨
13
+ - [x] **주석 스타일**: `//` 한 줄 주석 (JSDoc 아님)
14
+ - [x] **주석 언어**: 한국어
15
+ - [x] **기타 코드 미변경**: `git diff server-production-files.ts` 결과 라인 114의 주석 추가 1줄 외 변경 없음 (아래 명령 실행 결과 참조)
16
+ - [x] **기존 테스트 통과**: `pnpm exec vitest run --project node packages/sd-cli/tests/workers/server-build-worker.spec.ts` 25/25 통과, 특히 `generates dist/mise.toml when packageManager=mise` 테스트(TOML.parse 경로 실행)가 통과하여 파싱 로직 회귀 없음을 확인. 참고: 전체 sd-cli 테스트(`packages/sd-cli/tests/`)에서 1건 실패(`esbuild-worker-plugin.acc.spec.ts:143`)가 있으나 이는 Feature 1.1 영역이며 본 Feature 1.3 변경(주석 1줄 추가)과 무관