fix: require a compiled artifact for entrypoints.jobs #19

Merged
Zayin_Admin merged 1 commits from fix/compiled-jobs-entrypoint into main 2026-08-22 03:11:30 +00:00
6 changed files with 162 additions and 15 deletions
Showing only changes of commit 3f9b5e3cb0 - Show all commits
+22 -9
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,14 +301,14 @@ 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."
message: DIRECTORY_ENTRYPOINTS.has(entrypointName)
? `${entrypointName} must resolve to a directory.`
: `${entrypointName} must resolve to a file.`,
moduleDirectory: directoryName,
moduleId: manifest.id,
+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);
@@ -272,3 +272,9 @@ every real feature module. `entrypoints.jobs` still accepts source TypeScript wh
entrypoints require compiled output — that inconsistency is unchanged and remains open, because this
pass did not centralize executable-entrypoint validation and fixing it here would have been unrelated
scope.
> **Correction — this deferred item is closed.** `entrypoints.jobs` now requires a compiled `.js` or
> `.mjs` artifact, exactly like `server`, `schema`, and `ui`. Module discovery expresses the rule as
> "every entrypoint except the directory entrypoint (`migrations`)", so the omission that let `jobs`
> escape it cannot recur. No jobs runtime was added: the entrypoint is still declared, validated, and
> never executed. Every other item in this list remains deferred.
@@ -198,3 +198,9 @@ loading; and every real feature module. `entrypoints.jobs` still accepts source
other executable entrypoints require compiled output; this pass reused the *path* grammar from
`@ekklesia/contracts` but did not centralize the *extension* rule, so that inconsistency is
unchanged and still open.
> **Correction — this deferred item is closed.** `entrypoints.jobs` now requires a compiled `.js` or
> `.mjs` artifact, exactly like `server`, `schema`, and `ui`. Module discovery expresses the rule as
> "every entrypoint except the directory entrypoint (`migrations`)", so the omission that let `jobs`
> escape it cannot recur. No jobs runtime was added: the entrypoint is still declared, validated, and
> never executed. Every other item in this list remains deferred.
@@ -197,3 +197,9 @@ is unchanged and still open. The permission-id typing gap (`permissions.has` tak
a typo silently hides a control) is likewise unchanged: the browser-safe split did not produce a
clean answer for it, and inventing one would have meant shipping generated constants this phase does
not need.
> **Correction — this deferred item is closed.** `entrypoints.jobs` now requires a compiled `.js` or
> `.mjs` artifact, exactly like `server`, `schema`, and `ui`. Module discovery expresses the rule as
> "every entrypoint except the directory entrypoint (`migrations`)", so the omission that let `jobs`
> escape it cannot recur. No jobs runtime was added: the entrypoint is still declared, validated, and
> never executed. Every other item in this list remains deferred.
+5 -5
View File
@@ -153,11 +153,11 @@ module's own HTTP routes, and creating relationship-derived grants automatically
own business logic, are both still later work; this only makes a declared permission *usable*
through Core's existing, generic surface.
Server and optional schema and UI entrypoints must be compiled ESM `.js` or `.mjs` files. The
optional migration entrypoint is a directory. All entrypoints begin with `./`, remain inside the
package, and exist. Production never imports source TypeScript directly. Modules with no tables omit
`entrypoints.schema`; modules with no migration history omit `entrypoints.migrations`; modules with
no UI omit `entrypoints.ui`.
Every entrypoint except `migrations` must be a compiled ESM `.js` or `.mjs` file — `server`,
`schema`, `jobs`, and `ui` alike. `migrations` is the one directory entrypoint. All entrypoints begin
with `./`, remain inside the package, and exist. Production never imports source TypeScript directly.
Modules with no tables omit `entrypoints.schema`; modules with no migration history omit
`entrypoints.migrations`; modules with no UI omit `entrypoints.ui`.
Unknown manifest properties are still rejected. `ui` is the only entrypoint version 4 adds — a
`dashboard`, `surface`, `client`, `web`, or `renderers` key fails validation — and the manifest