@zaaxch/tailframe 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.
- package/assets/validate-architecture.mjs +279 -0
- package/bin/tailframe.mjs +83 -0
- package/package.json +21 -0
- package/src/architecture.mjs +259 -0
- package/src/conventions.mjs +188 -0
- package/src/exceptions.mjs +36 -0
- package/src/generate.mjs +353 -0
- package/src/new.mjs +754 -0
- package/src/validate.mjs +15 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { toPosix, walk } from "./architecture.mjs";
|
|
4
|
+
|
|
5
|
+
const PASCAL = /^[A-Z][A-Za-z0-9]*$/;
|
|
6
|
+
const CAMEL = /^[a-z][A-Za-z0-9]*$/;
|
|
7
|
+
const KEBAB = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
8
|
+
const ROLES = new Set(["routes", "schemas", "api", "store", "types", "documents"]);
|
|
9
|
+
const NOT_PLURAL = new Set(["status", "analysis"]);
|
|
10
|
+
const BRAND_PATTERN = /\bBrand\s*<|unique symbol/;
|
|
11
|
+
|
|
12
|
+
function parseName(fileName) {
|
|
13
|
+
if (fileName.endsWith(".d.ts")) return undefined;
|
|
14
|
+
const extension = path.extname(fileName);
|
|
15
|
+
let stem = fileName.slice(0, -extension.length);
|
|
16
|
+
let isTest = false;
|
|
17
|
+
for (const suffix of [".integration.test", ".test", ".spec"]) {
|
|
18
|
+
if (stem.endsWith(suffix)) {
|
|
19
|
+
stem = stem.slice(0, -suffix.length);
|
|
20
|
+
isTest = true;
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (!stem.includes(".")) return { stem, isTest, extension, subject: undefined, role: undefined };
|
|
25
|
+
const parts = stem.split(".");
|
|
26
|
+
return { stem, isTest, extension, subject: parts.slice(0, -1).join("."), role: parts.at(-1) };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function looksPlural(name) {
|
|
30
|
+
const last = name.split("-").at(-1);
|
|
31
|
+
return last.endsWith("s") && !last.endsWith("ss") && !NOT_PLURAL.has(last);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function validateConventions(rootArgument, kind) {
|
|
35
|
+
const root = path.resolve(rootArgument);
|
|
36
|
+
const src = path.join(root, "src");
|
|
37
|
+
if (!fs.existsSync(src)) return [];
|
|
38
|
+
const violations = [];
|
|
39
|
+
const seenModules = new Set();
|
|
40
|
+
const flag = (rule, relative, message) => violations.push({ rule, path: relative, message });
|
|
41
|
+
|
|
42
|
+
for (const absolute of walk(src)) {
|
|
43
|
+
const relative = toPosix(path.relative(root, absolute));
|
|
44
|
+
const name = parseName(path.basename(relative));
|
|
45
|
+
if (!name) continue;
|
|
46
|
+
const moduleMatch = relative.match(/^src\/modules\/([^/]+)\/(.*)$/);
|
|
47
|
+
|
|
48
|
+
if (name.role !== undefined && !ROLES.has(name.role)) {
|
|
49
|
+
flag("G2", relative, `${relative} uses role suffix ".${name.role}"; the only role suffixes are ${[...ROLES].join(", ")}`);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (name.role !== undefined && !KEBAB.test(name.subject)) {
|
|
53
|
+
flag("G2", relative, `${relative} role-suffix subject must be lowercase kebab-case`);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!moduleMatch) {
|
|
58
|
+
validateShared(relative, name, flag);
|
|
59
|
+
if ((relative.startsWith("src/core/") && name.extension === ".ts") && BRAND_PATTERN.test(fs.readFileSync(absolute, "utf8"))) {
|
|
60
|
+
flag("S5", relative, `${relative} defines identifier branding in core; branded IDs and the Brand helper belong in the owning module's domain/identifiers.ts`);
|
|
61
|
+
}
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const [, moduleName, remainder] = moduleMatch;
|
|
66
|
+
const modulePath = `src/modules/${moduleName}`;
|
|
67
|
+
if (!seenModules.has(moduleName)) {
|
|
68
|
+
seenModules.add(moduleName);
|
|
69
|
+
if (!KEBAB.test(moduleName)) flag("G4", modulePath, `module "${moduleName}" must be lowercase kebab-case`);
|
|
70
|
+
else if (looksPlural(moduleName)) flag("G4", modulePath, `module "${moduleName}" looks plural; module names are singular unless the project root AGENTS.md records the exception`);
|
|
71
|
+
}
|
|
72
|
+
if (name.stem === "index") {
|
|
73
|
+
flag("G3", relative, `${relative} is a barrel; import the named file instead`);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (name.isTest && remainder !== `__tests__/${path.basename(relative)}`) {
|
|
77
|
+
flag(kind === "service" ? "S7" : "U4", relative, `${relative} is a test outside the module-root __tests__ directory`);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (!name.isTest && remainder.startsWith("__tests__/")) {
|
|
81
|
+
flag(kind === "service" ? "S7" : "U4", relative, `${relative} is a non-test file inside __tests__`);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (name.isTest) {
|
|
85
|
+
if (!PASCAL.test(name.stem) && !CAMEL.test(name.stem)) flag("G1", relative, `${relative} test subject must be PascalCase or camelCase`);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const layer = remainder.split("/")[0];
|
|
90
|
+
if (kind === "service") validateServiceFile(relative, moduleName, layer, remainder, name, flag);
|
|
91
|
+
else validateUiFile(relative, moduleName, layer, name, flag);
|
|
92
|
+
|
|
93
|
+
if (kind === "service" && name.extension === ".ts" && relative !== `${modulePath}/domain/identifiers.ts` &&
|
|
94
|
+
BRAND_PATTERN.test(fs.readFileSync(absolute, "utf8"))) {
|
|
95
|
+
flag("S5", relative, `${relative} declares identifier branding outside domain/identifiers.ts; each module keeps its brands, Brand helper, and boundary helpers in one domain/identifiers.ts`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return violations;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function validateShared(relative, name, flag) {
|
|
102
|
+
if (name.stem === "index") return;
|
|
103
|
+
const directory = path.posix.basename(path.posix.dirname(relative));
|
|
104
|
+
if (name.role !== undefined) {
|
|
105
|
+
const allowedHere = (name.role === "store" && directory === "stores") || (name.role === "routes" && directory === "http");
|
|
106
|
+
if (!allowedHere) flag("G2", relative, `${relative} role suffix ".${name.role}" is not valid in this directory`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (name.extension === ".vue") {
|
|
110
|
+
if (!PASCAL.test(name.stem)) flag("G1", relative, `${relative} Vue components are PascalCase`);
|
|
111
|
+
else if (directory === "views" && !name.stem.endsWith("View")) flag("U2", relative, `${relative} views are named <Name>View.vue`);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (!PASCAL.test(name.stem) && !CAMEL.test(name.stem)) {
|
|
115
|
+
flag("G1", relative, `${relative} must be named after its primary export in that export's casing (PascalCase or camelCase)`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function validateServiceFile(relative, moduleName, layer, remainder, name, flag) {
|
|
120
|
+
if (layer === "domain") {
|
|
121
|
+
if (name.role !== undefined) flag("G2", relative, `${relative} role-suffix files do not belong in domain`);
|
|
122
|
+
else if (!PASCAL.test(name.stem) && !CAMEL.test(name.stem)) flag("G1", relative, `${relative} domain files are PascalCase types or camelCase functions`);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (layer === "use-cases") {
|
|
126
|
+
if (remainder.split("/")[1] === "ports") {
|
|
127
|
+
if (name.role !== undefined || !PASCAL.test(name.stem)) flag("S2", relative, `${relative} ports are one PascalCase interface per file, named after the interface`);
|
|
128
|
+
else if (/(?:Ports|Repositories)$/.test(name.stem)) flag("G5", relative, `${relative} aggregates ports; define one port per file`);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (/^validatedidentifiers$/i.test(name.stem)) flag("S5", relative, `${relative} identifier helpers belong in domain/identifiers.ts`);
|
|
132
|
+
else if (name.role !== undefined || !PASCAL.test(name.stem)) flag("S1", relative, `${relative} use cases are one PascalCase verb phrase per file`);
|
|
133
|
+
else if (/UseCases$/.test(name.stem)) flag("G5", relative, `${relative} aggregates use cases; define one operation per file`);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (layer === "persistence") {
|
|
137
|
+
if (name.role !== undefined) {
|
|
138
|
+
if (name.role !== "documents" || name.subject !== moduleName) {
|
|
139
|
+
flag("S3", relative, `${relative} the only persistence role file is ${moduleName}.documents.ts`);
|
|
140
|
+
}
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (!PASCAL.test(name.stem) && !CAMEL.test(name.stem)) flag("G1", relative, `${relative} persistence adapters are PascalCase classes; helpers are camelCase`);
|
|
144
|
+
else if (/Repositories$/.test(name.stem)) flag("G5", relative, `${relative} aggregates adapters; define one adapter per file`);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (layer === "http") {
|
|
148
|
+
if (name.role === undefined || !["routes", "schemas"].includes(name.role) || name.subject !== moduleName) {
|
|
149
|
+
flag("S6", relative, `${relative} http files are ${moduleName}.routes.ts and ${moduleName}.schemas.ts`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function validateUiFile(relative, moduleName, layer, name, flag) {
|
|
155
|
+
if (layer === "api") {
|
|
156
|
+
if (name.role !== "api" || name.subject !== moduleName) flag("U1", relative, `${relative} API clients are named ${moduleName}.api.ts`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (layer === "routes") {
|
|
160
|
+
if (name.extension === ".vue") flag("U2", relative, `${relative} .vue files do not belong under routes/; module shells belong in components/ or views/`);
|
|
161
|
+
else if (name.role !== "routes" || name.subject !== moduleName) flag("U2", relative, `${relative} route definitions are named ${moduleName}.routes.ts`);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (layer === "views") {
|
|
165
|
+
if (name.extension === ".vue" && (!PASCAL.test(name.stem) || !name.stem.endsWith("View"))) flag("U2", relative, `${relative} views are named <Name>View.vue`);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (layer === "components") {
|
|
169
|
+
if (name.extension === ".vue" && !PASCAL.test(name.stem)) flag("U2", relative, `${relative} components are PascalCase .vue files`);
|
|
170
|
+
else if (name.extension !== ".vue" && !PASCAL.test(name.stem) && !CAMEL.test(name.stem)) flag("G1", relative, `${relative} must follow primary-export casing`);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (layer === "composables") {
|
|
174
|
+
if (name.role !== undefined || !/^use[A-Z][A-Za-z0-9]*$/.test(name.stem)) flag("U3", relative, `${relative} composables are named use<Name>.ts`);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (layer === "stores") {
|
|
178
|
+
if (name.role !== "store") flag("U3", relative, `${relative} stores are named <name>.store.ts`);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (layer === "types") {
|
|
182
|
+
if (name.role !== undefined) {
|
|
183
|
+
if (name.role !== "types" || name.subject !== moduleName) flag("U3", relative, `${relative} the module types role file is ${moduleName}.types.ts`);
|
|
184
|
+
} else if (!PASCAL.test(name.stem) && !CAMEL.test(name.stem)) {
|
|
185
|
+
flag("G1", relative, `${relative} must follow primary-export casing`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
function parseExceptionSection(markdown) {
|
|
5
|
+
const lines = markdown.split(/\r?\n/);
|
|
6
|
+
const start = lines.findIndex((line) => /^##\s+Tailframe exceptions\s*$/.test(line));
|
|
7
|
+
if (start === -1) return [];
|
|
8
|
+
const covered = [];
|
|
9
|
+
for (const line of lines.slice(start + 1)) {
|
|
10
|
+
if (/^##\s/.test(line)) break;
|
|
11
|
+
const match = line.match(/^-\s+`([^`]+)`/);
|
|
12
|
+
if (match) covered.push(match[1].replace(/\/+$/, ""));
|
|
13
|
+
}
|
|
14
|
+
return covered;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function loadExceptions(rootArgument) {
|
|
18
|
+
const root = path.resolve(rootArgument);
|
|
19
|
+
const repositoryName = path.basename(root);
|
|
20
|
+
const covered = [];
|
|
21
|
+
const own = path.join(root, "AGENTS.md");
|
|
22
|
+
if (fs.existsSync(own)) covered.push(...parseExceptionSection(fs.readFileSync(own, "utf8")));
|
|
23
|
+
const project = path.join(path.dirname(root), "AGENTS.md");
|
|
24
|
+
if (fs.existsSync(project)) {
|
|
25
|
+
for (const entry of parseExceptionSection(fs.readFileSync(project, "utf8"))) {
|
|
26
|
+
if (entry === repositoryName || entry.startsWith(`${repositoryName}/`)) {
|
|
27
|
+
covered.push(entry.slice(repositoryName.length + 1) || ".");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return covered;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isExcepted(relative, exceptions) {
|
|
35
|
+
return exceptions.some((covered) => covered === "." || relative === covered || relative.startsWith(`${covered}/`));
|
|
36
|
+
}
|
package/src/generate.mjs
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { loadExceptions, isExcepted } from "./exceptions.mjs";
|
|
4
|
+
|
|
5
|
+
const KEBAB = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
6
|
+
const PASCAL = /^[A-Z][A-Za-z0-9]*$/;
|
|
7
|
+
const NOT_PLURAL = new Set(["status", "analysis"]);
|
|
8
|
+
|
|
9
|
+
export class GenerateError extends Error {}
|
|
10
|
+
|
|
11
|
+
const fail = (message) => { throw new GenerateError(message); };
|
|
12
|
+
const pascal = (kebab) => kebab.split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join("");
|
|
13
|
+
const camel = (kebab) => { const value = pascal(kebab); return value[0].toLowerCase() + value.slice(1); };
|
|
14
|
+
|
|
15
|
+
function detectKind(root) {
|
|
16
|
+
const service = fs.existsSync(path.join(root, "src/server.ts"));
|
|
17
|
+
const ui = fs.existsSync(path.join(root, "src/main.ts"));
|
|
18
|
+
if (service && ui) fail("Repository has both src/server.ts and src/main.ts; cannot determine kind");
|
|
19
|
+
if (!service && !ui) fail("Run tailframe generate from a Tailframe repository root (src/server.ts or src/main.ts not found)");
|
|
20
|
+
return service ? "service" : "ui";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function requireModuleName(root, name) {
|
|
24
|
+
if (!name || !KEBAB.test(name)) fail(`Module name must be lowercase kebab-case, received "${name ?? ""}"`);
|
|
25
|
+
const last = name.split("-").at(-1);
|
|
26
|
+
if (last.endsWith("s") && !last.endsWith("ss") && !NOT_PLURAL.has(last) &&
|
|
27
|
+
!isExcepted(`src/modules/${name}`, loadExceptions(root))) {
|
|
28
|
+
fail(`Module "${name}" looks plural; module names are singular (G4). If the capability is inherently a plurality, record the exception in the project root AGENTS.md under "## Tailframe exceptions" and rerun.`);
|
|
29
|
+
}
|
|
30
|
+
return name;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function requirePascal(value, label, forbiddenSuffixes = []) {
|
|
34
|
+
if (!value || !PASCAL.test(value)) fail(`${label} must be PascalCase, received "${value ?? ""}"`);
|
|
35
|
+
for (const suffix of forbiddenSuffixes) {
|
|
36
|
+
if (value.endsWith(suffix) && value !== suffix) fail(`${label} "${value}" is an aggregate name; one concept per file (G5)`);
|
|
37
|
+
}
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function operationName(moduleName, verbNoun) {
|
|
42
|
+
const moduleSuffix = pascal(moduleName);
|
|
43
|
+
if (verbNoun.endsWith(moduleSuffix) && verbNoun !== moduleSuffix) {
|
|
44
|
+
const prefix = verbNoun.slice(0, -moduleSuffix.length);
|
|
45
|
+
return prefix[0].toLowerCase() + prefix.slice(1);
|
|
46
|
+
}
|
|
47
|
+
return verbNoun[0].toLowerCase() + verbNoun.slice(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function plan(files) {
|
|
51
|
+
return {
|
|
52
|
+
files,
|
|
53
|
+
write(root) {
|
|
54
|
+
const existing = Object.keys(files).filter((relative) => fs.existsSync(path.join(root, relative)));
|
|
55
|
+
if (existing.length) fail(`Refusing to overwrite existing file(s):\n${existing.map((file) => ` ${file}`).join("\n")}`);
|
|
56
|
+
for (const [relative, content] of Object.entries(files)) {
|
|
57
|
+
const absolute = path.join(root, relative);
|
|
58
|
+
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
|
59
|
+
fs.writeFileSync(absolute, content);
|
|
60
|
+
}
|
|
61
|
+
return Object.keys(files);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function useCaseFiles(moduleName, verbNoun) {
|
|
67
|
+
return {
|
|
68
|
+
[`src/modules/${moduleName}/use-cases/${verbNoun}.ts`]:
|
|
69
|
+
`import type { UseCase } from "@/core/UseCase";
|
|
70
|
+
import type { RequestContext } from "@/core/RequestContext";
|
|
71
|
+
|
|
72
|
+
export interface ${verbNoun}Input {}
|
|
73
|
+
|
|
74
|
+
export interface ${verbNoun}Output {}
|
|
75
|
+
|
|
76
|
+
export class ${verbNoun} implements UseCase<${verbNoun}Input, ${verbNoun}Output> {
|
|
77
|
+
async execute(_context: RequestContext, _input: ${verbNoun}Input): Promise<${verbNoun}Output> {
|
|
78
|
+
throw new Error("${verbNoun} is not implemented");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
`,
|
|
82
|
+
[`src/modules/${moduleName}/__tests__/${verbNoun}.test.ts`]:
|
|
83
|
+
`import { ${verbNoun} } from "@/modules/${moduleName}/use-cases/${verbNoun}";
|
|
84
|
+
|
|
85
|
+
describe("${verbNoun}", () => {
|
|
86
|
+
it("is not implemented yet", async () => {
|
|
87
|
+
await expect(new ${verbNoun}().execute({ requestId: "test", roles: [] }, {})).rejects.toThrow();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
`
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function httpFiles(moduleName, verbNoun) {
|
|
95
|
+
const operation = operationName(moduleName, verbNoun);
|
|
96
|
+
const dependency = verbNoun[0].toLowerCase() + verbNoun.slice(1);
|
|
97
|
+
return {
|
|
98
|
+
[`src/modules/${moduleName}/http/${moduleName}.schemas.ts`]:
|
|
99
|
+
`import Joi from "joi";
|
|
100
|
+
|
|
101
|
+
export const ${verbNoun}Schema = Joi.object({}).unknown(false);
|
|
102
|
+
`,
|
|
103
|
+
[`src/modules/${moduleName}/http/${moduleName}.routes.ts`]:
|
|
104
|
+
`import { Router } from "express";
|
|
105
|
+
import type { ${verbNoun} } from "@/modules/${moduleName}/use-cases/${verbNoun}";
|
|
106
|
+
import { ${verbNoun}Schema } from "@/modules/${moduleName}/http/${moduleName}.schemas";
|
|
107
|
+
import { createRequestContext } from "@/platform/http/createRequestContext";
|
|
108
|
+
import { rpcResult } from "@/platform/http/rpc";
|
|
109
|
+
|
|
110
|
+
export function ${camel(moduleName)}Routes(${dependency}: ${verbNoun}) {
|
|
111
|
+
const router = Router();
|
|
112
|
+
router.post("/${moduleName}.${operation}", async (req, res, next) => {
|
|
113
|
+
try {
|
|
114
|
+
const input = await ${verbNoun}Schema.validateAsync(req.body ?? {});
|
|
115
|
+
rpcResult(res, await ${dependency}.execute(await createRequestContext(req), input));
|
|
116
|
+
} catch (error) {
|
|
117
|
+
next(error);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
return router;
|
|
121
|
+
}
|
|
122
|
+
`
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const serviceSchematics = {
|
|
127
|
+
module(root, [name, verbNoun], options) {
|
|
128
|
+
requireModuleName(root, name);
|
|
129
|
+
requirePascal(verbNoun, "First use case", ["UseCases"]);
|
|
130
|
+
const files = { ...useCaseFiles(name, verbNoun), ...(options.has("--no-http") ? {} : httpFiles(name, verbNoun)) };
|
|
131
|
+
return [plan(files), [
|
|
132
|
+
`Register ${verbNoun} in src/app/container.ts`,
|
|
133
|
+
...(options.has("--no-http") ? [] : [`Mount ${camel(name)}Routes in src/app/routes.ts`]),
|
|
134
|
+
`Implement ${verbNoun}.execute and replace the placeholder test`
|
|
135
|
+
]];
|
|
136
|
+
},
|
|
137
|
+
"use-case"(root, [moduleName, verbNoun]) {
|
|
138
|
+
requireModuleName(root, moduleName);
|
|
139
|
+
requirePascal(verbNoun, "Use case", ["UseCases"]);
|
|
140
|
+
return [plan(useCaseFiles(moduleName, verbNoun)), [
|
|
141
|
+
`Register ${verbNoun} in src/app/container.ts`,
|
|
142
|
+
`Expose it from an entry point (http route, worker, job, or cli) if the capability needs one`,
|
|
143
|
+
`Implement ${verbNoun}.execute and replace the placeholder test`
|
|
144
|
+
]];
|
|
145
|
+
},
|
|
146
|
+
http(root, [moduleName, verbNoun]) {
|
|
147
|
+
requireModuleName(root, moduleName);
|
|
148
|
+
requirePascal(verbNoun, "Use case", ["UseCases"]);
|
|
149
|
+
if (!fs.existsSync(path.join(root, `src/modules/${moduleName}/use-cases/${verbNoun}.ts`)))
|
|
150
|
+
fail(`Use case src/modules/${moduleName}/use-cases/${verbNoun}.ts does not exist; generate it first`);
|
|
151
|
+
return [plan(httpFiles(moduleName, verbNoun)), [
|
|
152
|
+
`Mount ${camel(moduleName)}Routes in src/app/routes.ts`,
|
|
153
|
+
`Define the real request schema in ${moduleName}.schemas.ts`
|
|
154
|
+
]];
|
|
155
|
+
},
|
|
156
|
+
port(root, [moduleName, portName]) {
|
|
157
|
+
requireModuleName(root, moduleName);
|
|
158
|
+
requirePascal(portName, "Port", ["Ports", "Repositories"]);
|
|
159
|
+
return [plan({
|
|
160
|
+
[`src/modules/${moduleName}/use-cases/ports/${portName}.ts`]:
|
|
161
|
+
`export interface ${portName} {}
|
|
162
|
+
`
|
|
163
|
+
}), [
|
|
164
|
+
`Declare the port's methods with domain-owned types`,
|
|
165
|
+
`Implement it: tailframe generate adapter ${moduleName} ${portName} --db mongo`,
|
|
166
|
+
`Bind the implementation in src/app/container.ts`
|
|
167
|
+
]];
|
|
168
|
+
},
|
|
169
|
+
adapter(root, [moduleName, portName], options) {
|
|
170
|
+
requireModuleName(root, moduleName);
|
|
171
|
+
requirePascal(portName, "Port", ["Ports", "Repositories"]);
|
|
172
|
+
const technology = options.get("--db") ?? "mongo";
|
|
173
|
+
if (!/^[a-z][a-z0-9]*$/.test(technology)) fail(`--db must be a lowercase technology name, received "${technology}"`);
|
|
174
|
+
if (!fs.existsSync(path.join(root, `src/modules/${moduleName}/use-cases/ports/${portName}.ts`)))
|
|
175
|
+
fail(`Port src/modules/${moduleName}/use-cases/ports/${portName}.ts does not exist; generate it first`);
|
|
176
|
+
const className = `${technology[0].toUpperCase()}${technology.slice(1)}${portName}`;
|
|
177
|
+
return [plan({
|
|
178
|
+
[`src/modules/${moduleName}/persistence/${className}.ts`]:
|
|
179
|
+
`import type { ${portName} } from "@/modules/${moduleName}/use-cases/ports/${portName}";
|
|
180
|
+
|
|
181
|
+
export class ${className} implements ${portName} {}
|
|
182
|
+
`
|
|
183
|
+
}), [
|
|
184
|
+
`Implement the port using platform ${technology} mechanisms; keep driver types (ObjectId, rows, documents) inside this file`,
|
|
185
|
+
`Bind ${className} to ${portName} in src/app/container.ts`
|
|
186
|
+
]];
|
|
187
|
+
},
|
|
188
|
+
identifiers(root, [moduleName, ...idNames]) {
|
|
189
|
+
requireModuleName(root, moduleName);
|
|
190
|
+
if (!idNames.length) fail("Provide at least one identifier name, e.g. tailframe generate identifiers project ProjectId");
|
|
191
|
+
for (const idName of idNames) {
|
|
192
|
+
if (!/^[A-Z][A-Za-z0-9]*Id$/.test(idName)) fail(`Identifier "${idName}" must be PascalCase and end with "Id"`);
|
|
193
|
+
}
|
|
194
|
+
const body = idNames.map((idName) => {
|
|
195
|
+
const value = idName[0].toLowerCase() + idName.slice(1);
|
|
196
|
+
return `export type ${idName} = Brand<string, "${idName}">;
|
|
197
|
+
|
|
198
|
+
export function hydrate${idName}(value: string): ${idName} {
|
|
199
|
+
return value as ${idName};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function ${value}Value(id: ${idName}): string {
|
|
203
|
+
return id;
|
|
204
|
+
}`;
|
|
205
|
+
}).join("\n\n");
|
|
206
|
+
return [plan({
|
|
207
|
+
[`src/modules/${moduleName}/domain/identifiers.ts`]:
|
|
208
|
+
`declare const identifierBrand: unique symbol;
|
|
209
|
+
|
|
210
|
+
type Brand<Value, Name extends string> = Value & { readonly [identifierBrand]: Name };
|
|
211
|
+
|
|
212
|
+
${body}
|
|
213
|
+
`
|
|
214
|
+
}), [
|
|
215
|
+
`Use the branded types in this module's domain, use cases, and ports`,
|
|
216
|
+
`Convert at boundaries only: hydrate after http validation or persistence reads, unwrap with the *Value helpers when serializing or querying`
|
|
217
|
+
]];
|
|
218
|
+
},
|
|
219
|
+
integration(root, [provider]) {
|
|
220
|
+
if (!provider || !KEBAB.test(provider)) fail(`Provider must be lowercase kebab-case, received "${provider ?? ""}"`);
|
|
221
|
+
const className = `${pascal(provider)}Client`;
|
|
222
|
+
return [plan({
|
|
223
|
+
[`src/platform/integrations/${provider}/${className}.ts`]:
|
|
224
|
+
`export class ${className} {}
|
|
225
|
+
`
|
|
226
|
+
}), [
|
|
227
|
+
`Define a port in the owning module (tailframe generate port <module> <Name>) and implement it with ${className}`,
|
|
228
|
+
`Register the integration in src/app/container.ts`
|
|
229
|
+
]];
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
function uiArtifacts(moduleName, kindOptions) {
|
|
234
|
+
const files = {};
|
|
235
|
+
if (kindOptions.api) {
|
|
236
|
+
files[`src/modules/${moduleName}/api/${moduleName}.api.ts`] =
|
|
237
|
+
`import { http } from "@/platform/http";
|
|
238
|
+
import type { RpcResponse } from "@/core/rpc";
|
|
239
|
+
|
|
240
|
+
export async function get${pascal(moduleName)}() {
|
|
241
|
+
return (await http.post<RpcResponse<unknown>>("${moduleName}.get")).data.result;
|
|
242
|
+
}
|
|
243
|
+
`;
|
|
244
|
+
}
|
|
245
|
+
if (kindOptions.view) {
|
|
246
|
+
const viewName = kindOptions.view.endsWith("View") ? kindOptions.view : `${kindOptions.view}View`;
|
|
247
|
+
files[`src/modules/${moduleName}/views/${viewName}.vue`] =
|
|
248
|
+
`<script setup lang="ts"></script>
|
|
249
|
+
|
|
250
|
+
<template>
|
|
251
|
+
<main>
|
|
252
|
+
<h1>${viewName}</h1>
|
|
253
|
+
</main>
|
|
254
|
+
</template>
|
|
255
|
+
`;
|
|
256
|
+
}
|
|
257
|
+
if (kindOptions.component) {
|
|
258
|
+
files[`src/modules/${moduleName}/components/${kindOptions.component}.vue`] =
|
|
259
|
+
`<script setup lang="ts"></script>
|
|
260
|
+
|
|
261
|
+
<template>
|
|
262
|
+
<div></div>
|
|
263
|
+
</template>
|
|
264
|
+
`;
|
|
265
|
+
}
|
|
266
|
+
if (kindOptions.store) {
|
|
267
|
+
files[`src/modules/${moduleName}/stores/${kindOptions.store}.store.ts`] =
|
|
268
|
+
`import { reactive } from "vue";
|
|
269
|
+
|
|
270
|
+
export const ${camel(kindOptions.store)}Store = reactive({});
|
|
271
|
+
`;
|
|
272
|
+
}
|
|
273
|
+
if (kindOptions.composable) {
|
|
274
|
+
files[`src/modules/${moduleName}/composables/use${pascal(kindOptions.composable)}.ts`] =
|
|
275
|
+
`export function use${pascal(kindOptions.composable)}() {
|
|
276
|
+
return {};
|
|
277
|
+
}
|
|
278
|
+
`;
|
|
279
|
+
}
|
|
280
|
+
return files;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const uiSchematics = {
|
|
284
|
+
module(root, [name], options) {
|
|
285
|
+
requireModuleName(root, name);
|
|
286
|
+
const artifacts = {
|
|
287
|
+
api: options.has("--api"),
|
|
288
|
+
view: options.get("--view"),
|
|
289
|
+
component: options.get("--component"),
|
|
290
|
+
store: options.get("--store"),
|
|
291
|
+
composable: options.get("--composable")
|
|
292
|
+
};
|
|
293
|
+
if (artifacts.view) requirePascal(artifacts.view, "View");
|
|
294
|
+
if (artifacts.component) requirePascal(artifacts.component, "Component");
|
|
295
|
+
if (artifacts.store && !KEBAB.test(artifacts.store)) fail(`Store name must be lowercase kebab-case, received "${artifacts.store}"`);
|
|
296
|
+
if (artifacts.composable && !KEBAB.test(artifacts.composable)) fail(`Composable name must be lowercase kebab-case, received "${artifacts.composable}"`);
|
|
297
|
+
const files = uiArtifacts(name, artifacts);
|
|
298
|
+
if (!Object.keys(files).length) fail("A UI module needs at least one artifact: pass --api, --view <Name>, --component <Name>, --store <name>, or --composable <name> (empty layers are forbidden)");
|
|
299
|
+
return [plan(files), [
|
|
300
|
+
...(artifacts.view ? [`Register the view in a route and mount it from src/app/router.ts`] : []),
|
|
301
|
+
...(artifacts.api ? [`Point ${name}.api.ts at the real RPC operation`] : []),
|
|
302
|
+
`Compose the module from src/app only; other modules must not import it`
|
|
303
|
+
]];
|
|
304
|
+
},
|
|
305
|
+
api(root, [moduleName]) {
|
|
306
|
+
requireModuleName(root, moduleName);
|
|
307
|
+
return [plan(uiArtifacts(moduleName, { api: true })), [`Point ${moduleName}.api.ts at the real RPC operation`]];
|
|
308
|
+
},
|
|
309
|
+
view(root, [moduleName, name]) {
|
|
310
|
+
requireModuleName(root, moduleName);
|
|
311
|
+
requirePascal(name, "View");
|
|
312
|
+
return [plan(uiArtifacts(moduleName, { view: name })), [`Register the view in a route and mount it from src/app/router.ts`]];
|
|
313
|
+
},
|
|
314
|
+
component(root, [moduleName, name]) {
|
|
315
|
+
requireModuleName(root, moduleName);
|
|
316
|
+
requirePascal(name, "Component");
|
|
317
|
+
return [plan(uiArtifacts(moduleName, { component: name })), [`Use the component from this module's views only`]];
|
|
318
|
+
},
|
|
319
|
+
store(root, [moduleName, name]) {
|
|
320
|
+
requireModuleName(root, moduleName);
|
|
321
|
+
if (!name || !KEBAB.test(name)) fail(`Store name must be lowercase kebab-case, received "${name ?? ""}"`);
|
|
322
|
+
return [plan(uiArtifacts(moduleName, { store: name })), [`Keep the store module-private; cross-module state belongs in src/app`]];
|
|
323
|
+
},
|
|
324
|
+
composable(root, [moduleName, name]) {
|
|
325
|
+
requireModuleName(root, moduleName);
|
|
326
|
+
if (!name || !KEBAB.test(name)) fail(`Composable name must be lowercase kebab-case, received "${name ?? ""}"`);
|
|
327
|
+
return [plan(uiArtifacts(moduleName, { composable: name })), [`Use the composable from this module only`]];
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
export function runGenerate(rootArgument, argv) {
|
|
332
|
+
const root = path.resolve(rootArgument);
|
|
333
|
+
const positionals = [];
|
|
334
|
+
const options = new Map();
|
|
335
|
+
const flags = new Set();
|
|
336
|
+
const args = [...argv];
|
|
337
|
+
while (args.length) {
|
|
338
|
+
const value = args.shift();
|
|
339
|
+
if (value === "--no-http" || value === "--api") flags.add(value);
|
|
340
|
+
else if (value.startsWith("--")) options.set(value, args.shift());
|
|
341
|
+
else positionals.push(value);
|
|
342
|
+
}
|
|
343
|
+
options.has = (key) => flags.has(key) || Map.prototype.has.call(options, key);
|
|
344
|
+
const schematic = positionals.shift();
|
|
345
|
+
const kind = detectKind(root);
|
|
346
|
+
const registry = kind === "service" ? serviceSchematics : uiSchematics;
|
|
347
|
+
if (!schematic || !registry[schematic]) {
|
|
348
|
+
fail(`Unknown ${kind} schematic "${schematic ?? ""}". Available: ${Object.keys(registry).join(", ")}`);
|
|
349
|
+
}
|
|
350
|
+
const [filePlan, checklist] = registry[schematic](root, positionals, options);
|
|
351
|
+
const created = filePlan.write(root);
|
|
352
|
+
return { kind, created, checklist: [...checklist, "Run npm run validate:architecture, then focused type checks and tests"] };
|
|
353
|
+
}
|