From 34fd4f3080bc3fd29648a0eab3c32baf2e8ef4c9 Mon Sep 17 00:00:00 2001 From: YukiWorks432 Date: Sat, 1 Aug 2026 20:48:21 +0900 Subject: [PATCH 1/3] =?UTF-8?q?build:=20=E8=A4=87=E6=95=B0=E3=82=B9?= =?UTF-8?q?=E3=82=AF=E3=83=AA=E3=83=97=E3=83=88=E3=81=AE=E3=83=93=E3=83=AB?= =?UTF-8?q?=E3=83=89=E3=82=92=E4=B8=A6=E5=88=97=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/guides/getting-started.md | 5 + docs/project-overview.md | 12 ++ package.json | 9 +- pnpm-lock.yaml | 51 ++----- rollup.config.mjs | 101 +++++++++++-- scripts/build.mjs | 207 ++++++++++++++++++++++++++ scripts/build.test.mjs | 90 +++++++++++ scripts/buildDependencyScope.test.mjs | 106 +++++++++++++ scripts/buildOptions.mjs | 118 +++++++++++++++ scripts/buildOptions.test.mjs | 38 +++++ scripts/buildOutput.test.mjs | 71 +++++++++ scripts/buildScheduler.mjs | 56 +++++++ scripts/buildScheduler.test.mjs | 67 +++++++++ 13 files changed, 878 insertions(+), 53 deletions(-) create mode 100644 scripts/build.mjs create mode 100644 scripts/build.test.mjs create mode 100644 scripts/buildDependencyScope.test.mjs create mode 100644 scripts/buildOptions.mjs create mode 100644 scripts/buildOptions.test.mjs create mode 100644 scripts/buildOutput.test.mjs create mode 100644 scripts/buildScheduler.mjs create mode 100644 scripts/buildScheduler.test.mjs diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index d7d9a18..a599b1c 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -66,6 +66,11 @@ pnpm build 出力先: `dist/aeft/MyFirstScript/MyFirstScript.jsx` +複数スクリプトの単発ビルドは、既定で最大4件まで並列に実行されます。並列度を指定する +場合は `pnpm build --concurrency=2` のように指定してください。`--concurrency=1` では +スクリプト単位の依存範囲を保ったまま逐次実行できます。監視ビルド `pnpm watch` はこの +最適化の対象外です。 + ### 4. Adobe アプリで実行 - After Effects: `File > Scripts > Run Script File...` からビルド済み `.jsx` を選択します diff --git a/docs/project-overview.md b/docs/project-overview.md index 0564bab..503f1f4 100644 --- a/docs/project-overview.md +++ b/docs/project-overview.md @@ -248,6 +248,18 @@ pnpm add-app -- --app=idsn | `pnpm clean` | ビルドハッシュをクリーンアップ | | `pnpm test` | ビルド差分判定の回帰テスト | +`pnpm build`、`pnpm build --all`、`pnpm build --app=`、アプリ別のビルド別名は、 +対象スクリプトごとにTypeScriptの依存範囲を限定し、上限付きで並列実行します。既定の +並列度は `min(4, os.availableParallelism(), 対象件数)` です。利用できない実行環境では +`os.cpus().length` を使います。 + +並列度は `--concurrency=<正整数>` で上書きできます。`--concurrency=1` は並列実行だけを +無効にし、スクリプト単位のTypeScript範囲限定は維持します。0、負数、小数、数値以外、 +値なしはエラーとして終了します。 + +`pnpm watch` は今回の単発ビルド最適化の対象外です。従来どおりRollupの監視処理を使い、 +`--concurrency` の指定は監視ビルドには適用されません。 + ## 差分ビルドの判定 `pnpm build` は、各スクリプトの `index.ts` から相対 `import` / `export ... from` diff --git a/package.json b/package.json index c5d70b7..20678e2 100644 --- a/package.json +++ b/package.json @@ -7,10 +7,10 @@ }, "packageManager": "pnpm@11.17.0", "scripts": { - "build": "rollup -c", - "build:aeft": "rollup -c --app=aeft", - "build:ilst": "rollup -c --app=ilst", - "build:phxs": "rollup -c --app=phxs", + "build": "node ./scripts/build.mjs", + "build:aeft": "node ./scripts/build.mjs --app=aeft", + "build:ilst": "node ./scripts/build.mjs --app=ilst", + "build:phxs": "node ./scripts/build.mjs --app=phxs", "clean": "node ./scripts/cleanBuildHashes.mjs", "test": "node --test", "watch": "rollup -c -w", @@ -34,6 +34,7 @@ "@babel/core": "^7.29.7", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-transform-class-properties": "^7.29.7", + "@babel/plugin-transform-classes": "^7.29.7", "@babel/plugin-transform-member-expression-literals": "^7.29.7", "@babel/plugin-transform-property-literals": "^7.29.7", "@babel/plugin-transform-property-mutators": "^7.29.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0b8d1d..425346f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,9 @@ importers: '@babel/plugin-transform-class-properties': specifier: ^7.29.7 version: 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-classes': + specifier: ^7.29.7 + version: 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-member-expression-literals': specifier: ^7.29.7 version: 7.29.7(@babel/core@7.29.7) @@ -130,18 +133,10 @@ packages: resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} engines: {node: '>=6.9.0'} - '@babel/helper-annotate-as-pure@7.27.3': - resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} - engines: {node: '>=6.9.0'} - '@babel/helper-annotate-as-pure@7.29.7': resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.28.6': - resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} - engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.29.7': resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} engines: {node: '>=6.9.0'} @@ -169,10 +164,6 @@ packages: peerDependencies: '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} - engines: {node: '>=6.9.0'} - '@babel/helper-globals@7.29.7': resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} engines: {node: '>=6.9.0'} @@ -239,10 +230,6 @@ packages: resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} - engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.29.7': resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} engines: {node: '>=6.9.0'} @@ -1649,7 +1636,7 @@ snapshots: '@babel/generator@7.29.1': dependencies: '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 @@ -1662,22 +1649,10 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 - '@babel/helper-annotate-as-pure@7.27.3': - dependencies: - '@babel/types': 7.29.0 - '@babel/helper-annotate-as-pure@7.29.7': dependencies: '@babel/types': 7.29.7 - '@babel/helper-compilation-targets@7.28.6': - dependencies: - '@babel/compat-data': 7.29.0 - '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.2 - lru-cache: 5.1.1 - semver: 6.3.1 - '@babel/helper-compilation-targets@7.29.7': dependencies: '@babel/compat-data': 7.29.7 @@ -1702,7 +1677,7 @@ snapshots: '@babel/helper-create-regexp-features-plugin@7.28.5(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-annotate-as-pure': 7.29.7 regexpu-core: 6.4.0 semver: 6.3.1 @@ -1716,16 +1691,14 @@ snapshots: '@babel/helper-define-polyfill-provider@0.6.8(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 debug: 4.4.3 lodash.debounce: 4.0.8 resolve: 1.22.12 transitivePeerDependencies: - supports-color - '@babel/helper-globals@7.28.0': {} - '@babel/helper-globals@7.29.7': {} '@babel/helper-member-expression-to-functions@7.29.7': @@ -1799,8 +1772,6 @@ snapshots: '@babel/helper-validator-identifier@7.29.7': {} - '@babel/helper-validator-option@7.27.1': {} - '@babel/helper-validator-option@7.29.7': {} '@babel/helper-wrap-function@7.29.7': @@ -1818,7 +1789,7 @@ snapshots: '@babel/parser@7.29.2': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@babel/parser@7.29.7': dependencies: @@ -2318,7 +2289,7 @@ snapshots: dependencies: '@babel/code-frame': 7.29.0 '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@babel/template@7.29.7': dependencies: @@ -2330,10 +2301,10 @@ snapshots: dependencies: '@babel/code-frame': 7.29.0 '@babel/generator': 7.29.1 - '@babel/helper-globals': 7.28.0 + '@babel/helper-globals': 7.29.7 '@babel/parser': 7.29.2 '@babel/template': 7.28.6 - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 debug: 4.4.3 transitivePeerDependencies: - supports-color diff --git a/rollup.config.mjs b/rollup.config.mjs index 6953a49..1abe5e6 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -119,6 +119,54 @@ const getAmbientTypeInputs = (appId) => { return inputs; }; +const resolveTypeScriptConfigPath = (configPath, extendsValue) => { + const basePath = path.resolve(path.dirname(configPath), extendsValue); + const candidates = [basePath, `${basePath}.json`]; + return candidates.find((candidate) => fs.existsSync(candidate)) || null; +}; + +const getTypeScriptConfigTypes = (configPath, visited = new Set()) => { + const resolvedConfigPath = path.resolve(configPath); + if (visited.has(resolvedConfigPath) || !fs.existsSync(resolvedConfigPath)) { + return []; + } + + visited.add(resolvedConfigPath); + const parsedConfig = JSON.parse(fs.readFileSync(resolvedConfigPath, "utf8")); + const compilerOptions = parsedConfig.compilerOptions || {}; + if (Object.prototype.hasOwnProperty.call(compilerOptions, "types")) { + return compilerOptions.types || []; + } + + if (parsedConfig.extends) { + const parentConfigPath = resolveTypeScriptConfigPath( + resolvedConfigPath, + parsedConfig.extends + ); + if (parentConfigPath) { + return getTypeScriptConfigTypes(parentConfigPath, visited); + } + } + + return []; +}; + +const getTypeScriptConfigTypeInputs = (tsconfig) => { + const configPath = path.resolve(tsconfig); + return getTypeScriptConfigTypes(configPath).flatMap((typePath) => { + const basePath = path.resolve(path.dirname(configPath), typePath); + const candidates = [ + basePath, + `${basePath}.d.ts`, + path.join(basePath, "index.d.ts"), + ]; + return candidates.find((candidate) => fs.existsSync(candidate)) || []; + }); +}; + +const isTypeScriptInputFile = (filePath) => + /\.(?:d\.)?(?:c|m)?tsx?$/i.test(filePath); + const stripComments = (source) => source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1"); @@ -177,7 +225,7 @@ const resolveRelativeImport = (fromFilePath, specifier) => { const canReadImports = (filePath) => IMPORT_RESOLVE_EXTENSIONS.includes(path.extname(filePath)); -const collectImportDependencyFiles = (entryFile) => { +export const collectImportDependencyFiles = (entryFile) => { const files = new Map(); const visit = (filePath) => { @@ -209,6 +257,20 @@ const collectImportDependencyFiles = (entryFile) => { ); }; +export const getTypeScriptInputFiles = ({ + appId, + srcDir, + tsconfig, + ambientTypeInputs = getAmbientTypeInputs(appId), +}) => + getUniqueSortedFiles([ + ...collectImportDependencyFiles(`${srcDir}/index.ts`), + ...ambientTypeInputs, + ...(tsconfig ? getTypeScriptConfigTypeInputs(tsconfig) : []), + ]) + .filter(isTypeScriptInputFile) + .map(normalizePath); + const getScriptHashInputs = ({ appId, script, srcDir, tsconfig }) => { const inputs = [ ...SHARED_BUILD_INPUTS, @@ -248,7 +310,7 @@ const loadBuildHashes = () => { } }; -const saveBuildHashes = (hashes) => { +export const saveBuildHashes = (hashes) => { ensureDirectory(BUILD_HASH_DIR); fs.writeFileSync(BUILD_HASH_FILE, JSON.stringify(hashes, null, 2), "utf8"); }; @@ -271,7 +333,8 @@ const hasForceBuildFlag = (commandLineArgs = {}) => { }; const getAppFilter = (commandLineArgs = {}) => { - const appFilter = commandLineArgs.app || null; + const appFilter = + commandLineArgs.app || process.env.EXTENDSCRIPT_BUILD_APP || null; delete commandLineArgs.app; return appFilter; }; @@ -359,11 +422,16 @@ const createBabelConfig = () => }); let hasSavedBuildHashes = false; +export const BUILD_HASH_PLUGIN_NAME = "persist-build-hashes"; -const persistBuildHashes = (hashes) => ({ - name: "persist-build-hashes", +const persistBuildHashes = (hashes, metadata) => ({ + name: BUILD_HASH_PLUGIN_NAME, + buildHashState: { hashes, metadata }, closeBundle() { - if (hasSavedBuildHashes) { + if ( + hasSavedBuildHashes || + process.env.EXTENDSCRIPT_DEFER_BUILD_HASHES === "1" + ) { return; } @@ -454,10 +522,21 @@ export default (commandLineArgs) => { const targetScripts = selection.targetScripts; const entries = targetScripts.map( - ({ script, srcDir, outDir, hashKey, tsconfig }) => { + ({ appId, script, srcDir, outDir, hashKey, tsconfig }) => { const inputFile = `${srcDir}/index.ts`; const fileHash = currentBuildHashes[hashKey] || calculateFileHash(inputFile); + const typeScriptInputFiles = getTypeScriptInputFiles({ + appId, + srcDir, + tsconfig, + }); + const metadata = { + appId, + hashKey, + scriptName: script.name, + targetName: appId ? `${appId}/${script.name}` : script.name, + }; const banner = `/** ${script.name} v${script.version} hash: ${fileHash} */\nvar __ES_THIS__=this;`; @@ -471,7 +550,11 @@ export default (commandLineArgs) => { context: "this", onwarn, plugins: [ - typescript({ tsconfig }), + typescript({ + tsconfig, + include: typeScriptInputFiles, + filterRoot: false, + }), resolve({ extensions, }), @@ -480,7 +563,7 @@ export default (commandLineArgs) => { extractCommentsToTop(), terserConfig(banner), script.license ? licenser(srcDir) : null, - persistBuildHashes(currentBuildHashes), + persistBuildHashes(currentBuildHashes, metadata), ], }; } diff --git a/scripts/build.mjs b/scripts/build.mjs new file mode 100644 index 0000000..f228cdf --- /dev/null +++ b/scripts/build.mjs @@ -0,0 +1,207 @@ +import os from "os"; +import path from "path"; +import process from "process"; +import { pathToFileURL } from "url"; + +import { rollup } from "rollup"; +import { loadConfigFile } from "rollup/loadConfigFile"; + +import { BUILD_HASH_PLUGIN_NAME, saveBuildHashes } from "../rollup.config.mjs"; +import { + BuildArgumentError, + getDefaultConcurrency, + parseBuildArguments, +} from "./buildOptions.mjs"; +import { BuildSchedulerError, runBuildJobs } from "./buildScheduler.mjs"; + +const getBuildHashState = (option) => { + const plugin = (option.plugins || []).find( + (candidate) => candidate && candidate.name === BUILD_HASH_PLUGIN_NAME + ); + return plugin ? plugin.buildHashState : null; +}; + +export const findBuildHashState = (options) => { + const states = options.map(getBuildHashState).filter(Boolean); + if (states.length === 0) { + throw new Error("ビルドハッシュの調停情報を取得できませんでした。"); + } + + const firstState = states[0]; + if (states.some((state) => state.hashes !== firstState.hashes)) { + throw new Error("ビルドハッシュの調停情報が対象間で一致しません。"); + } + + return firstState; +}; + +const getTargetMetadata = (option, index) => { + const state = getBuildHashState(option); + const metadata = state && state.metadata; + if (!metadata || !metadata.targetName) { + throw new Error( + `ビルド対象 ${index + 1} の識別情報を取得できませんでした。` + ); + } + return metadata; +}; + +const getOutputOptions = (option) => { + const output = option.output; + if (output === undefined || output === null) { + throw new Error("Rollup設定に出力先がありません。"); + } + return Array.isArray(output) ? output : [output]; +}; + +export const buildOne = async ({ option, rollupFn = rollup }) => { + const { output, watch: _watch, ...inputOptions } = option; + const bundle = await rollupFn(inputOptions); + + try { + for (const outputOption of getOutputOptions({ output })) { + await bundle.write(outputOption); + } + } finally { + await bundle.close(); + } +}; + +const compareLabels = (left, right) => { + if (left < right) return -1; + if (left > right) return 1; + return 0; +}; + +export const executeBuild = async ({ + options, + hashState, + concurrency, + rollupFn = rollup, + saveHashes = saveBuildHashes, +}) => { + const jobs = options.map((option, index) => ({ + label: getTargetMetadata(option, index).targetName, + option, + })); + + const results = await runBuildJobs({ + jobs, + concurrency, + run: async (job) => { + await buildOne({ option: job.option, rollupFn }); + return { label: job.label, status: "成功" }; + }, + }); + + saveHashes(hashState.hashes); + return results + .slice() + .sort((left, right) => compareLabels(left.label, right.label)); +}; + +const getAvailableParallelism = () => { + if (typeof os.availableParallelism === "function") { + try { + return os.availableParallelism(); + } catch (_error) { + // 古い実行環境や実行時の取得失敗ではCPU数へフォールバックします。 + } + } + + return os.cpus().length; +}; + +const printUsage = () => { + console.log(`使い方: pn build [--all|-a] [--app=] [--concurrency=<正整数>] + +既定の並列度: min(4, 利用可能な並列数, 対象件数) +--concurrency=1 を指定すると、範囲限定を維持したまま逐次実行します。 +監視ビルドは pn watch で実行し、この指定の対象外です。`); +}; + +const restoreEnvironmentValue = (name, value) => { + if (value === undefined) { + delete process.env[name]; + return; + } + process.env[name] = value; +}; + +const main = async () => { + let argumentsConfig; + try { + argumentsConfig = parseBuildArguments(process.argv.slice(2), process.env); + } catch (error) { + if (error instanceof BuildArgumentError) { + console.error(`ビルド引数エラー: ${error.message}`); + process.exitCode = 1; + return; + } + throw error; + } + + if (argumentsConfig.help) { + printUsage(); + return; + } + + const previousApp = process.env.EXTENDSCRIPT_BUILD_APP; + const previousBuildAll = process.env.BUILD_ALL; + const previousDefer = process.env.EXTENDSCRIPT_DEFER_BUILD_HASHES; + + try { + if (argumentsConfig.app === null) { + delete process.env.EXTENDSCRIPT_BUILD_APP; + } else { + process.env.EXTENDSCRIPT_BUILD_APP = argumentsConfig.app; + } + if (argumentsConfig.all) { + process.env.BUILD_ALL = "1"; + } + process.env.EXTENDSCRIPT_DEFER_BUILD_HASHES = "1"; + + const configPath = path.resolve("rollup.config.mjs"); + const { options, warnings } = await loadConfigFile(configPath, {}); + warnings.flush(); + + const hashState = findBuildHashState(options); + const requestedConcurrency = + argumentsConfig.concurrency ?? + getDefaultConcurrency(options.length, getAvailableParallelism()); + const concurrency = Math.min(requestedConcurrency, options.length); + + console.log(`並列度 ${concurrency} で ${options.length} 件を実行します。`); + const results = await executeBuild({ + options, + hashState, + concurrency, + }); + + console.log("ビルド結果:"); + results.forEach(({ label, status }) => { + console.log(`- ${label}: ${status}`); + }); + } catch (error) { + if (error instanceof BuildSchedulerError) { + console.error(`ビルドに失敗しました: ${error.message}`); + console.error("失敗を検出したため、未開始の処理は停止しました。"); + } else { + const message = error instanceof Error ? error.message : String(error); + console.error(`ビルドに失敗しました: ${message}`); + } + process.exitCode = 1; + } finally { + restoreEnvironmentValue("EXTENDSCRIPT_BUILD_APP", previousApp); + restoreEnvironmentValue("BUILD_ALL", previousBuildAll); + restoreEnvironmentValue("EXTENDSCRIPT_DEFER_BUILD_HASHES", previousDefer); + } +}; + +const isMainModule = + process.argv[1] && + pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url; + +if (isMainModule) { + await main(); +} diff --git a/scripts/build.test.mjs b/scripts/build.test.mjs new file mode 100644 index 0000000..f907c83 --- /dev/null +++ b/scripts/build.test.mjs @@ -0,0 +1,90 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { BUILD_HASH_PLUGIN_NAME } from "../rollup.config.mjs"; +import { executeBuild } from "./build.mjs"; + +const createOption = (label, hashes) => ({ + input: label, + output: { file: `${label}.jsx`, format: "cjs" }, + plugins: [ + { + name: BUILD_HASH_PLUGIN_NAME, + buildHashState: { + hashes, + metadata: { targetName: label }, + }, + }, + ], +}); + +test("全件成功後だけハッシュ確定し、結果は対象名順に返す", async () => { + const hashes = { first: "hash-first", second: "hash-second" }; + const saved = []; + const options = [ + createOption("second", hashes), + createOption("first", hashes), + ]; + + const results = await executeBuild({ + options, + hashState: { hashes }, + concurrency: 2, + rollupFn: async () => ({ + async write() {}, + async close() {}, + }), + saveHashes: (value) => saved.push(value), + }); + + assert.deepEqual( + results.map(({ label }) => label), + ["first", "second"] + ); + assert.deepEqual(saved, [hashes]); +}); + +test("失敗時は開始済みbundleを閉じ、ハッシュを保存しない", async () => { + const hashes = { failing: "old", running: "old", later: "old" }; + const options = [ + createOption("failing", hashes), + createOption("running", hashes), + createOption("later", hashes), + ]; + const started = []; + const closed = []; + const saved = []; + let releaseRunning; + const running = new Promise((resolve) => { + releaseRunning = resolve; + }); + + const execution = executeBuild({ + options, + hashState: { hashes }, + concurrency: 2, + rollupFn: async (inputOptions) => { + started.push(inputOptions.input); + return { + async write() { + if (inputOptions.input === "failing") { + throw new Error("出力失敗"); + } + await running; + }, + async close() { + closed.push(inputOptions.input); + }, + }; + }, + saveHashes: (value) => saved.push(value), + }); + + await Promise.resolve(); + releaseRunning(); + + await assert.rejects(execution, /failing: 出力失敗/); + assert.deepEqual(started, ["failing", "running"]); + assert.deepEqual(closed.sort(), ["failing", "running"]); + assert.deepEqual(saved, []); +}); diff --git a/scripts/buildDependencyScope.test.mjs b/scripts/buildDependencyScope.test.mjs new file mode 100644 index 0000000..375cf30 --- /dev/null +++ b/scripts/buildDependencyScope.test.mjs @@ -0,0 +1,106 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "fs"; +import os from "os"; +import path from "path"; + +import { + collectImportDependencyFiles, + getTypeScriptInputFiles, +} from "../rollup.config.mjs"; + +const normalize = (filePath) => filePath.replace(/\\/g, "/"); + +test("スクリプト単位のTypeScript範囲は相対依存と環境型だけを含む", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "es-build-scope-")); + const sourceRoot = path.join(root, "src"); + const targetDir = path.join(sourceRoot, "common", "target"); + const siblingDir = path.join(sourceRoot, "common", "sibling"); + const typesDir = path.join(sourceRoot, "types"); + const runtimeDir = path.join(sourceRoot, "lib"); + + try { + fs.mkdirSync(targetDir, { recursive: true }); + fs.mkdirSync(siblingDir, { recursive: true }); + fs.mkdirSync(typesDir, { recursive: true }); + fs.mkdirSync(runtimeDir, { recursive: true }); + fs.writeFileSync( + path.join(targetDir, "index.ts"), + 'import "../../init"; import "../shared";\n' + ); + fs.writeFileSync( + path.join(sourceRoot, "common", "shared.ts"), + "export {};\n" + ); + fs.writeFileSync( + path.join(siblingDir, "index.ts"), + "const unrelated: MissingType = 1;\n" + ); + fs.writeFileSync( + path.join(sourceRoot, "init.ts"), + 'import "./lib/runtime";\n' + ); + fs.writeFileSync( + path.join(runtimeDir, "runtime.js"), + "module.exports = {};\n" + ); + fs.writeFileSync( + path.join(typesDir, "environment.d.ts"), + "declare const app: unknown;\n" + ); + + const dependencies = collectImportDependencyFiles( + path.join(targetDir, "index.ts") + ).map(normalize); + const typeScriptFiles = getTypeScriptInputFiles({ + appId: null, + srcDir: targetDir, + ambientTypeInputs: [typesDir], + }); + + assert.ok( + dependencies.includes(normalize(path.join(sourceRoot, "init.ts"))) + ); + assert.ok( + dependencies.includes(normalize(path.join(runtimeDir, "runtime.js"))) + ); + assert.ok( + typeScriptFiles.includes(normalize(path.join(targetDir, "index.ts"))) + ); + assert.ok( + typeScriptFiles.includes( + normalize(path.join(sourceRoot, "common", "shared.ts")) + ) + ); + assert.ok( + typeScriptFiles.includes( + normalize(path.join(typesDir, "environment.d.ts")) + ) + ); + assert.equal( + typeScriptFiles.includes(normalize(path.join(siblingDir, "index.ts"))), + false + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("アプリ別tsconfigの環境型定義を範囲へ含める", () => { + const files = getTypeScriptInputFiles({ + appId: "aeft", + srcDir: "src/aeft/example", + tsconfig: "src/aeft/tsconfig.json", + }); + + assert.ok( + files.some((filePath) => + filePath.endsWith("types-for-adobe/AfterEffects/22.0/index.d.ts") + ) + ); + assert.ok( + files.some((filePath) => + filePath.endsWith("types-for-adobe/shared/XMPScript.d.ts") + ) + ); +}); diff --git a/scripts/buildOptions.mjs b/scripts/buildOptions.mjs new file mode 100644 index 0000000..7d3c2c5 --- /dev/null +++ b/scripts/buildOptions.mjs @@ -0,0 +1,118 @@ +export class BuildArgumentError extends Error { + constructor(message) { + super(message); + this.name = "BuildArgumentError"; + } +} + +const isTruthyFlag = (value) => + value !== undefined && value !== "" && value !== "false" && value !== "0"; + +export const parseConcurrencyValue = (value) => { + if (typeof value !== "string" || !/^\d+$/.test(value)) { + throw new BuildArgumentError( + `--concurrency には1以上の整数を指定してください(受け取った値: ${String(value)})。` + ); + } + + const concurrency = Number(value); + if (!Number.isSafeInteger(concurrency) || concurrency < 1) { + throw new BuildArgumentError( + `--concurrency には安全な範囲の1以上の整数を指定してください(受け取った値: ${value})。` + ); + } + + return concurrency; +}; + +export const getDefaultConcurrency = (targetCount, availableParallelism) => { + if (!Number.isSafeInteger(targetCount) || targetCount < 1) { + return 0; + } + + const parallelism = + Number.isSafeInteger(availableParallelism) && availableParallelism > 0 + ? availableParallelism + : 1; + + return Math.min(4, parallelism, targetCount); +}; + +const takeValue = (argumentsList, index, optionName) => { + const value = argumentsList[index + 1]; + if (value === undefined || value.startsWith("--")) { + throw new BuildArgumentError( + `${optionName} の値がありません。例: ${optionName}=4` + ); + } + return value; +}; + +export const parseBuildArguments = (argumentsList = [], environment = {}) => { + const result = { + all: isTruthyFlag(environment.BUILD_ALL), + app: null, + concurrency: null, + help: false, + }; + + for (let index = 0; index < argumentsList.length; index += 1) { + const argument = argumentsList[index]; + + if (argument === "--all" || argument === "-a") { + result.all = true; + continue; + } + + if (argument === "--help" || argument === "-h") { + result.help = true; + continue; + } + + if (argument === "--app") { + if (result.app !== null) { + throw new BuildArgumentError("--app は複数回指定できません。"); + } + result.app = takeValue(argumentsList, index, "--app"); + index += 1; + continue; + } + + if (argument.startsWith("--app=")) { + if (result.app !== null) { + throw new BuildArgumentError("--app は複数回指定できません。"); + } + const value = argument.slice("--app=".length); + if (value === "") { + throw new BuildArgumentError("--app の値がありません。例: --app=aeft"); + } + result.app = value; + continue; + } + + if (argument === "--concurrency") { + if (result.concurrency !== null) { + throw new BuildArgumentError("--concurrency は複数回指定できません。"); + } + result.concurrency = parseConcurrencyValue( + takeValue(argumentsList, index, "--concurrency") + ); + index += 1; + continue; + } + + if (argument.startsWith("--concurrency=")) { + if (result.concurrency !== null) { + throw new BuildArgumentError("--concurrency は複数回指定できません。"); + } + result.concurrency = parseConcurrencyValue( + argument.slice("--concurrency=".length) + ); + continue; + } + + throw new BuildArgumentError(`未知のビルドオプションです: ${argument}`); + } + + return result; +}; diff --git a/scripts/buildOptions.test.mjs b/scripts/buildOptions.test.mjs new file mode 100644 index 0000000..fd82211 --- /dev/null +++ b/scripts/buildOptions.test.mjs @@ -0,0 +1,38 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + BuildArgumentError, + getDefaultConcurrency, + parseBuildArguments, + parseConcurrencyValue, +} from "./buildOptions.mjs"; + +test("ビルド引数は全件指定、アプリ指定、並列度指定を解析する", () => { + assert.deepEqual( + parseBuildArguments(["--all", "--app", "aeft", "--concurrency=2"]), + { all: true, app: "aeft", concurrency: 2, help: false } + ); + assert.equal(parseBuildArguments([], { BUILD_ALL: "1" }).all, true); +}); + +test("既定並列度は対象件数と利用可能な並列数を上限にする", () => { + assert.equal(getDefaultConcurrency(70, 16), 4); + assert.equal(getDefaultConcurrency(3, 16), 3); + assert.equal(getDefaultConcurrency(70, 2), 2); + assert.equal(getDefaultConcurrency(0, 16), 0); +}); + +test("並列度は1以上の安全な整数だけを受け付ける", () => { + assert.equal(parseConcurrencyValue("1"), 1); + assert.equal(parseConcurrencyValue("004"), 4); + + for (const value of ["", "0", "-1", "1.5", "abc", "1e2"]) { + assert.throws(() => parseConcurrencyValue(value), BuildArgumentError); + } + assert.throws(() => parseBuildArguments(["--concurrency"]), /値がありません/); + assert.throws( + () => parseBuildArguments(["--unknown"]), + /未知のビルドオプション/ + ); +}); diff --git a/scripts/buildOutput.test.mjs b/scripts/buildOutput.test.mjs new file mode 100644 index 0000000..a8159ca --- /dev/null +++ b/scripts/buildOutput.test.mjs @@ -0,0 +1,71 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { promisify } from "node:util"; +import fs from "fs"; +import path from "path"; +import { execFile } from "child_process"; + +const execFileAsync = promisify(execFile); +const projectRoot = process.cwd(); +const outputFiles = [ + "dist/aeft/example/example.jsx", + "dist/ilst/example/example.jsx", + "dist/phxs/example/example.jsx", + "dist/tests/tests.jsx", +].map((filePath) => path.resolve(projectRoot, filePath)); +const buildHashFile = path.resolve(projectRoot, "dist/temp/build-hashes.json"); + +const snapshotFile = (filePath) => + fs.existsSync(filePath) ? fs.readFileSync(filePath) : null; + +const restoreFile = (filePath, snapshot) => { + if (snapshot === null) { + if (fs.existsSync(filePath)) { + fs.rmSync(filePath, { force: true }); + } + return; + } + + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, snapshot); +}; + +const runBuild = async (concurrency) => { + const environment = { ...process.env }; + delete environment.BUILD_ALL; + delete environment.EXTENDSCRIPT_BUILD_APP; + delete environment.EXTENDSCRIPT_DEFER_BUILD_HASHES; + + await execFileAsync( + process.execPath, + ["scripts/build.mjs", "--all", `--concurrency=${concurrency}`], + { + cwd: projectRoot, + env: environment, + maxBuffer: 8 * 1024 * 1024, + } + ); +}; + +test("逐次実行と並列実行の生成物はバイト単位で一致する", async () => { + const snapshots = new Map( + [...outputFiles, buildHashFile].map((filePath) => [ + filePath, + snapshotFile(filePath), + ]) + ); + + try { + await runBuild(1); + const serialOutputs = outputFiles.map((filePath) => snapshotFile(filePath)); + assert.ok(serialOutputs.every((content) => content !== null)); + + await runBuild(4); + const parallelOutputs = outputFiles.map((filePath) => + snapshotFile(filePath) + ); + assert.deepEqual(parallelOutputs, serialOutputs); + } finally { + snapshots.forEach((snapshot, filePath) => restoreFile(filePath, snapshot)); + } +}); diff --git a/scripts/buildScheduler.mjs b/scripts/buildScheduler.mjs new file mode 100644 index 0000000..60068f5 --- /dev/null +++ b/scripts/buildScheduler.mjs @@ -0,0 +1,56 @@ +export class BuildSchedulerError extends Error { + constructor(job, cause) { + const label = job.label || job.name || "対象不明"; + const message = cause instanceof Error ? cause.message : String(cause); + super(`${label}: ${message}`); + this.name = "BuildSchedulerError"; + this.job = job; + this.cause = cause; + } +} + +export const runBuildJobs = async ({ jobs, concurrency, run }) => { + if (!Array.isArray(jobs)) { + throw new TypeError("jobs には配列を指定してください。"); + } + if (!Number.isSafeInteger(concurrency) || concurrency < 1) { + throw new RangeError("concurrency には1以上の整数を指定してください。"); + } + if (typeof run !== "function") { + throw new TypeError("run には関数を指定してください。"); + } + + const results = new Array(jobs.length); + let nextIndex = 0; + let failure = null; + + const worker = async () => { + while (true) { + if (failure || nextIndex >= jobs.length) { + return; + } + + const index = nextIndex; + nextIndex += 1; + const job = jobs[index]; + + try { + results[index] = await run(job, index); + } catch (error) { + if (failure === null) { + failure = { job, cause: error }; + } + return; + } + } + }; + + const workerCount = Math.min(concurrency, jobs.length); + await Promise.all(Array.from({ length: workerCount }, () => worker())); + + if (failure !== null) { + throw new BuildSchedulerError(failure.job, failure.cause); + } + + return results; +}; diff --git a/scripts/buildScheduler.test.mjs b/scripts/buildScheduler.test.mjs new file mode 100644 index 0000000..e12a7ca --- /dev/null +++ b/scripts/buildScheduler.test.mjs @@ -0,0 +1,67 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { BuildSchedulerError, runBuildJobs } from "./buildScheduler.mjs"; + +test("実行中のビルド数は指定した上限を超えない", async () => { + let active = 0; + let maximumActive = 0; + + const results = await runBuildJobs({ + jobs: Array.from({ length: 9 }, (_, id) => ({ id })), + concurrency: 3, + run: async (job) => { + active += 1; + maximumActive = Math.max(maximumActive, active); + await Promise.resolve(); + active -= 1; + return job.id; + }, + }); + + assert.equal(maximumActive, 3); + assert.deepEqual( + results, + Array.from({ length: 9 }, (_, id) => id) + ); +}); + +test("失敗後に未開始の仕事を増やさず、開始済みの仕事を完了させる", async () => { + const started = []; + const closed = []; + let releaseRunning; + const running = new Promise((resolve) => { + releaseRunning = resolve; + }); + + const execution = runBuildJobs({ + jobs: [ + { id: "失敗", label: "失敗対象" }, + { id: "実行中", label: "実行中対象" }, + { id: "未開始", label: "未開始対象" }, + ], + concurrency: 2, + run: async (job) => { + started.push(job.id); + try { + if (job.id === "失敗") { + throw new Error("意図した失敗"); + } + await running; + } finally { + closed.push(job.id); + } + }, + }); + + await Promise.resolve(); + releaseRunning(); + + await assert.rejects(execution, (error) => { + assert.ok(error instanceof BuildSchedulerError); + assert.equal(error.job.id, "失敗"); + return true; + }); + assert.deepEqual(started, ["失敗", "実行中"]); + assert.deepEqual(closed.sort(), ["失敗", "実行中"]); +}); From 4191ddc2dc19c9511e50a82c2f71274d20914fee Mon Sep 17 00:00:00 2001 From: YukiWorks432 Date: Sat, 1 Aug 2026 21:12:31 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=E3=83=93=E3=83=AB=E3=83=89=E7=B5=8C?= =?UTF-8?q?=E8=B7=AF=E3=81=A8=E7=9B=A3=E8=A6=96=E8=A8=AD=E5=AE=9A=E3=81=AE?= =?UTF-8?q?=E6=95=B4=E5=90=88=E6=80=A7=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rollup.config.mjs | 29 +++++++++--- scripts/addApp.mjs | 15 +++++-- scripts/addApp.test.mjs | 11 +++++ scripts/buildDependencyScope.test.mjs | 15 +++++++ scripts/buildOutput.test.mjs | 63 +++++++++++++++++++++++++++ 5 files changed, 123 insertions(+), 10 deletions(-) create mode 100644 scripts/addApp.test.mjs diff --git a/rollup.config.mjs b/rollup.config.mjs index 1abe5e6..68e6ae7 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -271,6 +271,23 @@ export const getTypeScriptInputFiles = ({ .filter(isTypeScriptInputFile) .map(normalizePath); +export const getTypeScriptPluginOptions = ({ + appId, + srcDir, + tsconfig, + watch = false, +}) => { + if (watch) { + return { tsconfig }; + } + + return { + tsconfig, + include: getTypeScriptInputFiles({ appId, srcDir, tsconfig }), + filterRoot: false, + }; +}; + const getScriptHashInputs = ({ appId, script, srcDir, tsconfig }) => { const inputs = [ ...SHARED_BUILD_INPUTS, @@ -440,7 +457,7 @@ const persistBuildHashes = (hashes, metadata) => ({ }, }); -export default (commandLineArgs) => { +export default (commandLineArgs = {}) => { const forceBuildAll = hasForceBuildFlag(commandLineArgs); const appFilter = getAppFilter(commandLineArgs); @@ -526,15 +543,17 @@ export default (commandLineArgs) => { const inputFile = `${srcDir}/index.ts`; const fileHash = currentBuildHashes[hashKey] || calculateFileHash(inputFile); - const typeScriptInputFiles = getTypeScriptInputFiles({ + const typeScriptPluginOptions = getTypeScriptPluginOptions({ appId, srcDir, tsconfig, + watch: Boolean(commandLineArgs.watch), }); const metadata = { appId, hashKey, scriptName: script.name, + tsconfig, targetName: appId ? `${appId}/${script.name}` : script.name, }; @@ -550,11 +569,7 @@ export default (commandLineArgs) => { context: "this", onwarn, plugins: [ - typescript({ - tsconfig, - include: typeScriptInputFiles, - filterRoot: false, - }), + typescript(typeScriptPluginOptions), resolve({ extensions, }), diff --git a/scripts/addApp.mjs b/scripts/addApp.mjs index 09463b9..fbc455e 100644 --- a/scripts/addApp.mjs +++ b/scripts/addApp.mjs @@ -288,6 +288,9 @@ function shouldInsertAfterBuildScript(currentKey, nextKey) { return isBuildKey && !nextIsBuildKey; } +export const getBuildScriptAlias = (appId) => + `node ./scripts/build.mjs --app=${appId}`; + function addBuildScriptAlias(scripts, appId) { const scriptName = `build:${appId}`; if (Object.prototype.hasOwnProperty.call(scripts, scriptName)) { @@ -304,13 +307,13 @@ function addBuildScriptAlias(scripts, appId) { const nextKey = entries[i + 1]?.[0] || null; if (!inserted && shouldInsertAfterBuildScript(key, nextKey)) { - nextScripts[scriptName] = `rollup -c --app=${appId}`; + nextScripts[scriptName] = getBuildScriptAlias(appId); inserted = true; } } if (!inserted) { - nextScripts[scriptName] = `rollup -c --app=${appId}`; + nextScripts[scriptName] = getBuildScriptAlias(appId); } return { scripts: nextScripts, added: true }; @@ -446,4 +449,10 @@ async function main() { } } -main(); +const isMainModule = + process.argv[1] && + pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url; + +if (isMainModule) { + await main(); +} diff --git a/scripts/addApp.test.mjs b/scripts/addApp.test.mjs new file mode 100644 index 0000000..dbe4633 --- /dev/null +++ b/scripts/addApp.test.mjs @@ -0,0 +1,11 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { getBuildScriptAlias } from "./addApp.mjs"; + +test("新規アプリのビルド別名は単発ビルド経路を使う", () => { + assert.equal( + getBuildScriptAlias("idsn"), + "node ./scripts/build.mjs --app=idsn" + ); +}); diff --git a/scripts/buildDependencyScope.test.mjs b/scripts/buildDependencyScope.test.mjs index 375cf30..cf8dba1 100644 --- a/scripts/buildDependencyScope.test.mjs +++ b/scripts/buildDependencyScope.test.mjs @@ -7,6 +7,7 @@ import path from "path"; import { collectImportDependencyFiles, getTypeScriptInputFiles, + getTypeScriptPluginOptions, } from "../rollup.config.mjs"; const normalize = (filePath) => filePath.replace(/\\/g, "/"); @@ -104,3 +105,17 @@ test("アプリ別tsconfigの環境型定義を範囲へ含める", () => { ) ); }); + +test("監視ビルドは従来のTypeScript設定を使い、単発ビルドだけ範囲を限定する", () => { + const common = { + appId: "aeft", + srcDir: "src/aeft/example", + tsconfig: "src/aeft/tsconfig.json", + }; + const watchOptions = getTypeScriptPluginOptions({ ...common, watch: true }); + const buildOptions = getTypeScriptPluginOptions(common); + + assert.deepEqual(watchOptions, { tsconfig: common.tsconfig }); + assert.equal(buildOptions.filterRoot, false); + assert.ok(Array.isArray(buildOptions.include)); +}); diff --git a/scripts/buildOutput.test.mjs b/scripts/buildOutput.test.mjs index a8159ca..1bb26ad 100644 --- a/scripts/buildOutput.test.mjs +++ b/scripts/buildOutput.test.mjs @@ -4,7 +4,11 @@ import { promisify } from "node:util"; import fs from "fs"; import path from "path"; import { execFile } from "child_process"; +import typescript from "@rollup/plugin-typescript"; +import { loadConfigFile } from "rollup/loadConfigFile"; +import { buildOne } from "./build.mjs"; +import { BUILD_HASH_PLUGIN_NAME } from "../rollup.config.mjs"; const execFileAsync = promisify(execFile); const projectRoot = process.cwd(); const outputFiles = [ @@ -30,6 +34,14 @@ const restoreFile = (filePath, snapshot) => { fs.writeFileSync(filePath, snapshot); }; +const normalizeBuildMetadata = (content) => + content + .toString("utf8") + .replace( + /\/\*\* [^\n]* hash: [0-9a-f]{64} \*\/\n/g, + "/** normalized build metadata */\n" + ); + const runBuild = async (concurrency) => { const environment = { ...process.env }; delete environment.BUILD_ALL; @@ -47,6 +59,48 @@ const runBuild = async (concurrency) => { ); }; +const restoreEnvironmentValue = (name, value) => { + if (value === undefined) { + delete process.env[name]; + return; + } + process.env[name] = value; +}; + +const runUnboundedBuild = async () => { + const previousBuildAll = process.env.BUILD_ALL; + const previousDefer = process.env.EXTENDSCRIPT_DEFER_BUILD_HASHES; + process.env.BUILD_ALL = "1"; + process.env.EXTENDSCRIPT_DEFER_BUILD_HASHES = "1"; + + try { + const { options, warnings } = await loadConfigFile( + path.resolve(projectRoot, "rollup.config.mjs"), + {} + ); + warnings.flush(); + + for (const option of options) { + const hashPlugin = option.plugins.find( + (plugin) => plugin && plugin.name === BUILD_HASH_PLUGIN_NAME + ); + const tsconfig = hashPlugin.buildHashState.metadata.tsconfig; + const unboundedOptions = { + ...option, + plugins: option.plugins.map((plugin) => + plugin && plugin.name === "typescript" + ? typescript({ tsconfig }) + : plugin + ), + }; + await buildOne({ option: unboundedOptions }); + } + } finally { + restoreEnvironmentValue("BUILD_ALL", previousBuildAll); + restoreEnvironmentValue("EXTENDSCRIPT_DEFER_BUILD_HASHES", previousDefer); + } +}; + test("逐次実行と並列実行の生成物はバイト単位で一致する", async () => { const snapshots = new Map( [...outputFiles, buildHashFile].map((filePath) => [ @@ -65,6 +119,15 @@ test("逐次実行と並列実行の生成物はバイト単位で一致する", snapshotFile(filePath) ); assert.deepEqual(parallelOutputs, serialOutputs); + + await runUnboundedBuild(); + const unboundedOutputs = outputFiles.map((filePath) => + snapshotFile(filePath) + ); + assert.deepEqual( + unboundedOutputs.map(normalizeBuildMetadata), + parallelOutputs.map(normalizeBuildMetadata) + ); } finally { snapshots.forEach((snapshot, filePath) => restoreFile(filePath, snapshot)); } From 06c23906646c0de43eddd022a17bdde49fd7eac3 Mon Sep 17 00:00:00 2001 From: YukiWorks432 Date: Sat, 1 Aug 2026 23:31:16 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=E4=BE=9D=E5=AD=98=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E3=81=A8=E3=83=8F=E3=83=83=E3=82=B7=E3=83=A5=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=E3=82=92=E5=AE=89=E5=85=A8=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rollup.config.mjs | 96 ++++++++++++--------------- scripts/build.test.mjs | 38 ++++++++++- scripts/buildDependencyScope.test.mjs | 19 +++++- 3 files changed, 97 insertions(+), 56 deletions(-) diff --git a/rollup.config.mjs b/rollup.config.mjs index 68e6ae7..483ce2d 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -9,6 +9,7 @@ import fs from "fs"; import crypto from "crypto"; import process from "process"; import path from "path"; +import ts from "typescript"; import config from "./es.config.mjs"; import { @@ -31,10 +32,6 @@ const hashText = (text) => const normalizePath = (filePath) => filePath.replace(/\\/g, "/"); -const IMPORT_RESOLVE_EXTENSIONS = [".ts", ".js", ".d.ts"]; -const IMPORT_SPECIFIER_PATTERN = - /\b(?:import|export)\s+(?:type\s+)?(?:[^'"]*?\s+from\s+)?["']([^"']+)["']|import\s*\(\s*["']([^"']+)["']\s*\)/g; - const collectFiles = (targetPath) => { if (!fs.existsSync(targetPath)) { return []; @@ -167,63 +164,42 @@ const getTypeScriptConfigTypeInputs = (tsconfig) => { const isTypeScriptInputFile = (filePath) => /\.(?:d\.)?(?:c|m)?tsx?$/i.test(filePath); -const stripComments = (source) => - source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1"); - const getRelativeImportSpecifiers = (filePath) => { - const source = stripComments(fs.readFileSync(filePath, "utf8")); - const specifiers = []; - - IMPORT_SPECIFIER_PATTERN.lastIndex = 0; - - let match = IMPORT_SPECIFIER_PATTERN.exec(source); - while (match) { - const specifier = match[1] || match[2]; - - if (specifier && specifier.startsWith(".")) { - specifiers.push(specifier); - } - - match = IMPORT_SPECIFIER_PATTERN.exec(source); - } - - return specifiers; + const source = fs.readFileSync(filePath, "utf8"); + return ts + .preProcessFile(source, true, true) + .importedFiles.map(({ fileName }) => fileName) + .filter((specifier) => specifier.startsWith(".")); }; -const getImportCandidates = (importBasePath) => { - const candidates = []; - - if (path.extname(importBasePath)) { - candidates.push(importBasePath); - } else { - IMPORT_RESOLVE_EXTENSIONS.forEach((extension) => { - candidates.push(`${importBasePath}${extension}`); - }); - } - - IMPORT_RESOLVE_EXTENSIONS.forEach((extension) => { - candidates.push(path.join(importBasePath, `index${extension}`)); - }); - - return candidates; +const IMPORT_RESOLVE_OPTIONS = { + allowJs: true, + moduleResolution: ts.ModuleResolutionKind.NodeJs, }; const resolveRelativeImport = (fromFilePath, specifier) => { - const importBasePath = path.resolve(path.dirname(fromFilePath), specifier); + const resolvedModule = ts.resolveModuleName( + specifier, + fromFilePath, + IMPORT_RESOLVE_OPTIONS, + ts.sys + ).resolvedModule; + const resolvedFilePath = resolvedModule?.resolvedFileName; + + if (!resolvedFilePath) { + return null; + } - return ( - getImportCandidates(importBasePath).find((candidate) => { - if (!fs.existsSync(candidate)) { - return false; - } + const absoluteFilePath = path.resolve(resolvedFilePath); + if (!fs.existsSync(absoluteFilePath)) { + return null; + } - return fs.statSync(candidate).isFile(); - }) || null - ); + return fs.statSync(absoluteFilePath).isFile() ? absoluteFilePath : null; }; const canReadImports = (filePath) => - IMPORT_RESOLVE_EXTENSIONS.includes(path.extname(filePath)); + isTypeScriptInputFile(filePath) || path.extname(filePath) === ".js"; export const collectImportDependencyFiles = (entryFile) => { const files = new Map(); @@ -327,9 +303,23 @@ const loadBuildHashes = () => { } }; -export const saveBuildHashes = (hashes) => { - ensureDirectory(BUILD_HASH_DIR); - fs.writeFileSync(BUILD_HASH_FILE, JSON.stringify(hashes, null, 2), "utf8"); +export const saveBuildHashes = (hashes, buildHashFile = BUILD_HASH_FILE) => { + const targetPath = path.resolve(buildHashFile); + const temporaryPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`; + + ensureDirectory(path.dirname(targetPath)); + + try { + fs.writeFileSync(temporaryPath, JSON.stringify(hashes, null, 2), "utf8"); + fs.renameSync(temporaryPath, targetPath); + } catch (error) { + try { + fs.rmSync(temporaryPath, { force: true }); + } catch { + // 元の履歴を保持することを優先し、一時ファイルの削除失敗は元のエラーに委ねる。 + } + throw error; + } }; const isTruthyFlag = (value) => diff --git a/scripts/build.test.mjs b/scripts/build.test.mjs index f907c83..ca0cfb8 100644 --- a/scripts/build.test.mjs +++ b/scripts/build.test.mjs @@ -1,7 +1,10 @@ import test from "node:test"; import assert from "node:assert/strict"; +import fs from "fs"; +import os from "os"; +import path from "path"; -import { BUILD_HASH_PLUGIN_NAME } from "../rollup.config.mjs"; +import { BUILD_HASH_PLUGIN_NAME, saveBuildHashes } from "../rollup.config.mjs"; import { executeBuild } from "./build.mjs"; const createOption = (label, hashes) => ({ @@ -88,3 +91,36 @@ test("失敗時は開始済みbundleを閉じ、ハッシュを保存しない", assert.deepEqual(closed.sort(), ["failing", "running"]); assert.deepEqual(saved, []); }); + +test("ハッシュ履歴の保存失敗時は既存内容を保持する", (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "es-build-hash-")); + const hashFile = path.join(root, "build-hashes.json"); + const existingContent = Buffer.from('{"existing":"hash"}'); + const originalWriteFileSync = fs.writeFileSync; + + try { + fs.writeFileSync(hashFile, existingContent); + t.mock.method(fs, "writeFileSync", (filePath, data, options) => { + if (String(filePath).endsWith(".tmp")) { + originalWriteFileSync.call( + fs, + filePath, + String(data).slice(0, 2), + options + ); + throw new Error("ハッシュ履歴の保存失敗"); + } + + return originalWriteFileSync.call(fs, filePath, data, options); + }); + + assert.throws( + () => saveBuildHashes({ next: "hash" }, hashFile), + /ハッシュ履歴の保存失敗/ + ); + assert.deepEqual(fs.readFileSync(hashFile), existingContent); + assert.deepEqual(fs.readdirSync(root), ["build-hashes.json"]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/scripts/buildDependencyScope.test.mjs b/scripts/buildDependencyScope.test.mjs index cf8dba1..9f76f20 100644 --- a/scripts/buildDependencyScope.test.mjs +++ b/scripts/buildDependencyScope.test.mjs @@ -19,6 +19,9 @@ test("スクリプト単位のTypeScript範囲は相対依存と環境型だけ const siblingDir = path.join(sourceRoot, "common", "sibling"); const typesDir = path.join(sourceRoot, "types"); const runtimeDir = path.join(sourceRoot, "lib"); + const stringOnlyPath = path.join(siblingDir, "string-only.ts"); + const commentedPath = path.join(siblingDir, "commented.ts"); + const lazyPath = path.join(targetDir, "lazy.ts"); try { fs.mkdirSync(targetDir, { recursive: true }); @@ -27,7 +30,13 @@ test("スクリプト単位のTypeScript範囲は相対依存と環境型だけ fs.mkdirSync(runtimeDir, { recursive: true }); fs.writeFileSync( path.join(targetDir, "index.ts"), - 'import "../../init"; import "../shared";\n' + [ + "const text = 'import \"../sibling/string-only\";';", + '/* import "../sibling/commented"; */', + 'import "../../init";', + 'export * from "../shared";', + 'const load = import("./lazy");', + ].join("\n") ); fs.writeFileSync( path.join(sourceRoot, "common", "shared.ts"), @@ -37,9 +46,12 @@ test("スクリプト単位のTypeScript範囲は相対依存と環境型だけ path.join(siblingDir, "index.ts"), "const unrelated: MissingType = 1;\n" ); + fs.writeFileSync(stringOnlyPath, "export {};\n"); + fs.writeFileSync(commentedPath, "export {};\n"); + fs.writeFileSync(lazyPath, "export {};\n"); fs.writeFileSync( path.join(sourceRoot, "init.ts"), - 'import "./lib/runtime";\n' + 'const text = "// import \'./missing\';";\nimport "./lib/runtime";\n' ); fs.writeFileSync( path.join(runtimeDir, "runtime.js"), @@ -65,6 +77,9 @@ test("スクリプト単位のTypeScript範囲は相対依存と環境型だけ assert.ok( dependencies.includes(normalize(path.join(runtimeDir, "runtime.js"))) ); + assert.ok(dependencies.includes(normalize(lazyPath))); + assert.equal(dependencies.includes(normalize(stringOnlyPath)), false); + assert.equal(dependencies.includes(normalize(commentedPath)), false); assert.ok( typeScriptFiles.includes(normalize(path.join(targetDir, "index.ts"))) );