feat: add module contracts and discovery
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@ekklesia/contracts",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --project tsconfig.build.json",
|
||||
"lint": "biome lint .",
|
||||
"prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/contracts",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit --project tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"semver": "7.8.5",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.3",
|
||||
"@types/node": "24.13.3",
|
||||
"@types/semver": "7.7.1",
|
||||
"typescript": "7.0.2",
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
"ekklesia": {
|
||||
"layer": "contracts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const localIdentifierPattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
|
||||
const namespacedIdentifierPattern =
|
||||
/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:\.[a-z][a-z0-9]*(?:-[a-z0-9]+)*)+$/u;
|
||||
|
||||
export const moduleIdSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(localIdentifierPattern, "Must be a lowercase kebab-case module identifier.");
|
||||
|
||||
export const capabilityIdSchema = z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(160)
|
||||
.regex(namespacedIdentifierPattern, "Must be a lowercase dot-namespaced capability identifier.");
|
||||
|
||||
export const permissionIdSchema = z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(160)
|
||||
.regex(namespacedIdentifierPattern, "Must be a lowercase dot-namespaced permission identifier.");
|
||||
|
||||
export const logicalBucketIdSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(48)
|
||||
.regex(localIdentifierPattern, "Must be a lowercase kebab-case logical bucket identifier.");
|
||||
|
||||
export const packageRelativePathSchema = z.string().superRefine((value, context) => {
|
||||
if (!value.startsWith("./")) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Must begin with ./ and remain inside the module package.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.includes("\\") || value.includes("\0") || value.includes("?") || value.includes("#")) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Must be a portable package-relative path without URLs, queries, or fragments.",
|
||||
});
|
||||
}
|
||||
|
||||
const segments = value.slice(2).split("/");
|
||||
if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Must not contain empty, current-directory, or parent-directory segments.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type ModuleId = z.infer<typeof moduleIdSchema>;
|
||||
export type CapabilityId = z.infer<typeof capabilityIdSchema>;
|
||||
export type PermissionId = z.infer<typeof permissionIdSchema>;
|
||||
export type LogicalBucketId = z.infer<typeof logicalBucketIdSchema>;
|
||||
export type PackageRelativePath = z.infer<typeof packageRelativePathSchema>;
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./identifiers.js";
|
||||
export * from "./module-lifecycle.js";
|
||||
export * from "./module-manifest.js";
|
||||
export * from "./versions.js";
|
||||
@@ -0,0 +1,17 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const MODULE_LIFECYCLE_STAGES = [
|
||||
"discovered",
|
||||
"validated",
|
||||
"configured",
|
||||
"registered",
|
||||
"migrated",
|
||||
"initialized",
|
||||
"started",
|
||||
"stopped",
|
||||
"failed",
|
||||
] as const;
|
||||
|
||||
export const moduleLifecycleStageSchema = z.enum(MODULE_LIFECYCLE_STAGES);
|
||||
|
||||
export type ModuleLifecycleStage = z.infer<typeof moduleLifecycleStageSchema>;
|
||||
@@ -0,0 +1,199 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
capabilityIdSchema,
|
||||
logicalBucketIdSchema,
|
||||
moduleIdSchema,
|
||||
packageRelativePathSchema,
|
||||
permissionIdSchema,
|
||||
} from "./identifiers.js";
|
||||
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js";
|
||||
|
||||
const moduleVersionMapSchema = z.record(moduleIdSchema, semanticVersionRangeSchema);
|
||||
|
||||
const moduleDependenciesSchema = z
|
||||
.object({
|
||||
required: moduleVersionMapSchema.default(() => ({})),
|
||||
optional: moduleVersionMapSchema.default(() => ({})),
|
||||
})
|
||||
.strict()
|
||||
.default(() => ({ required: {}, optional: {} }));
|
||||
|
||||
const capabilityDependenciesSchema = z
|
||||
.object({
|
||||
required: z.array(capabilityIdSchema).default(() => []),
|
||||
optional: z.array(capabilityIdSchema).default(() => []),
|
||||
})
|
||||
.strict()
|
||||
.default(() => ({ required: [], optional: [] }));
|
||||
|
||||
const dependenciesSchema = z
|
||||
.object({
|
||||
modules: moduleDependenciesSchema,
|
||||
capabilities: capabilityDependenciesSchema,
|
||||
})
|
||||
.strict()
|
||||
.default(() => ({
|
||||
modules: { required: {}, optional: {} },
|
||||
capabilities: { required: [], optional: [] },
|
||||
}));
|
||||
|
||||
export const moduleBucketDeclarationSchema = z
|
||||
.object({
|
||||
id: logicalBucketIdSchema,
|
||||
description: z.string().trim().min(1).max(240).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const moduleStorageSchema = z
|
||||
.object({
|
||||
buckets: z.array(moduleBucketDeclarationSchema).default(() => []),
|
||||
})
|
||||
.strict()
|
||||
.default(() => ({ buckets: [] }));
|
||||
|
||||
const entrypointsSchema = z
|
||||
.object({
|
||||
server: packageRelativePathSchema,
|
||||
schema: packageRelativePathSchema.optional(),
|
||||
migrations: packageRelativePathSchema.optional(),
|
||||
jobs: packageRelativePathSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
function reportDuplicates(
|
||||
values: readonly string[],
|
||||
path: readonly (string | number)[],
|
||||
context: z.RefinementCtx,
|
||||
): void {
|
||||
const seen = new Set<string>();
|
||||
values.forEach((value, index) => {
|
||||
if (seen.has(value)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: `${value} is declared more than once.`,
|
||||
path: [...path, index],
|
||||
});
|
||||
}
|
||||
seen.add(value);
|
||||
});
|
||||
}
|
||||
|
||||
export const moduleManifestSchema = z
|
||||
.object({
|
||||
manifestVersion: z.literal(1),
|
||||
id: moduleIdSchema,
|
||||
name: z.string().trim().min(1).max(120),
|
||||
description: z.string().trim().min(1).max(500).optional(),
|
||||
version: semanticVersionSchema,
|
||||
compatibility: z
|
||||
.object({
|
||||
platform: semanticVersionRangeSchema,
|
||||
moduleSdk: semanticVersionRangeSchema,
|
||||
})
|
||||
.strict(),
|
||||
dependencies: dependenciesSchema,
|
||||
capabilities: z.array(capabilityIdSchema).default(() => []),
|
||||
permissions: z.array(permissionIdSchema).default(() => []),
|
||||
storage: moduleStorageSchema,
|
||||
entrypoints: entrypointsSchema,
|
||||
})
|
||||
.strict()
|
||||
.superRefine((manifest, context) => {
|
||||
reportDuplicates(manifest.capabilities, ["capabilities"], context);
|
||||
reportDuplicates(manifest.permissions, ["permissions"], context);
|
||||
reportDuplicates(
|
||||
manifest.dependencies.capabilities.required,
|
||||
["dependencies", "capabilities", "required"],
|
||||
context,
|
||||
);
|
||||
reportDuplicates(
|
||||
manifest.dependencies.capabilities.optional,
|
||||
["dependencies", "capabilities", "optional"],
|
||||
context,
|
||||
);
|
||||
reportDuplicates(
|
||||
manifest.storage.buckets.map((bucket) => bucket.id),
|
||||
["storage", "buckets"],
|
||||
context,
|
||||
);
|
||||
|
||||
manifest.capabilities.forEach((capability, index) => {
|
||||
if (!capability.startsWith(`${manifest.id}.`)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: `Capability identifiers owned by this module must begin with ${manifest.id}.`,
|
||||
path: ["capabilities", index],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
manifest.permissions.forEach((permission, index) => {
|
||||
if (!permission.startsWith(`${manifest.id}.`)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: `Permission identifiers owned by this module must begin with ${manifest.id}.`,
|
||||
path: ["permissions", index],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
for (const dependencyKind of ["required", "optional"] as const) {
|
||||
if (Object.hasOwn(manifest.dependencies.modules[dependencyKind], manifest.id)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "A module cannot depend on itself.",
|
||||
path: ["dependencies", "modules", dependencyKind, manifest.id],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const dependencyId of Object.keys(manifest.dependencies.modules.required)) {
|
||||
if (Object.hasOwn(manifest.dependencies.modules.optional, dependencyId)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: `${dependencyId} cannot be both a required and optional module dependency.`,
|
||||
path: ["dependencies", "modules", "optional", dependencyId],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const ownedCapabilities = new Set(manifest.capabilities);
|
||||
const requiredCapabilities = new Set(manifest.dependencies.capabilities.required);
|
||||
manifest.dependencies.capabilities.required.forEach((capability, index) => {
|
||||
if (ownedCapabilities.has(capability)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "A module does not need to depend on a capability it owns.",
|
||||
path: ["dependencies", "capabilities", "required", index],
|
||||
});
|
||||
}
|
||||
});
|
||||
manifest.dependencies.capabilities.optional.forEach((capability, index) => {
|
||||
if (ownedCapabilities.has(capability)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "A module does not need to depend on a capability it owns.",
|
||||
path: ["dependencies", "capabilities", "optional", index],
|
||||
});
|
||||
}
|
||||
if (requiredCapabilities.has(capability)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: `${capability} cannot be both a required and optional capability dependency.`,
|
||||
path: ["dependencies", "capabilities", "optional", index],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export function parseModuleManifest(input: unknown): ModuleManifest {
|
||||
return moduleManifestSchema.parse(input);
|
||||
}
|
||||
|
||||
export function safeParseModuleManifest(input: unknown) {
|
||||
return moduleManifestSchema.safeParse(input);
|
||||
}
|
||||
|
||||
export type ModuleManifestInput = z.input<typeof moduleManifestSchema>;
|
||||
export type ModuleManifest = z.output<typeof moduleManifestSchema>;
|
||||
export type ModuleBucketDeclaration = z.output<typeof moduleBucketDeclarationSchema>;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { satisfies, valid, validRange } from "semver";
|
||||
import { z } from "zod";
|
||||
|
||||
export const semanticVersionSchema = z.string().refine((value) => valid(value) === value, {
|
||||
message: "Must be a strict semantic version.",
|
||||
});
|
||||
|
||||
export const semanticVersionRangeSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine((value) => validRange(value) !== null, {
|
||||
message: "Must be a valid semantic version range.",
|
||||
});
|
||||
|
||||
export function isVersionCompatible(version: string, range: string): boolean {
|
||||
return valid(version) === version && validRange(range) !== null && satisfies(version, range);
|
||||
}
|
||||
|
||||
export type SemanticVersion = z.infer<typeof semanticVersionSchema>;
|
||||
export type SemanticVersionRange = z.infer<typeof semanticVersionRangeSchema>;
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isVersionCompatible,
|
||||
MODULE_LIFECYCLE_STAGES,
|
||||
packageRelativePathSchema,
|
||||
parseModuleManifest,
|
||||
safeParseModuleManifest,
|
||||
} from "../src/index.js";
|
||||
|
||||
function validManifest(): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 1,
|
||||
id: "calendar",
|
||||
name: "Calendar",
|
||||
version: "0.1.0",
|
||||
compatibility: {
|
||||
platform: "^0.1.0",
|
||||
moduleSdk: "^0.1.0",
|
||||
},
|
||||
capabilities: ["calendar.events"],
|
||||
permissions: ["calendar.events.read"],
|
||||
storage: {
|
||||
buckets: [{ id: "assets", description: "Calendar-owned event assets." }],
|
||||
},
|
||||
entrypoints: {
|
||||
server: "./src/server/index.ts",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("module manifest contract", () => {
|
||||
it("normalizes a valid manifest with safe defaults", () => {
|
||||
const manifest = parseModuleManifest(validManifest());
|
||||
|
||||
expect(manifest.id).toBe("calendar");
|
||||
expect(manifest.dependencies).toEqual({
|
||||
capabilities: { optional: [], required: [] },
|
||||
modules: { optional: {}, required: {} },
|
||||
});
|
||||
expect(manifest.storage.buckets).toEqual([
|
||||
{ id: "assets", description: "Calendar-owned event assets." },
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates isolated default collections for every parsed manifest", () => {
|
||||
const input = validManifest();
|
||||
delete input.storage;
|
||||
const first = parseModuleManifest(input);
|
||||
const second = parseModuleManifest(input);
|
||||
|
||||
first.dependencies.modules.required.people = "^0.1.0";
|
||||
first.dependencies.capabilities.required.push("people.records");
|
||||
first.storage.buckets.push({ id: "exports" });
|
||||
|
||||
expect(second.dependencies.modules.required).toEqual({});
|
||||
expect(second.dependencies.capabilities.required).toEqual([]);
|
||||
expect(second.storage.buckets).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects unknown and presentation-oriented manifest properties", () => {
|
||||
const manifest = { ...validManifest(), renderers: ["calendar-month"] };
|
||||
const result = safeParseModuleManifest(manifest);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("requires module-owned capability and permission namespaces", () => {
|
||||
const manifest = {
|
||||
...validManifest(),
|
||||
capabilities: ["media.items"],
|
||||
permissions: ["media.items.read"],
|
||||
};
|
||||
const result = safeParseModuleManifest(manifest);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues.map((issue) => issue.path.join("."))).toEqual(
|
||||
expect.arrayContaining(["capabilities.0", "permissions.0"]),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects duplicate declarations and dependency overlap", () => {
|
||||
const manifest = {
|
||||
...validManifest(),
|
||||
dependencies: {
|
||||
modules: {
|
||||
required: { people: "^1.0.0" },
|
||||
optional: { people: "^1.0.0" },
|
||||
},
|
||||
capabilities: {
|
||||
required: ["people.records"],
|
||||
optional: ["people.records"],
|
||||
},
|
||||
},
|
||||
capabilities: ["calendar.events", "calendar.events"],
|
||||
storage: { buckets: [{ id: "assets" }, { id: "assets" }] },
|
||||
};
|
||||
const result = safeParseModuleManifest(manifest);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues).toHaveLength(4);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects module and capability self-dependencies", () => {
|
||||
const manifest = {
|
||||
...validManifest(),
|
||||
dependencies: {
|
||||
modules: { required: { calendar: "^0.1.0" } },
|
||||
capabilities: { required: ["calendar.events"] },
|
||||
},
|
||||
};
|
||||
|
||||
expect(safeParseModuleManifest(manifest).success).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"../outside.ts",
|
||||
"/absolute.ts",
|
||||
"file:///server.ts",
|
||||
"./src/../server.ts",
|
||||
"./src\\server.ts",
|
||||
"./src/server.ts?raw",
|
||||
])("rejects unsafe package entrypoint %s", (entrypoint) => {
|
||||
expect(packageRelativePathSchema.safeParse(entrypoint).success).toBe(false);
|
||||
});
|
||||
|
||||
it("allows package directories whose names merely begin with two dots", () => {
|
||||
expect(packageRelativePathSchema.safeParse("./..data/server.ts").success).toBe(true);
|
||||
});
|
||||
|
||||
it("uses full semantic-version compatibility ranges", () => {
|
||||
expect(isVersionCompatible("0.1.5", "^0.1.0")).toBe(true);
|
||||
expect(isVersionCompatible("0.2.0", "^0.1.0")).toBe(false);
|
||||
expect(isVersionCompatible("not-a-version", "*")).toBe(false);
|
||||
});
|
||||
|
||||
it("publishes the complete lifecycle vocabulary", () => {
|
||||
expect(MODULE_LIFECYCLE_STAGES).toContain("discovered");
|
||||
expect(MODULE_LIFECYCLE_STAGES).toContain("failed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"noEmit": false,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"sourceMap": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "tests"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../typescript-config/node.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"types": ["node", "vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@ekklesia/module-sdk",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --project tsconfig.build.json",
|
||||
"lint": "biome lint .",
|
||||
"prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/module-sdk",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit --project tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ekklesia/contracts": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.3",
|
||||
"@types/node": "24.13.3",
|
||||
"typescript": "7.0.2",
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
"ekklesia": {
|
||||
"layer": "module-sdk"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import {
|
||||
type ModuleManifest,
|
||||
type ModuleManifestInput,
|
||||
parseModuleManifest,
|
||||
} from "@ekklesia/contracts";
|
||||
|
||||
export function defineModule(manifest: ModuleManifestInput): ModuleManifest {
|
||||
return parseModuleManifest(manifest);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "@ekklesia/contracts";
|
||||
export * from "./define-module.js";
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defineModule } from "../src/index.js";
|
||||
|
||||
describe("defineModule", () => {
|
||||
it("returns a validated and normalized public manifest", () => {
|
||||
const module = defineModule({
|
||||
manifestVersion: 1,
|
||||
id: "example",
|
||||
name: "Example",
|
||||
version: "0.1.0",
|
||||
compatibility: {
|
||||
platform: "^0.1.0",
|
||||
moduleSdk: "^0.1.0",
|
||||
},
|
||||
entrypoints: {
|
||||
server: "./src/server/index.ts",
|
||||
},
|
||||
});
|
||||
|
||||
expect(module.capabilities).toEqual([]);
|
||||
expect(module.permissions).toEqual([]);
|
||||
expect(module.storage.buckets).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects invalid module definitions before runtime registration", () => {
|
||||
expect(() =>
|
||||
defineModule({
|
||||
manifestVersion: 1,
|
||||
id: "Not Valid",
|
||||
name: "Invalid",
|
||||
version: "0.1.0",
|
||||
compatibility: {
|
||||
platform: "^0.1.0",
|
||||
moduleSdk: "^0.1.0",
|
||||
},
|
||||
entrypoints: {
|
||||
server: "./src/server/index.ts",
|
||||
},
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"noEmit": false,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"sourceMap": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "tests"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../typescript-config/node.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"types": ["node", "vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user