@simplysm/sd-cli 14.2.6 → 14.2.7

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.
@@ -0,0 +1,224 @@
1
+ import path from "path";
2
+ import { fsx } from "@simplysm/core-node";
3
+ import type esbuild from "esbuild";
4
+
5
+ /** 빌드 산출물에 기록하는 제3자 라이선스 고지 파일명 */
6
+ export const LICENSE_NOTICE_FILE_NAME = "3rdpartylicenses.txt";
7
+
8
+ /** 패키지 경계를 나타내는 경로 세그먼트 */
9
+ const NODE_MODULES_SEGMENT = "node_modules";
10
+
11
+ /** 패키지 루트에서 라이선스 본문을 찾을 때 시도하는 파일명 */
12
+ const LICENSE_FILE_NAMES = ["LICENSE", "LICENSE.txt", "LICENSE.md"];
13
+
14
+ /** npm 이 정한 커스텀 라이선스 표기 접두사 (`SEE LICENSE IN <파일명>`) */
15
+ const CUSTOM_LICENSE_PREFIX = "SEE LICENSE IN ";
16
+
17
+ /** 고지 파일의 패키지 항목 구분선 */
18
+ const ENTRY_SEPARATOR = "-".repeat(80);
19
+
20
+ /** 고지 파일 머리말 */
21
+ const FILE_HEADER = "이 파일은 함께 배포되는 산출물에 포함된 제3자 패키지의 라이선스 고지입니다.";
22
+
23
+ /**
24
+ * 라이선스 판정에 사용하는 package.json 필드.
25
+ *
26
+ * `license` 오브젝트와 `licenses` 배열은 npm 이 deprecated 로 지정한 표기지만
27
+ * 실제 배포된 구 패키지에 남아 있어, 라이선스 미상 오판을 막기 위해 함께 인식한다.
28
+ * (https://docs.npmjs.com/cli/v11/configuring-npm/package-json)
29
+ */
30
+ interface LicenseManifest {
31
+ name?: string;
32
+ version?: string;
33
+ license?: string | { type?: string };
34
+ licenses?: { type?: string }[];
35
+ }
36
+
37
+ /** metafile 입력 경로에서 역추적한 패키지 위치 */
38
+ export interface PackageRef {
39
+ packageName: string;
40
+ packageDir: string;
41
+ }
42
+
43
+ /**
44
+ * `node_modules` 아래 파일의 절대 경로에서 그 파일이 속한 패키지를 역추적한다.
45
+ *
46
+ * 경로를 상위로 거슬러 올라가며 `node_modules` 세그먼트를 찾고, 직전까지 지나온
47
+ * 세그먼트로 패키지명을 조립한다. `@scope/name` 형태는 두 세그먼트를 한 이름으로 합친다.
48
+ *
49
+ * @returns `node_modules` 를 거치지 않는 경로(자체 소스, 워크스페이스 패키지)면 undefined
50
+ */
51
+ export function resolvePackageRef(absInputPath: string): PackageRef | undefined {
52
+ let dir = absInputPath;
53
+ let nameOrScope: string | undefined;
54
+ let nameOrFile: string | undefined;
55
+ let found = false;
56
+
57
+ while (dir !== path.dirname(dir)) {
58
+ const segment = path.basename(dir);
59
+ if (segment === NODE_MODULES_SEGMENT) {
60
+ found = true;
61
+ break;
62
+ }
63
+ nameOrFile = nameOrScope;
64
+ nameOrScope = segment;
65
+ dir = path.dirname(dir);
66
+ }
67
+
68
+ if (!found || nameOrScope == null) return undefined;
69
+
70
+ const packageName =
71
+ nameOrScope.startsWith("@") && nameOrFile != null
72
+ ? `${nameOrScope}/${nameOrFile}`
73
+ : nameOrScope;
74
+
75
+ return { packageName, packageDir: path.join(dir, packageName) };
76
+ }
77
+
78
+ /**
79
+ * package.json 에서 라이선스 식별자를 뽑는다.
80
+ *
81
+ * @returns 어떤 표기로도 라이선스를 알 수 없으면 undefined
82
+ */
83
+ export function resolveLicenseId(manifest: LicenseManifest): string | undefined {
84
+ if (typeof manifest.license === "string") {
85
+ const trimmed = manifest.license.trim();
86
+ if (trimmed !== "") return trimmed;
87
+ } else if (manifest.license != null) {
88
+ const type = manifest.license.type?.trim();
89
+ if (type != null && type !== "") return type;
90
+ }
91
+
92
+ const legacyTypes = (manifest.licenses ?? [])
93
+ .map((item) => item.type?.trim())
94
+ .filter((type): type is string => type != null && type !== "");
95
+ if (legacyTypes.length > 0) return legacyTypes.join(" OR ");
96
+
97
+ return undefined;
98
+ }
99
+
100
+ /**
101
+ * 패키지 루트에서 라이선스 본문을 읽는다.
102
+ *
103
+ * `SEE LICENSE IN <파일명>` 표기면 그 파일을 읽고, 아니면 관용적인 LICENSE 파일명을 차례로 시도한다.
104
+ *
105
+ * @returns 라이선스 파일을 동봉하지 않은 패키지면 undefined (식별자만으로 고지가 성립한다)
106
+ * @throws `SEE LICENSE IN` 이 가리키는 파일을 읽을 수 없을 때
107
+ */
108
+ async function readLicenseText(
109
+ packageRef: PackageRef,
110
+ licenseId: string,
111
+ ): Promise<string | undefined> {
112
+ if (licenseId.toUpperCase().startsWith(CUSTOM_LICENSE_PREFIX)) {
113
+ const fileName = licenseId.slice(CUSTOM_LICENSE_PREFIX.length).trim();
114
+ const normalized = path.normalize(fileName);
115
+ if (path.isAbsolute(normalized) || normalized.startsWith("..")) {
116
+ throw new Error(
117
+ `라이선스 파일 경로가 패키지 바깥을 가리킵니다: ${packageRef.packageName} (${licenseId})`,
118
+ );
119
+ }
120
+
121
+ const customPath = path.join(packageRef.packageDir, normalized);
122
+ if (!(await fsx.exists(customPath))) {
123
+ throw new Error(`라이선스 파일을 찾을 수 없습니다: ${packageRef.packageName} (${licenseId})`);
124
+ }
125
+ return fsx.read(customPath);
126
+ }
127
+
128
+ for (const candidate of LICENSE_FILE_NAMES) {
129
+ const licensePath = path.join(packageRef.packageDir, candidate);
130
+ if (await fsx.exists(licensePath)) {
131
+ return fsx.read(licensePath);
132
+ }
133
+ }
134
+
135
+ return undefined;
136
+ }
137
+
138
+ /** 고지 파일에 기록할 패키지 1건 */
139
+ interface LicenseEntry {
140
+ name: string;
141
+ version: string | undefined;
142
+ licenseId: string;
143
+ licenseText: string | undefined;
144
+ }
145
+
146
+ /**
147
+ * esbuild metafile 을 근거로 번들에 실제 포함된 제3자 패키지의 라이선스 고지문을 만든다.
148
+ *
149
+ * 트리셰이킹으로 잘려나가 산출물에 남지 않은 입력(`bytesInOutput` 이 0 이하)은 제외한다.
150
+ * 같은 패키지의 같은 버전은 한 번만 기록한다.
151
+ *
152
+ * @param metafile esbuild 빌드 결과 metafile
153
+ * @param rootDir metafile 의 상대 입력 경로를 절대 경로로 바꿀 기준 디렉터리 (esbuild 실행 작업 디렉터리)
154
+ * @throws 라이선스를 알 수 없는 패키지가 하나라도 있을 때
155
+ */
156
+ export async function extractLicenses(
157
+ metafile: esbuild.Metafile,
158
+ rootDir: string,
159
+ ): Promise<string> {
160
+ const seenInputPaths = new Set<string>();
161
+ const seenPackageIds = new Set<string>();
162
+ const entries: LicenseEntry[] = [];
163
+ const unknownPackages: string[] = [];
164
+
165
+ for (const output of Object.values(metafile.outputs)) {
166
+ for (const [inputPath, { bytesInOutput }] of Object.entries(output.inputs)) {
167
+ if (bytesInOutput <= 0) continue;
168
+ if (seenInputPaths.has(inputPath)) continue;
169
+ seenInputPaths.add(inputPath);
170
+
171
+ const packageRef = resolvePackageRef(path.resolve(rootDir, inputPath));
172
+ if (packageRef == null) continue;
173
+
174
+ const manifestPath = path.join(packageRef.packageDir, "package.json");
175
+ if (!(await fsx.exists(manifestPath))) {
176
+ throw new Error(`패키지 정보를 찾을 수 없습니다: ${manifestPath} (입력: ${inputPath})`);
177
+ }
178
+ const manifest = await fsx.readJson<LicenseManifest>(manifestPath);
179
+
180
+ const packageId = `${packageRef.packageName}@${manifest.version ?? ""}`;
181
+ if (seenPackageIds.has(packageId)) continue;
182
+ seenPackageIds.add(packageId);
183
+
184
+ const licenseId = resolveLicenseId(manifest);
185
+ if (licenseId == null) {
186
+ unknownPackages.push(packageId);
187
+ continue;
188
+ }
189
+
190
+ entries.push({
191
+ name: manifest.name ?? packageRef.packageName,
192
+ version: manifest.version,
193
+ licenseId,
194
+ licenseText: await readLicenseText(packageRef, licenseId),
195
+ });
196
+ }
197
+ }
198
+
199
+ if (unknownPackages.length > 0) {
200
+ throw new Error(
201
+ `라이선스를 알 수 없는 패키지가 있어 고지 파일을 만들 수 없습니다.\n` +
202
+ unknownPackages
203
+ .sort()
204
+ .map((id) => ` - ${id}`)
205
+ .join("\n"),
206
+ );
207
+ }
208
+
209
+ entries.sort(
210
+ (a, b) => a.name.localeCompare(b.name) || (a.version ?? "").localeCompare(b.version ?? ""),
211
+ );
212
+
213
+ let content = `${FILE_HEADER}\n\n${ENTRY_SEPARATOR}\n`;
214
+ for (const entry of entries) {
215
+ content += `Package: ${entry.name}${entry.version != null ? `@${entry.version}` : ""}\n`;
216
+ content += `License: ${entry.licenseId}\n`;
217
+ if (entry.licenseText != null) {
218
+ content += `\n${entry.licenseText.trimEnd()}\n`;
219
+ }
220
+ content += `${ENTRY_SEPARATOR}\n`;
221
+ }
222
+
223
+ return content;
224
+ }
@@ -13,6 +13,7 @@ import { createDevHttpServer, type DevHttpServer } from "../dev-server/dev-http-
13
13
  import { createHmrService, type HmrService } from "../dev-server/hmr-service";
