planmode 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,119 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { parse } from "yaml";
4
+ import type { PackageManifest, PackageType, VariableType, Category } from "../types/index.js";
5
+
6
+ const NAME_REGEX = /^(@[a-z0-9-]+\/)?[a-z0-9][a-z0-9-]*$/;
7
+ const SEMVER_REGEX = /^\d+\.\d+\.\d+$/;
8
+ const VALID_TYPES: PackageType[] = ["prompt", "rule", "plan"];
9
+ const VALID_VAR_TYPES: VariableType[] = ["string", "number", "boolean", "enum", "resolved"];
10
+ const VALID_CATEGORIES: Category[] = [
11
+ "frontend", "backend", "devops", "database", "testing",
12
+ "mobile", "ai-ml", "design", "security", "other",
13
+ ];
14
+
15
+ export function parseManifest(raw: string): PackageManifest {
16
+ const data = parse(raw);
17
+ if (!data || typeof data !== "object") {
18
+ throw new Error("Invalid YAML: manifest must be an object");
19
+ }
20
+ return data as PackageManifest;
21
+ }
22
+
23
+ export function readManifest(dir: string): PackageManifest {
24
+ const manifestPath = path.join(dir, "planmode.yaml");
25
+ if (!fs.existsSync(manifestPath)) {
26
+ throw new Error(`No planmode.yaml found in ${dir}`);
27
+ }
28
+ const raw = fs.readFileSync(manifestPath, "utf-8");
29
+ return parseManifest(raw);
30
+ }
31
+
32
+ export function validateManifest(manifest: PackageManifest, requirePublishFields = false): string[] {
33
+ const errors: string[] = [];
34
+
35
+ // Required fields
36
+ if (!manifest.name) {
37
+ errors.push("Missing required field: name");
38
+ } else if (!NAME_REGEX.test(manifest.name)) {
39
+ errors.push(`Invalid name "${manifest.name}": must match ${NAME_REGEX}`);
40
+ } else if (manifest.name.length > 100) {
41
+ errors.push("Name must be 100 characters or fewer");
42
+ }
43
+
44
+ if (!manifest.version) {
45
+ errors.push("Missing required field: version");
46
+ } else if (!SEMVER_REGEX.test(manifest.version)) {
47
+ errors.push(`Invalid version "${manifest.version}": must be valid semver (X.Y.Z)`);
48
+ }
49
+
50
+ if (!manifest.type) {
51
+ errors.push("Missing required field: type");
52
+ } else if (!VALID_TYPES.includes(manifest.type)) {
53
+ errors.push(`Invalid type "${manifest.type}": must be one of ${VALID_TYPES.join(", ")}`);
54
+ }
55
+
56
+ // Publish fields
57
+ if (requirePublishFields) {
58
+ if (!manifest.description) errors.push("Missing required field: description");
59
+ if (manifest.description && manifest.description.length > 200) {
60
+ errors.push("Description must be 200 characters or fewer");
61
+ }
62
+ if (!manifest.author) errors.push("Missing required field: author");
63
+ if (!manifest.license) errors.push("Missing required field: license");
64
+ }
65
+
66
+ // Optional field validation
67
+ if (manifest.tags) {
68
+ if (manifest.tags.length > 10) {
69
+ errors.push("Maximum 10 tags allowed");
70
+ }
71
+ for (const tag of manifest.tags) {
72
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(tag)) {
73
+ errors.push(`Invalid tag "${tag}": must be lowercase alphanumeric with hyphens`);
74
+ }
75
+ }
76
+ }
77
+
78
+ if (manifest.category && !VALID_CATEGORIES.includes(manifest.category)) {
79
+ errors.push(`Invalid category "${manifest.category}": must be one of ${VALID_CATEGORIES.join(", ")}`);
80
+ }
81
+
82
+ // Dependencies only for plan and rule
83
+ if (manifest.dependencies && manifest.type === "prompt") {
84
+ errors.push("Dependencies are not allowed for prompt type packages");
85
+ }
86
+
87
+ // Variable validation
88
+ if (manifest.variables) {
89
+ for (const [varName, varDef] of Object.entries(manifest.variables)) {
90
+ if (!varDef.type || !VALID_VAR_TYPES.includes(varDef.type)) {
91
+ errors.push(`Variable "${varName}" has invalid type: must be one of ${VALID_VAR_TYPES.join(", ")}`);
92
+ }
93
+ if (varDef.type === "enum" && (!varDef.options || varDef.options.length === 0)) {
94
+ errors.push(`Variable "${varName}" of type enum must have options`);
95
+ }
96
+ }
97
+ }
98
+
99
+ // Content
100
+ if (manifest.content && manifest.content_file) {
101
+ errors.push("Cannot specify both content and content_file");
102
+ }
103
+
104
+ return errors;
105
+ }
106
+
107
+ export function readPackageContent(dir: string, manifest: PackageManifest): string {
108
+ if (manifest.content) {
109
+ return manifest.content;
110
+ }
111
+ if (manifest.content_file) {
112
+ const contentPath = path.join(dir, manifest.content_file);
113
+ if (!fs.existsSync(contentPath)) {
114
+ throw new Error(`Content file not found: ${manifest.content_file}`);
115
+ }
116
+ return fs.readFileSync(contentPath, "utf-8");
117
+ }
118
+ throw new Error("Package must specify either content or content_file");
119
+ }
@@ -0,0 +1,135 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { getCacheDir, getCacheTTL, getRegistries, getGitHubToken } from "./config.js";
4
+ import type { RegistryIndex, PackageSummary, PackageMetadata, VersionMetadata } from "../types/index.js";
5
+
6
+ const INDEX_CACHE_FILE = "index.json";
7
+
8
+ function getHeaders(): Record<string, string> {
9
+ const headers: Record<string, string> = {
10
+ "Accept": "application/vnd.github.v3.raw+json",
11
+ "User-Agent": "planmode-cli",
12
+ };
13
+ const token = getGitHubToken();
14
+ if (token) {
15
+ headers["Authorization"] = `Bearer ${token}`;
16
+ }
17
+ return headers;
18
+ }
19
+
20
+ function registryRawUrl(registryUrl: string, filePath: string): string {
21
+ // Convert github.com/org/repo to raw.githubusercontent.com URL
22
+ const match = registryUrl.match(/^github\.com\/([^/]+)\/([^/]+)$/);
23
+ if (!match) {
24
+ throw new Error(`Invalid registry URL: ${registryUrl}`);
25
+ }
26
+ return `https://raw.githubusercontent.com/${match[1]}/${match[2]}/main/${filePath}`;
27
+ }
28
+
29
+ function resolveRegistry(packageName: string): string {
30
+ const registries = getRegistries();
31
+
32
+ // Scoped packages: @scope/name → look up scope in registries
33
+ if (packageName.startsWith("@")) {
34
+ const scope = packageName.split("/")[0]!.slice(1);
35
+ const registryUrl = registries[scope];
36
+ if (!registryUrl) {
37
+ throw new Error(
38
+ `No registry configured for scope "@${scope}". Run: planmode registry add ${scope} <url>`,
39
+ );
40
+ }
41
+ return registryUrl;
42
+ }
43
+
44
+ return registries["default"]!;
45
+ }
46
+
47
+ export async function fetchIndex(registryUrl?: string): Promise<RegistryIndex> {
48
+ const url = registryUrl ?? getRegistries()["default"]!;
49
+ const cacheDir = getCacheDir();
50
+ const cachePath = path.join(cacheDir, INDEX_CACHE_FILE);
51
+ const ttl = getCacheTTL();
52
+
53
+ // Check cache
54
+ try {
55
+ const stat = fs.statSync(cachePath);
56
+ const ageSeconds = (Date.now() - stat.mtimeMs) / 1000;
57
+ if (ageSeconds < ttl) {
58
+ const cached = JSON.parse(fs.readFileSync(cachePath, "utf-8"));
59
+ return cached as RegistryIndex;
60
+ }
61
+ } catch {
62
+ // Cache miss
63
+ }
64
+
65
+ // Fetch from remote
66
+ const rawUrl = registryRawUrl(url, "index.json");
67
+ const response = await fetch(rawUrl, { headers: getHeaders() });
68
+ if (!response.ok) {
69
+ throw new Error(`Failed to fetch registry index: ${response.status} ${response.statusText}`);
70
+ }
71
+ const data = (await response.json()) as RegistryIndex;
72
+
73
+ // Write cache
74
+ fs.mkdirSync(cacheDir, { recursive: true });
75
+ fs.writeFileSync(cachePath, JSON.stringify(data, null, 2), "utf-8");
76
+
77
+ return data;
78
+ }
79
+
80
+ export async function searchPackages(
81
+ query: string,
82
+ options?: { type?: string; category?: string },
83
+ ): Promise<PackageSummary[]> {
84
+ const index = await fetchIndex();
85
+ const q = query.toLowerCase();
86
+
87
+ let results = index.packages.filter((pkg) => {
88
+ const searchable = [pkg.name, pkg.description, pkg.author, ...pkg.tags].join(" ").toLowerCase();
89
+ return searchable.includes(q);
90
+ });
91
+
92
+ if (options?.type) {
93
+ results = results.filter((pkg) => pkg.type === options.type);
94
+ }
95
+ if (options?.category) {
96
+ results = results.filter((pkg) => pkg.category === options.category);
97
+ }
98
+
99
+ return results.sort((a, b) => b.downloads - a.downloads);
100
+ }
101
+
102
+ export async function fetchPackageMetadata(packageName: string): Promise<PackageMetadata> {
103
+ const registryUrl = resolveRegistry(packageName);
104
+ const name = packageName.startsWith("@") ? packageName.split("/")[1]! : packageName;
105
+ const rawUrl = registryRawUrl(registryUrl, `packages/${name}/metadata.json`);
106
+
107
+ const response = await fetch(rawUrl, { headers: getHeaders() });
108
+ if (!response.ok) {
109
+ if (response.status === 404) {
110
+ throw new Error(
111
+ `Package '${packageName}' not found in registry. Run \`planmode search <query>\` to find packages.`,
112
+ );
113
+ }
114
+ throw new Error(`Failed to fetch package metadata: ${response.status}`);
115
+ }
116
+ return (await response.json()) as PackageMetadata;
117
+ }
118
+
119
+ export async function fetchVersionMetadata(
120
+ packageName: string,
121
+ version: string,
122
+ ): Promise<VersionMetadata> {
123
+ const registryUrl = resolveRegistry(packageName);
124
+ const name = packageName.startsWith("@") ? packageName.split("/")[1]! : packageName;
125
+ const rawUrl = registryRawUrl(registryUrl, `packages/${name}/versions/${version}.json`);
126
+
127
+ const response = await fetch(rawUrl, { headers: getHeaders() });
128
+ if (!response.ok) {
129
+ if (response.status === 404) {
130
+ throw new Error(`Version '${version}' not found for '${packageName}'.`);
131
+ }
132
+ throw new Error(`Failed to fetch version metadata: ${response.status}`);
133
+ }
134
+ return (await response.json()) as VersionMetadata;
135
+ }
@@ -0,0 +1,120 @@
1
+ import type { PackageMetadata } from "../types/index.js";
2
+ import { fetchPackageMetadata } from "./registry.js";
3
+
4
+ interface VersionRange {
5
+ operator: "exact" | "caret" | "tilde" | "gte" | "any";
6
+ major: number;
7
+ minor: number;
8
+ patch: number;
9
+ }
10
+
11
+ function parseSemver(version: string): { major: number; minor: number; patch: number } {
12
+ const parts = version.split(".").map(Number);
13
+ return { major: parts[0]!, minor: parts[1]!, patch: parts[2]! };
14
+ }
15
+
16
+ function parseVersionRange(range: string): VersionRange {
17
+ if (range === "*") {
18
+ return { operator: "any", major: 0, minor: 0, patch: 0 };
19
+ }
20
+ if (range.startsWith("^")) {
21
+ const { major, minor, patch } = parseSemver(range.slice(1));
22
+ return { operator: "caret", major, minor, patch };
23
+ }
24
+ if (range.startsWith("~")) {
25
+ const { major, minor, patch } = parseSemver(range.slice(1));
26
+ return { operator: "tilde", major, minor, patch };
27
+ }
28
+ if (range.startsWith(">=")) {
29
+ const { major, minor, patch } = parseSemver(range.slice(2));
30
+ return { operator: "gte", major, minor, patch };
31
+ }
32
+ const { major, minor, patch } = parseSemver(range);
33
+ return { operator: "exact", major, minor, patch };
34
+ }
35
+
36
+ function satisfies(version: string, range: VersionRange): boolean {
37
+ const v = parseSemver(version);
38
+
39
+ switch (range.operator) {
40
+ case "any":
41
+ return true;
42
+
43
+ case "exact":
44
+ return v.major === range.major && v.minor === range.minor && v.patch === range.patch;
45
+
46
+ case "caret":
47
+ // ^1.2.3 → >=1.2.3, <2.0.0
48
+ if (v.major !== range.major) return false;
49
+ if (v.minor > range.minor) return true;
50
+ if (v.minor === range.minor) return v.patch >= range.patch;
51
+ return false;
52
+
53
+ case "tilde":
54
+ // ~1.2.3 → >=1.2.3, <1.3.0
55
+ if (v.major !== range.major) return false;
56
+ if (v.minor !== range.minor) return false;
57
+ return v.patch >= range.patch;
58
+
59
+ case "gte":
60
+ if (v.major > range.major) return true;
61
+ if (v.major < range.major) return false;
62
+ if (v.minor > range.minor) return true;
63
+ if (v.minor < range.minor) return false;
64
+ return v.patch >= range.patch;
65
+ }
66
+ }
67
+
68
+ function compareVersions(a: string, b: string): number {
69
+ const va = parseSemver(a);
70
+ const vb = parseSemver(b);
71
+ if (va.major !== vb.major) return va.major - vb.major;
72
+ if (va.minor !== vb.minor) return va.minor - vb.minor;
73
+ return va.patch - vb.patch;
74
+ }
75
+
76
+ export function parseDepString(dep: string): { name: string; range: string } {
77
+ const atIndex = dep.lastIndexOf("@");
78
+ if (atIndex > 0) {
79
+ return {
80
+ name: dep.slice(0, atIndex),
81
+ range: dep.slice(atIndex + 1),
82
+ };
83
+ }
84
+ return { name: dep, range: "*" };
85
+ }
86
+
87
+ export async function resolveVersion(
88
+ packageName: string,
89
+ versionRange?: string,
90
+ ): Promise<{ version: string; metadata: PackageMetadata }> {
91
+ const metadata = await fetchPackageMetadata(packageName);
92
+
93
+ if (!versionRange || versionRange === "latest") {
94
+ return { version: metadata.latest_version, metadata };
95
+ }
96
+
97
+ const range = parseVersionRange(versionRange);
98
+ const matching = metadata.versions
99
+ .filter((v) => satisfies(v, range))
100
+ .sort(compareVersions);
101
+
102
+ if (matching.length === 0) {
103
+ throw new Error(
104
+ `Version '${versionRange}' not found for '${packageName}'. Available: ${metadata.versions.join(", ")}`,
105
+ );
106
+ }
107
+
108
+ const version = matching[matching.length - 1]!;
109
+ return { version, metadata };
110
+ }
111
+
112
+ export function findHighestSatisfying(versions: string[], ranges: string[]): string | null {
113
+ const parsedRanges = ranges.map(parseVersionRange);
114
+
115
+ const matching = versions
116
+ .filter((v) => parsedRanges.every((range) => satisfies(v, range)))
117
+ .sort(compareVersions);
118
+
119
+ return matching.length > 0 ? matching[matching.length - 1]! : null;
120
+ }
@@ -0,0 +1,110 @@
1
+ import Handlebars from "handlebars";
2
+ import type { VariableDefinition } from "../types/index.js";
3
+
4
+ // Register helpers
5
+ Handlebars.registerHelper("eq", (a, b) => a === b);
6
+
7
+ export function renderTemplate(
8
+ content: string,
9
+ variables: Record<string, string | number | boolean>,
10
+ ): string {
11
+ const template = Handlebars.compile(content);
12
+ return template(variables);
13
+ }
14
+
15
+ export function collectVariableValues(
16
+ variableDefs: Record<string, VariableDefinition>,
17
+ provided: Record<string, string>,
18
+ ): Record<string, string | number | boolean> {
19
+ const values: Record<string, string | number | boolean> = {};
20
+
21
+ for (const [name, def] of Object.entries(variableDefs)) {
22
+ const rawValue = provided[name];
23
+
24
+ if (rawValue !== undefined) {
25
+ values[name] = coerceValue(rawValue, def);
26
+ } else if (def.default !== undefined) {
27
+ values[name] = def.default;
28
+ } else if (def.required) {
29
+ throw new Error(`Missing required variable: ${name} — ${def.description}`);
30
+ }
31
+ }
32
+
33
+ return values;
34
+ }
35
+
36
+ function coerceValue(
37
+ raw: string,
38
+ def: VariableDefinition,
39
+ ): string | number | boolean {
40
+ switch (def.type) {
41
+ case "number":
42
+ return Number(raw);
43
+ case "boolean":
44
+ return raw === "true" || raw === "1" || raw === "yes";
45
+ case "enum":
46
+ if (def.options && !def.options.includes(raw)) {
47
+ throw new Error(
48
+ `Invalid value "${raw}" for enum variable. Options: ${def.options.join(", ")}`,
49
+ );
50
+ }
51
+ return raw;
52
+ default:
53
+ return raw;
54
+ }
55
+ }
56
+
57
+ export async function resolveVariable(
58
+ def: VariableDefinition,
59
+ currentValues: Record<string, string | number | boolean>,
60
+ ): Promise<string> {
61
+ if (def.type !== "resolved" || !def.source) {
62
+ throw new Error("resolveVariable called on non-resolved variable");
63
+ }
64
+
65
+ // Render the source URL with current variable values
66
+ const sourceUrl = renderTemplate(def.source, currentValues);
67
+
68
+ const response = await fetch(sourceUrl);
69
+ if (!response.ok) {
70
+ throw new Error(`Failed to resolve variable from ${sourceUrl}: ${response.status}`);
71
+ }
72
+
73
+ const data = await response.json();
74
+
75
+ // Extract value using dot-bracket notation path
76
+ if (def.extract) {
77
+ return extractPath(data, def.extract);
78
+ }
79
+
80
+ return String(data);
81
+ }
82
+
83
+ function extractPath(obj: unknown, pathStr: string): string {
84
+ const parts = pathStr.match(/[^.[\]]+/g);
85
+ if (!parts) return String(obj);
86
+
87
+ let current: unknown = obj;
88
+ for (const part of parts) {
89
+ if (current === null || current === undefined) return "";
90
+ if (typeof current === "object") {
91
+ current = (current as Record<string, unknown>)[part];
92
+ }
93
+ }
94
+ return String(current ?? "");
95
+ }
96
+
97
+ export function getMissingRequiredVariables(
98
+ variableDefs: Record<string, VariableDefinition>,
99
+ provided: Record<string, string>,
100
+ ): Array<{ name: string; def: VariableDefinition }> {
101
+ const missing: Array<{ name: string; def: VariableDefinition }> = [];
102
+
103
+ for (const [name, def] of Object.entries(variableDefs)) {
104
+ if (def.required && provided[name] === undefined && def.default === undefined) {
105
+ missing.push({ name, def });
106
+ }
107
+ }
108
+
109
+ return missing;
110
+ }
@@ -0,0 +1,144 @@
1
+ // ── Package manifest (planmode.yaml) ──
2
+
3
+ export type PackageType = "prompt" | "rule" | "plan";
4
+ export type VariableType = "string" | "number" | "boolean" | "enum" | "resolved";
5
+ export type Category =
6
+ | "frontend"
7
+ | "backend"
8
+ | "devops"
9
+ | "database"
10
+ | "testing"
11
+ | "mobile"
12
+ | "ai-ml"
13
+ | "design"
14
+ | "security"
15
+ | "other";
16
+
17
+ export interface VariableDefinition {
18
+ description: string;
19
+ type: VariableType;
20
+ options?: string[];
21
+ required?: boolean;
22
+ default?: string | number | boolean;
23
+ resolver?: string;
24
+ source?: string;
25
+ extract?: string;
26
+ }
27
+
28
+ export interface PackageManifest {
29
+ name: string;
30
+ version: string;
31
+ type: PackageType;
32
+ description: string;
33
+ author: string;
34
+ license: string;
35
+ repository?: string;
36
+ models?: string[];
37
+ tags?: string[];
38
+ category?: Category;
39
+ dependencies?: {
40
+ rules?: string[];
41
+ plans?: string[];
42
+ };
43
+ variables?: Record<string, VariableDefinition>;
44
+ content?: string;
45
+ content_file?: string;
46
+ }
47
+
48
+ // ── Registry ──
49
+
50
+ export interface PackageSummary {
51
+ name: string;
52
+ version: string;
53
+ type: PackageType;
54
+ description: string;
55
+ author: string;
56
+ category: string;
57
+ tags: string[];
58
+ downloads: number;
59
+ created_at: string;
60
+ updated_at: string;
61
+ }
62
+
63
+ export interface RegistryIndex {
64
+ version: number;
65
+ updated_at: string;
66
+ packages: PackageSummary[];
67
+ }
68
+
69
+ export interface PackageMetadata {
70
+ name: string;
71
+ description: string;
72
+ author: string;
73
+ license: string;
74
+ repository: string;
75
+ category: string;
76
+ tags: string[];
77
+ type: PackageType;
78
+ models: string[];
79
+ latest_version: string;
80
+ versions: string[];
81
+ downloads: number;
82
+ created_at: string;
83
+ updated_at: string;
84
+ dependencies?: {
85
+ rules?: string[];
86
+ plans?: string[];
87
+ };
88
+ variables?: Record<string, VariableDefinition>;
89
+ }
90
+
91
+ export interface VersionMetadata {
92
+ version: string;
93
+ published_at: string;
94
+ source: {
95
+ repository: string;
96
+ tag: string;
97
+ sha: string;
98
+ };
99
+ files: string[];
100
+ content_hash: string;
101
+ }
102
+
103
+ // ── Lockfile ──
104
+
105
+ export interface LockfileEntry {
106
+ version: string;
107
+ type: PackageType;
108
+ source: string;
109
+ tag: string;
110
+ sha: string;
111
+ content_hash: string;
112
+ installed_to: string;
113
+ }
114
+
115
+ export interface Lockfile {
116
+ lockfile_version: number;
117
+ packages: Record<string, LockfileEntry>;
118
+ }
119
+
120
+ // ── Config ──
121
+
122
+ export interface PlanmodeConfig {
123
+ auth?: {
124
+ github_token?: string;
125
+ };
126
+ registries?: Record<string, string>;
127
+ cache?: {
128
+ dir?: string;
129
+ ttl?: number;
130
+ };
131
+ }
132
+
133
+ // ── Resolved package info ──
134
+
135
+ export interface ResolvedPackage {
136
+ name: string;
137
+ version: string;
138
+ source: string;
139
+ tag: string;
140
+ sha: string;
141
+ manifest: PackageManifest;
142
+ content: string;
143
+ contentHash: string;
144
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "forceConsistentCasingInFileNames": true,
10
+ "resolveJsonModule": true,
11
+ "declaration": true,
12
+ "declarationMap": true,
13
+ "outDir": "./dist",
14
+ "rootDir": "./src"
15
+ },
16
+ "include": ["src/**/*"],
17
+ "exclude": ["node_modules", "dist", "tests"]
18
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,14 @@
1
+ import { defineConfig } from "tsup";
2
+
3
+ export default defineConfig({
4
+ entry: ["src/index.ts"],
5
+ format: ["esm"],
6
+ target: "node20",
7
+ outDir: "dist",
8
+ clean: true,
9
+ dts: true,
10
+ splitting: false,
11
+ banner: {
12
+ js: "#!/usr/bin/env node",
13
+ },
14
+ });
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ include: ["tests/**/*.test.ts"],
7
+ },
8
+ });