@zaaxch/tailframe 1.0.0 → 2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zaaxch/tailframe",
3
- "version": "1.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Tailframe architecture toolkit: validates the Tailframe structure, import-boundary, and file-convention contracts. The package version is the contract version.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -148,6 +148,10 @@ function validateStructure(root, kind, fail) {
148
148
  }
149
149
 
150
150
  function validateServiceImport(source, target, external, fail, graph) {
151
+ if (external === "tsyringe" && source !== "src/app/container.ts") {
152
+ fail(`${source} may not import tsyringe; dependency-injection framework code is confined to src/app/container.ts`);
153
+ return;
154
+ }
151
155
  if (source === "src/server.ts" || source === "src/worker.ts") {
152
156
  if (external) {
153
157
  if (external !== "reflect-metadata") fail(`${source} may import only reflect-metadata and app bootstrap code, not ${external}`);
@@ -15,6 +15,11 @@ const ROUTE_NAMES_CANONICAL_DECLARATION = /\bexport\s+enum\s+RouteNames\b/;
15
15
  const ROUTE_NAMES_VALUE = /^\s*[A-Z][A-Z0-9_]*\s*=\s*["']([^"']+)["']/gm;
16
16
  const ROUTE_NAME_REFERENCE = /\bname:\s*["']([^"']+)["']/g;
17
17
 
18
+ const camel = (kebab) => {
19
+ const value = kebab.split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join("");
20
+ return value[0].toLowerCase() + value.slice(1);
21
+ };
22
+
18
23
  function parseName(fileName) {
19
24
  if (fileName.endsWith(".d.ts")) return undefined;
20
25
  const extension = path.extname(fileName);
@@ -98,6 +103,9 @@ export function validateConventions(rootArgument, kind) {
98
103
  const layer = remainder.split("/")[0];
99
104
  if (kind === "service") validateServiceFile(relative, moduleName, layer, remainder, name, flag);
100
105
  else validateUiFile(relative, moduleName, layer, name, flag);
106
+ if (kind === "service" && name.extension === ".ts") {
107
+ validateServiceSource(relative, moduleName, layer, remainder, name, fs.readFileSync(absolute, "utf8"), flag);
108
+ }
101
109
 
102
110
  if (kind === "service" && name.extension === ".ts" && relative !== `${modulePath}/domain/identifiers.ts` &&
103
111
  BRAND_PATTERN.test(fs.readFileSync(absolute, "utf8"))) {
@@ -107,10 +115,86 @@ export function validateConventions(rootArgument, kind) {
107
115
  if (kind === "ui") {
108
116
  validateRouteNames(root, src, flag);
109
117
  validateCanonicalShell(root, flag);
118
+ } else {
119
+ validateServiceComposition(root, flag);
110
120
  }
111
121
  return violations;
112
122
  }
113
123
 
124
+ function validateServiceSource(relative, moduleName, layer, remainder, name, source, flag) {
125
+ if (layer === "use-cases" && remainder.split("/")[1] !== "ports") {
126
+ const exportedClasses = [...source.matchAll(/^export\s+class\s+([A-Z][A-Za-z0-9]*)\b/gm)].map((match) => match[1]);
127
+ if (exportedClasses.length > 1) {
128
+ flag("G5", relative, `${relative} exports multiple classes (${exportedClasses.join(", ")}); keep one named use-case class per file`);
129
+ } else if (exportedClasses.length === 1 && exportedClasses[0] !== name.stem) {
130
+ flag("G1", relative, `${relative} must be named ${exportedClasses[0]}.ts after its exported use-case class`);
131
+ }
132
+ }
133
+
134
+ if (layer !== "http") return;
135
+ if (name.role === "routes" && name.subject === moduleName) {
136
+ const factories = [...source.matchAll(/^export\s+function\s+([A-Za-z][A-Za-z0-9]*Routes)\s*\(/gm)].map((match) => match[1]);
137
+ const expected = `${camel(moduleName)}Routes`;
138
+ if (factories.length !== 1 || factories[0] !== expected) {
139
+ flag("S8", relative, `${relative} must export exactly one module route factory named ${expected}`);
140
+ }
141
+ const postCount = [...source.matchAll(/\brouter\.post\s*\(/g)].length;
142
+ const rpcHandlerCount = [...source.matchAll(/\brpcHandler\s*\(/g)].length;
143
+ if (postCount > rpcHandlerCount) {
144
+ flag("S8", relative, `${relative} must translate every RPC POST through rpcHandler; found ${postCount} POST routes and ${rpcHandlerCount} rpcHandler calls`);
145
+ }
146
+ }
147
+ if (name.role === "schemas" && name.subject === moduleName) {
148
+ const schemas = [...source.matchAll(/^export\s+const\s+([A-Za-z][A-Za-z0-9]*)\s*=/gm)].map((match) => match[1]);
149
+ if (schemas.length === 0) flag("S8", relative, `${relative} must export at least one PascalCase operation schema`);
150
+ for (const schema of schemas) {
151
+ if (!PASCAL.test(schema) || !schema.endsWith("Schema")) {
152
+ flag("S8", relative, `${relative} exports ${schema}; operation schemas are PascalCase and end with Schema`);
153
+ }
154
+ }
155
+ }
156
+ }
157
+
158
+ function validateServiceComposition(root, flag) {
159
+ const containerRelative = "src/app/container.ts";
160
+ const containerFile = path.join(root, containerRelative);
161
+ if (!fs.existsSync(containerFile)) {
162
+ flag("S9", containerRelative, `${containerRelative} is required as the explicit application composition root`);
163
+ } else {
164
+ const source = fs.readFileSync(containerFile, "utf8");
165
+ for (const [pattern, message] of [
166
+ [/\bexport\s+function\s+registerDependencies\s*\(/, "must export registerDependencies"],
167
+ [/\bcontainer\.reset\s*\(/, "must reset the container before registration"],
168
+ [/\bregisterInstance\s*\(/, "must register explicit instances"]
169
+ ]) {
170
+ if (!pattern.test(source)) flag("S9", containerRelative, `${containerRelative} ${message}`);
171
+ }
172
+ if (/\bregisterSingleton\s*\(|\binstanceCachingFactory\s*\(|@inject(?:able)?\b/.test(source)) {
173
+ flag("S9", containerRelative, `${containerRelative} must use explicit construction and instance registration, not decorators, singleton factories, or implicit resolution`);
174
+ }
175
+ }
176
+
177
+ const serverRelative = "src/app/server.ts";
178
+ const serverFile = path.join(root, serverRelative);
179
+ if (!fs.existsSync(serverFile)) {
180
+ flag("S9", serverRelative, `${serverRelative} is required as the HTTP application assembly`);
181
+ } else if (!/\bregisterDependencies\s*\(/.test(fs.readFileSync(serverFile, "utf8"))) {
182
+ flag("S9", serverRelative, `${serverRelative} must initialize the app-owned composition root after infrastructure connects`);
183
+ }
184
+
185
+ const workerRelative = "src/app/workers/startWorker.ts";
186
+ const workerFile = path.join(root, workerRelative);
187
+ if (fs.existsSync(workerFile)) {
188
+ const source = fs.readFileSync(workerFile, "utf8");
189
+ if (!/\bregisterDependencies\s*\(/.test(source)) {
190
+ flag("S9", workerRelative, `${workerRelative} must initialize the same app-owned composition root as the HTTP server`);
191
+ }
192
+ if (/\bprocess\.exit\s*\(/.test(source)) {
193
+ flag("S9", workerRelative, `${workerRelative} must return an awaitable shutdown path instead of forcing process.exit`);
194
+ }
195
+ }
196
+ }
197
+
114
198
  function normalizeGeneratedSource(source) {
115
199
  return source.replace(/\r\n/g, "\n");
116
200
  }
package/src/generate.mjs CHANGED
@@ -1,6 +1,8 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { loadExceptions, isExcepted } from "./exceptions.mjs";
4
+ import { serviceHttpFiles, serviceOperationName } from "./service-templates.mjs";
5
+ import { moduleApiSource } from "./ui-templates.mjs";
4
6
 
5
7
  const KEBAB = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
6
8
  const PASCAL = /^[A-Z][A-Za-z0-9]*$/;
@@ -52,31 +54,124 @@ function requirePrimary(value, label) {
52
54
  return value;
53
55
  }
54
56
 
55
- function operationName(moduleName, verbNoun) {
56
- const moduleSuffix = pascal(moduleName);
57
- if (verbNoun.endsWith(moduleSuffix) && verbNoun !== moduleSuffix) {
58
- const prefix = verbNoun.slice(0, -moduleSuffix.length);
59
- return prefix[0].toLowerCase() + prefix.slice(1);
57
+ function edit(relative, update) {
58
+ return { relative, update };
59
+ }
60
+
61
+ function insertBefore(source, anchor, content, relative) {
62
+ const index = source.indexOf(anchor);
63
+ if (index < 0) fail(`Cannot update ${relative}: canonical insertion point was not found; normalize the file before generating`);
64
+ if (source.indexOf(anchor, index + anchor.length) >= 0) {
65
+ fail(`Cannot update ${relative}: canonical insertion point is ambiguous; normalize the file before generating`);
60
66
  }
61
- return verbNoun[0].toLowerCase() + verbNoun.slice(1);
67
+ return `${source.slice(0, index)}${content}${source.slice(index)}`;
62
68
  }
63
69
 
64
- function plan(files) {
70
+ function plan(files, edits = []) {
65
71
  return {
66
72
  files,
67
73
  write(root) {
68
74
  const existing = Object.keys(files).filter((relative) => fs.existsSync(path.join(root, relative)));
69
75
  if (existing.length) fail(`Refusing to overwrite existing file(s):\n${existing.map((file) => ` ${file}`).join("\n")}`);
76
+ const updates = edits.map(({ relative, update }) => {
77
+ const absolute = path.join(root, relative);
78
+ if (!fs.existsSync(absolute)) fail(`Cannot update missing generated composition file: ${relative}`);
79
+ return { relative, absolute, content: update(fs.readFileSync(absolute, "utf8")) };
80
+ });
70
81
  for (const [relative, content] of Object.entries(files)) {
71
82
  const absolute = path.join(root, relative);
72
83
  fs.mkdirSync(path.dirname(absolute), { recursive: true });
73
84
  fs.writeFileSync(absolute, content);
74
85
  }
75
- return Object.keys(files);
86
+ for (const update of updates) fs.writeFileSync(update.absolute, update.content);
87
+ return [...Object.keys(files), ...updates.map(({ relative }) => relative)];
76
88
  }
77
89
  };
78
90
  }
79
91
 
92
+ function registerUseCaseEdit(moduleName, verbNoun) {
93
+ const relative = "src/app/container.ts";
94
+ return edit(relative, (source) => {
95
+ if (new RegExp(`\\b${verbNoun}\\b`).test(source)) fail(`${relative} already references ${verbNoun}`);
96
+ let next = insertBefore(
97
+ source,
98
+ "\nconst register =",
99
+ `import { ${verbNoun} } from "@/modules/${moduleName}/use-cases/${verbNoun}";\n`,
100
+ relative
101
+ );
102
+ next = insertBefore(next, "\n\treturn container;", `\n\tregister(${verbNoun}, new ${verbNoun}());\n`, relative);
103
+ return next;
104
+ });
105
+ }
106
+
107
+ function mountModuleRoutesEdit(moduleName, verbNoun) {
108
+ const relative = "src/app/routes.ts";
109
+ const dependency = serviceOperationName(moduleName, verbNoun);
110
+ const factory = `${camel(moduleName)}Routes`;
111
+ return edit(relative, (source) => {
112
+ if (new RegExp(`\\b${factory}\\b`).test(source)) fail(`${relative} already mounts ${factory}`);
113
+ let next = insertBefore(
114
+ source,
115
+ "\nexport function applicationRoutes",
116
+ `import { ${verbNoun} } from "@/modules/${moduleName}/use-cases/${verbNoun}";\nimport { ${factory} } from "@/modules/${moduleName}/http/${moduleName}.routes";\n`,
117
+ relative
118
+ );
119
+ next = insertBefore(
120
+ next,
121
+ "\n\treturn router;",
122
+ `\n\trouter.use(\n\t\t${factory}({\n\t\t\t${dependency}: container.resolve(${verbNoun})\n\t\t})\n\t);\n`,
123
+ relative
124
+ );
125
+ return next;
126
+ });
127
+ }
128
+
129
+ function extendModuleRoutesEdits(moduleName, verbNoun) {
130
+ const operation = serviceOperationName(moduleName, verbNoun);
131
+ const dependency = operation;
132
+ const schemasRelative = `src/modules/${moduleName}/http/${moduleName}.schemas.ts`;
133
+ const routesRelative = `src/modules/${moduleName}/http/${moduleName}.routes.ts`;
134
+ const appRoutesRelative = "src/app/routes.ts";
135
+ return [
136
+ edit(schemasRelative, (source) => {
137
+ if (new RegExp(`\\b${verbNoun}Schema\\b`).test(source)) fail(`${schemasRelative} already defines ${verbNoun}Schema`);
138
+ return `${source.trimEnd()}\n\nexport const ${verbNoun}Schema = Joi.object({}).unknown(false);\n`;
139
+ }),
140
+ edit(routesRelative, (source) => {
141
+ if (new RegExp(`\\b${verbNoun}\\b`).test(source)) fail(`${routesRelative} already references ${verbNoun}`);
142
+ let next = insertBefore(
143
+ source,
144
+ `import { rpcHandler } from "@/platform/http/rpcHandler";`,
145
+ `import type { ${verbNoun} } from "@/modules/${moduleName}/use-cases/${verbNoun}";\nimport { ${verbNoun}Schema } from "@/modules/${moduleName}/http/${moduleName}.schemas";\n`,
146
+ routesRelative
147
+ );
148
+ next = insertBefore(next, "\n}\n\nexport function", `\n\t${dependency}: ${verbNoun};`, routesRelative);
149
+ next = insertBefore(
150
+ next,
151
+ "\n\treturn router;",
152
+ `\n\trouter.post("/${moduleName}.${operation}", rpcHandler(${verbNoun}Schema, useCases.${dependency}));`,
153
+ routesRelative
154
+ );
155
+ return next;
156
+ }),
157
+ edit(appRoutesRelative, (source) => {
158
+ if (new RegExp(`\\b${verbNoun}\\b`).test(source)) fail(`${appRoutesRelative} already references ${verbNoun}`);
159
+ let next = insertBefore(
160
+ source,
161
+ `import { ${camel(moduleName)}Routes } from "@/modules/${moduleName}/http/${moduleName}.routes";`,
162
+ `import { ${verbNoun} } from "@/modules/${moduleName}/use-cases/${verbNoun}";\n`,
163
+ appRoutesRelative
164
+ );
165
+ const start = next.indexOf(`\t\t${camel(moduleName)}Routes({`);
166
+ if (start < 0) fail(`Cannot update ${appRoutesRelative}: ${camel(moduleName)}Routes mount was not found`);
167
+ const end = next.indexOf("\n\t\t})", start);
168
+ if (end < 0) fail(`Cannot update ${appRoutesRelative}: ${camel(moduleName)}Routes mount is not canonical`);
169
+ const before = next.slice(0, end).replace(/\n\t\t\t([^\n]+)$/, "\n\t\t\t$1,");
170
+ return `${before}\n\t\t\t${dependency}: container.resolve(${verbNoun})${next.slice(end)}`;
171
+ })
172
+ ];
173
+ }
174
+
80
175
  function useCaseFiles(moduleName, verbNoun) {
81
176
  return {
82
177
  [`src/modules/${moduleName}/use-cases/${verbNoun}.ts`]:
@@ -98,61 +193,27 @@ export class ${verbNoun} implements UseCase<${verbNoun}Input, ${verbNoun}Output>
98
193
 
99
194
  describe("${verbNoun}", () => {
100
195
  it("is not implemented yet", async () => {
101
- await expect(new ${verbNoun}().execute({ requestId: "test", roles: [] }, {})).rejects.toThrow();
196
+ await expect(new ${verbNoun}().execute({ requestId: "test" }, {})).rejects.toThrow();
102
197
  });
103
198
  });
104
199
  `
105
200
  };
106
201
  }
107
202
 
108
- function httpFiles(moduleName, verbNoun) {
109
- const operation = operationName(moduleName, verbNoun);
110
- const dependency = verbNoun[0].toLowerCase() + verbNoun.slice(1);
111
- return {
112
- [`src/modules/${moduleName}/http/${moduleName}.schemas.ts`]:
113
- `import Joi from "joi";
114
-
115
- export const ${verbNoun}Schema = Joi.object({}).unknown(false);
116
- `,
117
- [`src/modules/${moduleName}/http/${moduleName}.routes.ts`]:
118
- `import { Router } from "express";
119
- import type { ${verbNoun} } from "@/modules/${moduleName}/use-cases/${verbNoun}";
120
- import { ${verbNoun}Schema } from "@/modules/${moduleName}/http/${moduleName}.schemas";
121
- import { createRequestContext } from "@/platform/http/createRequestContext";
122
- import { rpcResult } from "@/platform/http/rpc";
123
-
124
- export function ${camel(moduleName)}Routes(${dependency}: ${verbNoun}) {
125
- const router = Router();
126
- router.post("/${moduleName}.${operation}", async (req, res, next) => {
127
- try {
128
- const input = await ${verbNoun}Schema.validateAsync(req.body ?? {});
129
- rpcResult(res, await ${dependency}.execute(await createRequestContext(req), input));
130
- } catch (error) {
131
- next(error);
132
- }
133
- });
134
- return router;
135
- }
136
- `
137
- };
138
- }
139
-
140
203
  const serviceSchematics = {
141
204
  module(root, [name, verbNoun], options) {
142
205
  requireModuleName(root, name);
143
206
  requirePascal(verbNoun, "First use case", ["UseCases"]);
144
- const files = { ...useCaseFiles(name, verbNoun), ...(options.has("--no-http") ? {} : httpFiles(name, verbNoun)) };
145
- return [plan(files), [
146
- `Register ${verbNoun} in src/app/container.ts`,
147
- ...(options.has("--no-http") ? [] : [`Mount ${camel(name)}Routes in src/app/routes.ts`]),
207
+ const files = { ...useCaseFiles(name, verbNoun), ...(options.has("--no-http") ? {} : serviceHttpFiles(name, verbNoun)) };
208
+ const edits = [registerUseCaseEdit(name, verbNoun), ...(options.has("--no-http") ? [] : [mountModuleRoutesEdit(name, verbNoun)])];
209
+ return [plan(files, edits), [
148
210
  `Implement ${verbNoun}.execute and replace the placeholder test`
149
211
  ]];
150
212
  },
151
213
  "use-case"(root, [moduleName, verbNoun]) {
152
214
  requireModuleName(root, moduleName);
153
215
  requirePascal(verbNoun, "Use case", ["UseCases"]);
154
- return [plan(useCaseFiles(moduleName, verbNoun)), [
155
- `Register ${verbNoun} in src/app/container.ts`,
216
+ return [plan(useCaseFiles(moduleName, verbNoun), [registerUseCaseEdit(moduleName, verbNoun)]), [
156
217
  `Expose it from an entry point (http route, worker, job, or cli) if the capability needs one`,
157
218
  `Implement ${verbNoun}.execute and replace the placeholder test`
158
219
  ]];
@@ -162,8 +223,15 @@ const serviceSchematics = {
162
223
  requirePascal(verbNoun, "Use case", ["UseCases"]);
163
224
  if (!fs.existsSync(path.join(root, `src/modules/${moduleName}/use-cases/${verbNoun}.ts`)))
164
225
  fail(`Use case src/modules/${moduleName}/use-cases/${verbNoun}.ts does not exist; generate it first`);
165
- return [plan(httpFiles(moduleName, verbNoun)), [
166
- `Mount ${camel(moduleName)}Routes in src/app/routes.ts`,
226
+ const schemas = path.join(root, `src/modules/${moduleName}/http/${moduleName}.schemas.ts`);
227
+ const routes = path.join(root, `src/modules/${moduleName}/http/${moduleName}.routes.ts`);
228
+ if (fs.existsSync(schemas) !== fs.existsSync(routes)) {
229
+ fail(`Module ${moduleName} has only one canonical HTTP file; restore both ${moduleName}.schemas.ts and ${moduleName}.routes.ts before generating`);
230
+ }
231
+ const firstHttp = !fs.existsSync(schemas);
232
+ return [plan(firstHttp ? serviceHttpFiles(moduleName, verbNoun) : {}, firstHttp
233
+ ? [mountModuleRoutesEdit(moduleName, verbNoun)]
234
+ : extendModuleRoutesEdits(moduleName, verbNoun)), [
167
235
  `Define the real request schema in ${moduleName}.schemas.ts`
168
236
  ]];
169
237
  },
@@ -247,14 +315,7 @@ ${body}
247
315
  function uiArtifacts(moduleName, kindOptions) {
248
316
  const files = {};
249
317
  if (kindOptions.api) {
250
- files[`src/modules/${moduleName}/api/${moduleName}.api.ts`] =
251
- `import { http } from "@/platform/http";
252
- import type { RpcResponse } from "@/core/rpc";
253
-
254
- export async function get${pascal(moduleName)}() {
255
- return (await http.post<RpcResponse<unknown>>("${moduleName}.get")).data.result;
256
- }
257
- `;
318
+ files[`src/modules/${moduleName}/api/${moduleName}.api.ts`] = moduleApiSource(moduleName, pascal(moduleName));
258
319
  }
259
320
  if (kindOptions.view) {
260
321
  const viewName = kindOptions.view.endsWith("View") ? kindOptions.view : `${kindOptions.view}View`;