14
14
  import { createHmrPostTransform } from "../dev-server/hmr-client-script";
15
15
  import { copyPublicFiles, watchPublicFiles } from "../utils/copy-public";
16
+ import { extractLicenses, LICENSE_NOTICE_FILE_NAME } from "../utils/license-extractor";
16
17
  import { buildSsrBundle } from "../esbuild/esbuild-ssr-config";
17
18
  import { prerenderRoutes } from "../ssg/prerender";
18
19
  import type { SdBrowserSupportConfig, SdPwaConfig } from "../sd-config.types";
@@ -99,8 +100,7 @@ function resolvePackageInfo(info: ClientBuildInfo): {
99
100
  async function build(info: ClientBuildInfo): Promise<ClientBuildResult> {
100
101
  logger.debug(`[${info.name}] client worker build 시작`);
101
102
  try {
102
- const { pkgName, legacyModule, browserslist, postcssPlugins } =
103
- resolvePackageInfo(info);
103
+ const { pkgName, legacyModule, browserslist, postcssPlugins } = resolvePackageInfo(info);
104
104
 
105
105
  const outdir = info.outDir ?? path.join(info.pkgDir, "dist");
106
106
 
@@ -136,8 +136,7 @@ async function build(info: ClientBuildInfo): Promise<ClientBuildResult> {
136
136
  const basePath = info.base ?? `/${name}/`;
137
137
  const indexPath = path.join(info.pkgDir, "src", "index.html");
138
138
 
139
- const pwaHtmlTransform =
140
- info.pwa !== false ? createPwaHtmlTransform() : undefined;
139
+ const pwaHtmlTransform = info.pwa !== false ? createPwaHtmlTransform() : undefined;
141
140
 
142
141
  const indexResult = await generateIndexHtml({
143
142
  indexPath,
@@ -188,8 +187,12 @@ async function build(info: ClientBuildInfo): Promise<ClientBuildResult> {
188
187
  await ctx.context.dispose();
189
188
  // SourceFileCache는 LMDB 기반. context.dispose()에 의해 정리됨.
190
189
 
191
- // 8. .config.json 기록
190
+ // 8. .config.json + 제3자 라이선스 고지 기록
192
191
  writeConfigJson(outdir, info.configs);
192
+ fsx.writeSync(
193
+ path.join(outdir, LICENSE_NOTICE_FILE_NAME),
194
+ await extractLicenses(result.metafile!, process.cwd()),
195
+ );
193
196
 
194
197
  logger.debug(`[${info.name}] client worker build 완료`);
195
198
  return {
@@ -224,13 +227,9 @@ function createSourceFileCachePlugin(): esbuild.Plugin {
224
227
  pluginBuild.onStart(() => {
225
228
  // sourceFileCache 무효화: 변경된 파일의 loadResultCache + TypeScript 소스 캐시 모두 제거
226
229
  if (esbuildResult != null) {
227
- const { loadResultCache, typeScriptFileCache } =
228
- esbuildResult.sourceFileCache;
230
+ const { loadResultCache, typeScriptFileCache } = esbuildResult.sourceFileCache;
229
231
  // JS 파일 (loadResultCache) + TS 파일 (typeScriptFileCache) 모두 감시
230
- const watchTargets = [
231
- ...loadResultCache.watchFiles,
232
- ...typeScriptFileCache.keys(),
233
- ];
232
+ const watchTargets = [...loadResultCache.watchFiles, ...typeScriptFileCache.keys()];
234
233
  const changedFiles = mtimeTracker.detectChanges(watchTargets);
235
234
  const normalizedChangedFiles = new Set<string>();
236
235
  for (const file of changedFiles) {
@@ -296,12 +295,10 @@ function createDevBuildEndHandler(
296
295
 
297
296
  // build 이벤트 전송
298
297
  const success = result.errors.length === 0;
299
- const errors = result.errors.length > 0
300
- ? formatEsbuildMessages(result.errors, "error")
301
- : undefined;
302
- const warnings = result.warnings.length > 0
303
- ? formatEsbuildMessages(result.warnings, "warning")
304
- : undefined;
298
+ const errors =
299
+ result.errors.length > 0 ? formatEsbuildMessages(result.errors, "error") : undefined;
300
+ const warnings =
301
+ result.warnings.length > 0 ? formatEsbuildMessages(result.warnings, "warning") : undefined;
305
302
 
306
303
  if (!isInitialBuild) {
307
304
  sender.send("build", { success, errors, warnings });
@@ -338,8 +335,7 @@ function createDevBuildEndHandler(
338
335
  */
339
336
  async function startWatch(info: ClientBuildInfo): Promise<ClientBuildResult> {
340
337
  guardStartWatch();
341
- const { pkgName, legacyModule, browserslist, postcssPlugins } =
342
- resolvePackageInfo(info);
338
+ const { pkgName, legacyModule, browserslist, postcssPlugins } = resolvePackageInfo(info);
343
339
 
344
340
  logger.debug(
345
341
  `[${info.name}] client worker startWatch 시작 (port: ${info.port ?? "auto"}, legacy: ${legacyModule})`,
@@ -492,10 +488,7 @@ async function stopWatch(): Promise<void> {
492
488
  }
493
489
 
494
490
  /** .config.json 생성 */
495
- function writeConfigJson(
496
- distDir: string,
497
- configs?: Record<string, unknown>,
498
- ): void {
491
+ function writeConfigJson(distDir: string, configs?: Record<string, unknown>): void {
499
492
  fsx.writeJsonSync(path.join(distDir, ".config.json"), configs ?? {}, { space: 2 });
500
493
  }
501
494
 
@@ -7,21 +7,19 @@ import { formatEsbuildMessages } from "../utils/output-utils";
7
7
  import type { BuildOutput } from "../engines/types";
8
8
  import type { SerializedDiagnostic } from "../typecheck/typecheck-serialization";
9
9
  import type { LintWithProgramResult } from "../lint/lint-with-program";
10
+ import { parseTsconfig, getPackageSourceFiles } from "../utils/tsconfig";
11
+ import { createServerEsbuildOptions, writeChangedOutputFiles } from "../esbuild/esbuild-config";
10
12
  import {
11
- parseTsconfig,
12
- getPackageSourceFiles,
13
- } from "../utils/tsconfig";
14
- import {
15
- createServerEsbuildOptions,
16
- writeChangedOutputFiles,
17
- } from "../esbuild/esbuild-config";
18
- import { collectAllExternals, generateProductionFiles } from "../deps/server-externals/server-production-files";
13
+ collectAllExternals,
14
+ generateProductionFiles,
15
+ } from "../deps/server-externals/server-production-files";
19
16
  import { SdTsCompiler } from "../ts-compiler/SdTsCompiler";
20
17
  import { createTscPlugin } from "../esbuild/esbuild-tsc-plugin";
21
18
  import { createWorkerBundlePlugin } from "../esbuild/esbuild-worker-plugin";
22
19
  import { setupWorkerLifecycle } from "./shared-worker-lifecycle";
23
20
  import { buildWatchPaths } from "./build-watch-paths";
24
21
  import { copyPublicFiles, watchPublicFiles } from "../utils/copy-public";
22
+ import { extractLicenses, LICENSE_NOTICE_FILE_NAME } from "../utils/license-extractor";
25
23
  import * as esbuildCtx from "./server-esbuild-context";
26
24
  import { startServerWatchLoop } from "./server-watch-manager";
27
25
 
@@ -72,7 +70,12 @@ export interface ServerWatchInfo {
72
70
  * 서버 빌드 결과 (LibraryBuildResult + mainJsPath 형태)
73
71
  */
74
72
  export interface ServerBuildResult {
75
- build: { success: boolean; errors?: string[]; warnings?: string[]; diagnostics: SerializedDiagnostic[] };
73
+ build: {
74
+ success: boolean;
75
+ errors?: string[];
76
+ warnings?: string[];
77
+ diagnostics: SerializedDiagnostic[];
78
+ };
76
79
  lint?: LintWithProgramResult;
77
80
  mainJsPath: string;
78
81
  }
@@ -133,7 +136,9 @@ const { logger, guardStartWatch } = setupWorkerLifecycle("server-build", cleanup
133
136
  */
134
137
  async function build(info: ServerBuildInfo): Promise<ServerBuildResult> {
135
138
  const mainJsPath = pathx.posixResolve(info.pkgDir, "dist", "main.js");
136
- logger.debug(`[${info.name}] server worker build 시작 (js: ${info.output.js}, dts: ${info.output.dts})`);
139
+ logger.debug(
140
+ `[${info.name}] server worker build 시작 (js: ${info.output.js}, dts: ${info.output.dts})`,
141
+ );
137
142
 
138
143
  try {
139
144
  // tsconfig 파싱
@@ -147,6 +152,7 @@ async function build(info: ServerBuildInfo): Promise<ServerBuildResult> {
147
152
  let tscErrors: string[];
148
153
  let tscDiagnostics: SerializedDiagnostic[];
149
154
  let lint: LintWithProgramResult | undefined;
155
+ let bundleMetafile: esbuild.Metafile | undefined;
150
156
 
151
157
  if (info.output.js) {
152
158
  // js=true: tsc 플러그인 통합 — 단일 esbuild.build() 호출
@@ -166,8 +172,10 @@ async function build(info: ServerBuildInfo): Promise<ServerBuildResult> {
166
172
  external: bundleExternals,
167
173
  });
168
174
 
169
- jsResult = await esbuild.build({ ...esbuildOptions, plugins: [createWorkerBundlePlugin(), tscPlugin.plugin] })
175
+ jsResult = await esbuild
176
+ .build({ ...esbuildOptions, plugins: [createWorkerBundlePlugin(), tscPlugin.plugin] })
170
177
  .then(async (result) => {
178
+ bundleMetafile = result.metafile;
171
179
  if (result.outputFiles) {
172
180
  await writeChangedOutputFiles(result.outputFiles, { rewriteJsExtensions: false });
173
181
  }
@@ -217,11 +225,23 @@ async function build(info: ServerBuildInfo): Promise<ServerBuildResult> {
217
225
  await copyPublicFiles(info.pkgDir, false);
218
226
 
219
227
  generateProductionFiles(info, prodDependencies);
228
+
229
+ if (jsResult.success) {
230
+ if (bundleMetafile == null) {
231
+ throw new Error("번들 metafile이 없어 제3자 라이선스 고지를 생성할 수 없습니다.");
232
+ }
233
+ await fsx.write(
234
+ path.join(info.pkgDir, "dist", LICENSE_NOTICE_FILE_NAME),
235
+ await extractLicenses(bundleMetafile, process.cwd()),
236
+ );
237
+ }
220
238
  }
221
239
 
222
240
  const allErrors = [...(jsResult.errors ?? []), ...tscErrors];
223
241
  const tscSuccess = tscErrors.length === 0;
224
- logger.debug(`[${info.name}] server worker build 완료 (js: ${jsResult.success}, tsc: ${tscSuccess})`);
242
+ logger.debug(
243
+ `[${info.name}] server worker build 완료 (js: ${jsResult.success}, tsc: ${tscSuccess})`,
244
+ );
225
245
  return {
226
246
  build: {
227
247
  success: jsResult.success && tscSuccess,
@@ -0,0 +1,273 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import fs from "fs";
3
+ import os from "os";
4
+ import path from "path";
5
+ import type esbuild from "esbuild";
6
+
7
+ const { extractLicenses, resolvePackageRef, resolveLicenseId } =
8
+ await import("../../src/utils/license-extractor");
9
+
10
+ let rootDir: string;
11
+
12
+ beforeEach(() => {
13
+ rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "sd-cli-license-"));
14
+ });
15
+
16
+ afterEach(() => {
17
+ fs.rmSync(rootDir, { recursive: true, force: true });
18
+ });
19
+
20
+ /** rootDir 하위에 패키지 디렉터리를 만든다 */
21
+ function writePackage(
22
+ relDir: string,
23
+ manifest: object | undefined,
24
+ files: Record<string, string> = {},
25
+ ): void {
26
+ const dir = path.join(rootDir, relDir);
27
+ fs.mkdirSync(dir, { recursive: true });
28
+ if (manifest != null) {
29
+ fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify(manifest));
30
+ }
31
+ for (const [fileName, content] of Object.entries(files)) {
32
+ fs.writeFileSync(path.join(dir, fileName), content);
33
+ }
34
+ }
35
+
36
+ /** 입력 경로별 bytesInOutput 을 담은 단일 출력 metafile 을 만든다 */
37
+ function metafileOf(inputs: Record<string, number>): esbuild.Metafile {
38
+ return {
39
+ inputs: {},
40
+ outputs: {
41
+ "dist/main.js": {
42
+ bytes: 0,
43
+ inputs: Object.fromEntries(
44
+ Object.entries(inputs).map(([inputPath, bytesInOutput]) => [
45
+ inputPath,
46
+ { bytesInOutput },
47
+ ]),
48
+ ),
49
+ imports: [],
50
+ exports: [],
51
+ },
52
+ },
53
+ };
54
+ }
55
+
56
+ describe("resolvePackageRef", () => {
57
+ it("node_modules 아래 파일에서 패키지명을 역추적한다", () => {
58
+ const ref = resolvePackageRef(path.join(rootDir, "node_modules", "foo", "dist", "index.js"));
59
+ expect(ref?.packageName).toBe("foo");
60
+ expect(ref?.packageDir).toBe(path.join(rootDir, "node_modules", "foo"));
61
+ });
62
+
63
+ it("scope 가 있는 패키지는 scope 를 포함한 이름으로 역추적한다", () => {
64
+ const ref = resolvePackageRef(
65
+ path.join(rootDir, "node_modules", "@scope", "bar", "src", "index.js"),
66
+ );
67
+ expect(ref?.packageName).toBe("@scope/bar");
68
+ expect(ref?.packageDir).toBe(path.join(rootDir, "node_modules", "@scope", "bar"));
69
+ });
70
+
71
+ it("중첩된 node_modules 에서는 가장 가까운 패키지를 찾는다", () => {
72
+ const ref = resolvePackageRef(
73
+ path.join(rootDir, "node_modules", "outer", "node_modules", "inner", "index.js"),
74
+ );
75
+ expect(ref?.packageName).toBe("inner");
76
+ });
77
+
78
+ it("node_modules 를 거치지 않는 경로는 패키지가 아니다", () => {
79
+ expect(
80
+ resolvePackageRef(path.join(rootDir, "packages", "app", "src", "main.ts")),
81
+ ).toBeUndefined();
82
+ });
83
+ });
84
+
85
+ describe("resolveLicenseId", () => {
86
+ it("license 문자열을 그대로 사용한다", () => {
87
+ expect(resolveLicenseId({ license: "MIT" })).toBe("MIT");
88
+ });
89
+
90
+ it("공백뿐인 license 는 미상으로 본다", () => {
91
+ expect(resolveLicenseId({ license: " " })).toBeUndefined();
92
+ });
93
+
94
+ it("deprecated license 오브젝트의 type 을 인식한다", () => {
95
+ expect(resolveLicenseId({ license: { type: "BSD-3-Clause" } })).toBe("BSD-3-Clause");
96
+ });
97
+
98
+ it("deprecated licenses 배열의 type 들을 OR 로 잇는다", () => {
99
+ expect(resolveLicenseId({ licenses: [{ type: "MIT" }, { type: "Apache-2.0" }] })).toBe(
100
+ "MIT OR Apache-2.0",
101
+ );
102
+ });
103
+
104
+ it("어떤 표기도 없으면 미상이다", () => {
105
+ expect(resolveLicenseId({ name: "foo", version: "1.0.0" })).toBeUndefined();
106
+ });
107
+ });
108
+
109
+ describe("extractLicenses", () => {
110
+ it("LICENSE 파일이 있으면 본문까지 기록한다", async () => {
111
+ writePackage(
112
+ "node_modules/foo",
113
+ { name: "foo", version: "1.2.3", license: "MIT" },
114
+ { LICENSE: "Copyright (c) 2020 Foo Author" },
115
+ );
116
+
117
+ const content = await extractLicenses(metafileOf({ "node_modules/foo/index.js": 10 }), rootDir);
118
+
119
+ expect(content).toContain("Package: foo@1.2.3");
120
+ expect(content).toContain("License: MIT");
121
+ expect(content).toContain("Copyright (c) 2020 Foo Author");
122
+ });
123
+
124
+ it("LICENSE 파일이 없으면 라이선스 식별자만 기록한다", async () => {
125
+ writePackage("node_modules/bar", { name: "bar", version: "0.1.0", license: "ISC" });
126
+
127
+ const content = await extractLicenses(metafileOf({ "node_modules/bar/index.js": 10 }), rootDir);
128
+
129
+ expect(content).toContain("Package: bar@0.1.0");
130
+ expect(content).toContain("License: ISC");
131
+ });
132
+
133
+ it("LICENSE.md 등 다른 파일명도 찾는다", async () => {
134
+ writePackage(
135
+ "node_modules/baz",
136
+ { name: "baz", version: "1.0.0", license: "MIT" },
137
+ { "LICENSE.md": "# License\nMIT text here" },
138
+ );
139
+
140
+ const content = await extractLicenses(metafileOf({ "node_modules/baz/index.js": 10 }), rootDir);
141
+
142
+ expect(content).toContain("MIT text here");
143
+ });
144
+
145
+ it("scope 가 있는 패키지도 기록한다", async () => {
146
+ writePackage("node_modules/@scope/pkg", {
147
+ name: "@scope/pkg",
148
+ version: "2.0.0",
149
+ license: "Apache-2.0",
150
+ });
151
+
152
+ const content = await extractLicenses(
153
+ metafileOf({ "node_modules/@scope/pkg/index.js": 10 }),
154
+ rootDir,
155
+ );
156
+
157
+ expect(content).toContain("Package: @scope/pkg@2.0.0");
158
+ });
159
+
160
+ it("같은 패키지의 여러 파일이 포함돼도 한 번만 기록한다", async () => {
161
+ writePackage("node_modules/foo", { name: "foo", version: "1.0.0", license: "MIT" });
162
+
163
+ const content = await extractLicenses(
164
+ metafileOf({
165
+ "node_modules/foo/index.js": 10,
166
+ "node_modules/foo/util.js": 20,
167
+ "node_modules/foo/deep/inner.js": 30,
168
+ }),
169
+ rootDir,
170
+ );
171
+
172
+ expect(content.match(/Package: foo@1\.0\.0/g)).toHaveLength(1);
173
+ });
174
+
175
+ it("산출물에 남지 않은 입력(bytesInOutput 0)은 제외한다", async () => {
176
+ writePackage("node_modules/used", { name: "used", version: "1.0.0", license: "MIT" });
177
+ writePackage("node_modules/shaken", { name: "shaken", version: "1.0.0", license: "MIT" });
178
+
179
+ const content = await extractLicenses(
180
+ metafileOf({
181
+ "node_modules/used/index.js": 10,
182
+ "node_modules/shaken/index.js": 0,
183
+ }),
184
+ rootDir,
185
+ );
186
+
187
+ expect(content).toContain("Package: used@1.0.0");
188
+ expect(content).not.toContain("Package: shaken@1.0.0");
189
+ });
190
+
191
+ it("node_modules 밖의 자체 소스는 제외한다", async () => {
192
+ const content = await extractLicenses(metafileOf({ "packages/app/src/main.ts": 100 }), rootDir);
193
+
194
+ expect(content).not.toContain("Package:");
195
+ });
196
+
197
+ it("SEE LICENSE IN 표기는 지정한 파일을 읽는다", async () => {
198
+ writePackage(
199
+ "node_modules/custom",
200
+ { name: "custom", version: "1.0.0", license: "SEE LICENSE IN TERMS.txt" },
201
+ { "TERMS.txt": "사내 전용 라이선스 조건" },
202
+ );
203
+
204
+ const content = await extractLicenses(
205
+ metafileOf({ "node_modules/custom/index.js": 10 }),
206
+ rootDir,
207
+ );
208
+
209
+ expect(content).toContain("사내 전용 라이선스 조건");
210
+ });
211
+
212
+ it("SEE LICENSE IN 이 가리키는 파일이 없으면 실패한다", async () => {
213
+ writePackage("node_modules/custom", {
214
+ name: "custom",
215
+ version: "1.0.0",
216
+ license: "SEE LICENSE IN MISSING.txt",
217
+ });
218
+
219
+ await expect(
220
+ extractLicenses(metafileOf({ "node_modules/custom/index.js": 10 }), rootDir),
221
+ ).rejects.toThrow("custom");
222
+ });
223
+
224
+ it("SEE LICENSE IN 이 패키지 바깥을 가리키면 실패한다", async () => {
225
+ writePackage("node_modules/escape", {
226
+ name: "escape",
227
+ version: "1.0.0",
228
+ license: "SEE LICENSE IN ../../secret.txt",
229
+ });
230
+
231
+ await expect(
232
+ extractLicenses(metafileOf({ "node_modules/escape/index.js": 10 }), rootDir),
233
+ ).rejects.toThrow("패키지 바깥");
234
+ });
235
+
236
+ it("라이선스를 알 수 없는 패키지가 있으면 실패하고 그 패키지를 알린다", async () => {
237
+ writePackage("node_modules/ok", { name: "ok", version: "1.0.0", license: "MIT" });
238
+ writePackage("node_modules/nolicense", { name: "nolicense", version: "3.1.4" });
239
+
240
+ await expect(
241
+ extractLicenses(
242
+ metafileOf({
243
+ "node_modules/ok/index.js": 10,
244
+ "node_modules/nolicense/index.js": 10,
245
+ }),
246
+ rootDir,
247
+ ),
248
+ ).rejects.toThrow("nolicense@3.1.4");
249
+ });
250
+
251
+ it("package.json 이 없는 node_modules 경로는 실패한다", async () => {
252
+ writePackage("node_modules/broken", undefined, { "index.js": "" });
253
+
254
+ await expect(
255
+ extractLicenses(metafileOf({ "node_modules/broken/index.js": 10 }), rootDir),
256
+ ).rejects.toThrow("패키지 정보를 찾을 수 없습니다");
257
+ });
258
+
259
+ it("패키지를 이름순으로 정렬해 기록한다", async () => {
260
+ writePackage("node_modules/zeta", { name: "zeta", version: "1.0.0", license: "MIT" });
261
+ writePackage("node_modules/alpha", { name: "alpha", version: "1.0.0", license: "MIT" });
262
+
263
+ const content = await extractLicenses(
264
+ metafileOf({
265
+ "node_modules/zeta/index.js": 10,
266
+ "node_modules/alpha/index.js": 10,
267
+ }),
268
+ rootDir,
269
+ );
270
+
271
+ expect(content.indexOf("Package: alpha")).toBeLessThan(content.indexOf("Package: zeta"));
272
+ });
273
+ });