fix: require a compiled artifact for entrypoints.jobs
CI / verify (pull_request) Successful in 31s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 42s

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:
ekklesia
2026-08-21 22:07:54 -05:00
co-authored by Claude Opus 5
parent ff159a04dc
commit 3f9b5e3cb0
6 changed files with 162 additions and 15 deletions
+23 -10
View File
@@ -11,6 +11,22 @@ import {
export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json";
/**
* The only manifest entrypoint that resolves to a directory rather than a compiled file.
*
* Every other entrypoint is an executable build artifact, so the compiled-ESM rule is expressed as
* "not a directory entrypoint" rather than as a list of the entrypoints it covers. An allowlist is
* what let `jobs` and, before it, `ui` be added to the manifest and silently escape this check;
* defaulting a new entrypoint to *stricter* validation is the safer failure mode.
*/
const DIRECTORY_ENTRYPOINTS: ReadonlySet<string> = new Set(["migrations"]);
/** Whether an entrypoint must reference compiled ESM JavaScript (ADR 0006's production loading
* boundary): true for every entrypoint that is a file rather than a directory. */
function isCompiledEntrypoint(entrypointName: string): boolean {
return !DIRECTORY_ENTRYPOINTS.has(entrypointName);
}
export type ModuleDiscoveryIssueCode =
| "DISCOVERY_CONFIGURATION_INVALID"
| "MODULE_CAPABILITY_INCOMPATIBLE"
@@ -247,11 +263,8 @@ async function resolveEntrypoints(
if (relativeEntrypoint === undefined) {
continue;
}
// `ui` joins `schema`/`server` here: a presentation entrypoint is a build output like any
// other module artifact, so source TypeScript is rejected at discovery rather than relied upon
// to be compiled later (ADR 0006's compiled-artifact rule, extended by ADR 0021).
if (
["schema", "server", "ui"].includes(entrypointName) &&
isCompiledEntrypoint(entrypointName) &&
![".js", ".mjs"].includes(path.extname(relativeEntrypoint).toLowerCase())
) {
issues.push({
@@ -288,15 +301,15 @@ async function resolveEntrypoints(
continue;
}
const entrypointStats = await stat(canonicalEntrypoint);
const hasExpectedKind =
entrypointName === "migrations" ? entrypointStats.isDirectory() : entrypointStats.isFile();
const hasExpectedKind = DIRECTORY_ENTRYPOINTS.has(entrypointName)
? entrypointStats.isDirectory()
: entrypointStats.isFile();
if (!hasExpectedKind) {
issues.push({
code: "MODULE_ENTRYPOINT_INVALID",
message:
entrypointName === "migrations"
? "migrations must resolve to a directory."
: `${entrypointName} must resolve to a file.`,
message: DIRECTORY_ENTRYPOINTS.has(entrypointName)
? `${entrypointName} must resolve to a directory.`
: `${entrypointName} must resolve to a file.`,
moduleDirectory: directoryName,
moduleId: manifest.id,
path: `entrypoints.${entrypointName}`,
+116
View File
@@ -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);