fix: require a compiled artifact for entrypoints.jobs #19
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user