fix: require a compiled artifact for entrypoints.jobs
Module discovery gated the compiled-ESM rule on an allowlist, `["schema", "server", "ui"]`, so `entrypoints.jobs` accepted `./src/jobs/index.ts`. Every other executable entrypoint required a built `.js` or `.mjs`; `jobs` did not, purely because it was never added to that list. It is the second entrypoint to slip through the same way — `ui` did too, until Phase 6A added it explicitly — which is the argument against the allowlist rather than against `jobs`. The rule is now expressed as "every entrypoint except a directory entrypoint", with `migrations` the only member of that set. A new manifest entrypoint therefore defaults to the stricter validation instead of silently escaping it. The same constant replaces the two existing `=== "migrations"` special cases for the file/directory kind check and its message, so the one thing that distinguishes `migrations` is stated once. The directory message is now templated and produces byte-identical text. Every other entrypoint guarantee is untouched: package-relative paths, no traversal, inside-package before and after symlink resolution, the artifact must exist, and it must be a file. No runtime was added. `entrypoints.jobs` still has no consumer — it is declared, validated, and never executed — exactly as it has been since ADR 0006, and as `ui` was before its host loader existed. The manifest schema is unchanged and this stays within v4: the field, its type, and its optionality are all the same, and only discovery's validation of the declared path tightened. Compatibility impact is nil in first-party code: no module, template, or fixture declares a `jobs` entrypoint at all. A third-party module declaring a source-TypeScript path would now fail discovery, which is the intended correction. Tests cover accepted `.js` and `.mjs`, rejected `.ts`/`.tsx`/`.cjs`, a missing artifact, a directory, a symlink escaping the package, and that discovery still never evaluates the artifact. The three rejection cases were confirmed to fail against the previous implementation. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ff159a04dc
commit
3f9b5e3cb0
@@ -595,6 +595,122 @@ describe("module discovery", () => {
|
||||
expect(firstError.issues).toEqual(secondError.issues);
|
||||
});
|
||||
|
||||
describe("compiled jobs entrypoint", () => {
|
||||
function manifestWithJobs(id: string, jobs: string): Record<string, unknown> {
|
||||
const manifest = manifestFor(id);
|
||||
manifest.entrypoints = { server: "./dist/server/index.js", jobs };
|
||||
return manifest;
|
||||
}
|
||||
|
||||
it.each([
|
||||
["./dist/jobs/index.js", "index.js"],
|
||||
["./dist/jobs/index.mjs", "index.mjs"],
|
||||
])("accepts the compiled jobs artifact %s", async (jobs, filename) => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createModule(modulesDirectory, "jobs-compiled", {
|
||||
manifest: manifestWithJobs("jobs-compiled", jobs),
|
||||
extraFiles: { [`dist/jobs/${filename}`]: "export default {};\n" },
|
||||
});
|
||||
|
||||
const [discovered] = await discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
});
|
||||
|
||||
expect(discovered?.entrypoints.jobs).toBe(
|
||||
path.join(discovered?.directory ?? "", "dist/jobs", filename),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"./src/jobs/index.ts",
|
||||
"./src/jobs/index.tsx",
|
||||
"./dist/jobs/index.cjs",
|
||||
])("rejects the uncompiled jobs entrypoint %s", async (jobs) => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createModule(modulesDirectory, "jobs-source", {
|
||||
manifest: manifestWithJobs("jobs-source", jobs),
|
||||
extraFiles: { [jobs.slice(2)]: "export default {};\n" },
|
||||
});
|
||||
|
||||
const error = await discoveryError(modulesDirectory);
|
||||
expect(error.issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "MODULE_ENTRYPOINT_INVALID",
|
||||
message: "jobs must reference compiled ESM JavaScript with a .js or .mjs extension.",
|
||||
path: "entrypoints.jobs",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a declared jobs artifact that does not exist", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createModule(modulesDirectory, "jobs-missing", {
|
||||
manifest: manifestWithJobs("jobs-missing", "./dist/jobs/index.js"),
|
||||
});
|
||||
|
||||
const error = await discoveryError(modulesDirectory);
|
||||
expect(issueCodes(error)).toContain("MODULE_ENTRYPOINT_MISSING");
|
||||
});
|
||||
|
||||
it("rejects a jobs entrypoint that resolves to a directory", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createModule(modulesDirectory, "jobs-directory", {
|
||||
manifest: manifestWithJobs("jobs-directory", "./dist/jobs.js"),
|
||||
extraFiles: { "dist/jobs.js/placeholder.txt": "not a file entrypoint" },
|
||||
});
|
||||
|
||||
const error = await discoveryError(modulesDirectory);
|
||||
expect(error.issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "MODULE_ENTRYPOINT_INVALID",
|
||||
message: "jobs must resolve to a file.",
|
||||
path: "entrypoints.jobs",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a jobs entrypoint linked outside its own module package", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const externalDirectory = await createModulesDirectory();
|
||||
const externalArtifact = path.join(externalDirectory, "foreign-jobs.js");
|
||||
await writeFile(externalArtifact, "export default {};\n");
|
||||
await createModule(modulesDirectory, "jobs-linked", {
|
||||
manifest: manifestWithJobs("jobs-linked", "./dist/jobs/index.js"),
|
||||
});
|
||||
await mkdir(path.join(modulesDirectory, "jobs-linked/dist/jobs"), { recursive: true });
|
||||
await symlink(
|
||||
externalArtifact,
|
||||
path.join(modulesDirectory, "jobs-linked/dist/jobs/index.js"),
|
||||
);
|
||||
|
||||
const error = await discoveryError(modulesDirectory);
|
||||
expect(issueCodes(error)).toContain("MODULE_ENTRYPOINT_INVALID");
|
||||
});
|
||||
|
||||
it("never imports a jobs artifact merely to discover it", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createModule(modulesDirectory, "jobs-inert", {
|
||||
manifest: manifestWithJobs("jobs-inert", "./dist/jobs/index.js"),
|
||||
// Discovery is inert. If it ever imported a jobs entrypoint this would reject rather than
|
||||
// discover successfully — `jobs` still has no runtime consumer.
|
||||
extraFiles: {
|
||||
"dist/jobs/index.js": "throw new Error('a jobs entrypoint must never be evaluated');",
|
||||
},
|
||||
});
|
||||
|
||||
const discovered = await discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
});
|
||||
|
||||
expect(discovered).toHaveLength(1);
|
||||
expect(discovered[0]?.entrypoints.jobs).toContain("dist/jobs/index.js");
|
||||
});
|
||||
});
|
||||
|
||||
describe("module-owned UI entrypoint (ADR 0021)", () => {
|
||||
function manifestWithUi(id: string, ui: string): Record<string, unknown> {
|
||||
const manifest = manifestFor(id);
|
||||
|
||||
Reference in New Issue
Block a user