fix(docker): complete partially traced packages in standalone co-location

Publish-to-Docker-Hub has failed on every release/v3.8.50 push since #9151
enabled publishing from active release branches: the post-build guard dies
with "Cannot find module .../@atjsh/llmlingua-2/dist/index.js" while the
co-location step right above it reports 100 packages copied.

Root cause: Next's file tracing materializes @atjsh/llmlingua-2 PARTIALLY
in the standalone (package.json lands, the dist/ payload its main points at
does not). colocateOptionals' no-clobber checked existsSync on the package
DIRECTORY, so the partial shell counted as present and the one package that
mattered was skipped forever (#9185 added the closure walk but kept the
directory-level check).

Fix: presence is now judged by entrypoint integrity — the package resolves
from inside the target tree (same contract as the Dockerfile guard). Partial
directories are completed with a file-level no-clobber merge (cpSync
force:false), so files the trace did materialize are never overwritten and
pinned instances (dist transformers 3.5.2) keep their protection.

Validation (TDD): 2 new tests in docker-llmlingua-optionals-9166.test.ts
reproduce the CI failure (partial package skipped; closure-wide early-exit
firing while a member is partial) — red on the old code, 5/5 green after.
This commit is contained in:
diegosouzapw
2026-08-06 10:41:46 -03:00
parent 3c6f71776e
commit 69ddf22454
2 changed files with 140 additions and 6 deletions

View File

@@ -47,7 +47,8 @@
*/
import { cpSync, existsSync, mkdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { createRequire } from "node:module";
import { dirname, join, sep } from "node:path";
/**
* Entry packages of the SLM optional stack (the closure roots). `@huggingface/transformers` is
@@ -96,6 +97,33 @@ export function computeDependencyClosure(nodeModulesDir, seeds = SEED_PACKAGES)
return closure;
}
/**
* A package in the target tree counts as PRESENT only when its entrypoint
* resolves from inside that tree — the same contract the Dockerfile's
* post-build guard enforces. Next's file tracing can materialize a package
* PARTIALLY (the package.json lands, the files its `main` points at do not),
* and a directory-level `existsSync` check then skips the package forever
* while the runtime dies with "Cannot find module <pkg>/dist/index.js".
*
* @param {string} targetNodeModulesDir
* @param {string} name
* @returns {boolean}
*/
function isPackageIntact(targetNodeModulesDir, name) {
if (!existsSync(join(targetNodeModulesDir, name))) return false;
try {
const probe = createRequire(
join(targetNodeModulesDir, "__colocate_probe__.js")
);
const resolved = probe.resolve(name);
// A resolution that walked past the target into an ancestor tree does not
// prove the target copy is usable.
return resolved.startsWith(targetNodeModulesDir + sep);
} catch {
return false;
}
}
/**
* Co-locate the SLM optional dependency closure from `<rootDir>/node_modules`
* into a standalone bundle's `node_modules`.
@@ -142,11 +170,12 @@ export function colocateLlmlinguaOptionals({
const closure = computeDependencyClosure(rootNm, seeds);
// Check the complete closure rather than only the entry package. A partially
// populated bundle must still receive any missing transitive dependencies.
// Check the complete closure rather than only the entry package, and judge
// presence by entrypoint integrity — a partially traced directory (see
// isPackageIntact) must still receive its missing files.
if (
closure.length > 0 &&
closure.every((name) => existsSync(join(targetNm, name)))
closure.every((name) => isPackageIntact(targetNm, name))
) {
return { skipped: true, reason: "already co-located" };
}
@@ -155,11 +184,18 @@ export function colocateLlmlinguaOptionals({
for (const name of closure) {
const dest = join(targetNm, name);
if (existsSync(dest)) continue;
if (isPackageIntact(targetNm, name)) continue;
try {
mkdirSync(dirname(dest), { recursive: true });
cpSync(join(rootNm, name), dest, { recursive: true });
// force:false merges into a partially traced directory: files the trace
// already materialized are kept, missing ones (the package payload) are
// filled in from the root tree.
cpSync(join(rootNm, name), dest, {
recursive: true,
force: false,
errorOnExist: false,
});
copied++;
} catch (err) {
log(

View File

@@ -55,6 +55,7 @@ function buildLlmlinguaRoot(
rootNm,
"@atjsh/llmlingua-2",
{
main: "dist/index.js",
dependencies: {
"es-toolkit": "^1.38.0",
},
@@ -227,6 +228,103 @@ test("#9166 standalone assembly never overwrites an already pinned transformers
}
});
test("#9166 co-location completes a partially traced package (package.json without its main)", () => {
const root = mkdtempSync(
join(tmpdir(), "omniroute-docker-llmlingua-partial-9166-")
);
try {
buildLlmlinguaRoot(root);
const { distDir, standaloneDir } = createStandalone(root);
// Next's file tracing materializes @atjsh/llmlingua-2 PARTIALLY in the
// standalone: the package.json lands (its "main" points at dist/index.js)
// but the dist/ payload does not — the exact state the Docker guard hits
// ("Cannot find module .../dist/index.js"). A directory-level no-clobber
// sees the dir and skips the package forever.
mkPkg(join(standaloneDir, "node_modules"), "@atjsh/llmlingua-2", {
main: "dist/index.js",
});
assembleStandalone({
distDir,
outDir: standaloneDir,
projectRoot: root,
copyNatives: true,
});
assert.ok(
existsSync(
join(
standaloneDir,
"node_modules",
"@atjsh",
"llmlingua-2",
"dist",
"index.js"
)
),
"a partially traced package must be completed, not skipped as already present"
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("#9166 co-location is not skipped when every closure dir exists but one is partial", () => {
const root = mkdtempSync(
join(tmpdir(), "omniroute-docker-llmlingua-partial-all-9166-")
);
try {
buildLlmlinguaRoot(root);
const { distDir, standaloneDir } = createStandalone(root);
const standaloneNm = join(standaloneDir, "node_modules");
// Every closure package already has a directory in the standalone (so a
// directory-level "already co-located" early-exit would fire), but the
// llmlingua-2 one is the partial NFT-trace shell without its main.
for (const packageName of [
"es-toolkit",
"@tensorflow/tfjs",
"@tensorflow/tfjs-core",
"long",
"js-tiktoken",
"base64-js",
"@huggingface/transformers",
"onnxruntime-node",
]) {
mkPkg(standaloneNm, packageName, { main: "index.js" }, {
"index.js": "export {};\n",
});
}
mkPkg(standaloneNm, "@atjsh/llmlingua-2", { main: "dist/index.js" });
assembleStandalone({
distDir,
outDir: standaloneDir,
projectRoot: root,
copyNatives: true,
});
assert.ok(
existsSync(
join(
standaloneDir,
"node_modules",
"@atjsh",
"llmlingua-2",
"dist",
"index.js"
)
),
"the closure-wide early-exit must not fire while any member is partial"
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("#9166 Docker explicitly installs and validates LLMLingua optionals", () => {
const dockerfile = readFileSync(
new URL("../../Dockerfile", import.meta.url),