@zaaxch/tailframe 0.1.1 → 2.0.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/bin/tailframe.mjs +3 -2
- package/package.json +1 -1
- package/src/architecture.mjs +72 -10
- package/src/conventions.mjs +179 -0
- package/src/generate.mjs +312 -70
- package/src/new.mjs +188 -130
- package/src/service-templates.mjs +483 -0
- package/src/ui-templates.mjs +141 -0
- package/src/validate.mjs +3 -1
package/bin/tailframe.mjs
CHANGED
|
@@ -14,8 +14,9 @@ function usage() {
|
|
|
14
14
|
console.error(" service schematics: module <name> <VerbNoun> [--no-http], use-case <module> <VerbNoun>,");
|
|
15
15
|
console.error(" http <module> <VerbNoun>, port <module> <Name>, adapter <module> <PortName> --db <technology>,");
|
|
16
16
|
console.error(" identifiers <module> <NameId...>, integration <provider>");
|
|
17
|
-
console.error(" ui schematics: module <name> --api|--view <Name>|--component <Name>|--store <name>|--composable <name>,");
|
|
18
|
-
console.error(" api <module>, view <module> <Name>, component <module> <Name>, store <module> <name>, composable <module> <name
|
|
17
|
+
console.error(" ui schematics: module <name> --api|--view <Name>|--component <Name>|--store <name>|--composable <name>|--public <name>|--public-component <Name>,");
|
|
18
|
+
console.error(" api <module>, view <module> <Name>, component <module> <Name>, store <module> <name>, composable <module> <name>,");
|
|
19
|
+
console.error(" public <module> <name>, public-component <module> <Name>, app-store auth, app-store theme --storage-key <key>, app-component ThemeToggle");
|
|
19
20
|
process.exit(2);
|
|
20
21
|
}
|
|
21
22
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zaaxch/tailframe",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "2.0.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": {
|
package/src/architecture.mjs
CHANGED
|
@@ -4,6 +4,22 @@ import path from "node:path";
|
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
const SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".vue"]);
|
|
7
|
+
const APP_STORE_FILES = ["auth.store.ts", "theme.store.ts"];
|
|
8
|
+
const APP_PUBLIC_FILES = ["ThemeToggle.vue"];
|
|
9
|
+
const APP_STORE_TARGETS = new Set(["src/app/stores/auth.store", "src/app/stores/theme.store"]);
|
|
10
|
+
const APP_PUBLIC_TARGETS = new Set(["src/app/public/ThemeToggle"]);
|
|
11
|
+
|
|
12
|
+
function withoutSourceExtension(target) {
|
|
13
|
+
return target?.replace(/\.(?:ts|tsx|vue)$/, "");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isPublicAppStore(target) {
|
|
17
|
+
return APP_STORE_TARGETS.has(withoutSourceExtension(target));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isPublicAppUi(target) {
|
|
21
|
+
return APP_PUBLIC_TARGETS.has(withoutSourceExtension(target));
|
|
22
|
+
}
|
|
7
23
|
|
|
8
24
|
export function toPosix(value) {
|
|
9
25
|
return value.split(path.sep).join("/");
|
|
@@ -47,6 +63,10 @@ function resolveInternal(relativeFile, specifier) {
|
|
|
47
63
|
return path.posix.normalize(path.posix.join(path.posix.dirname(relativeFile), specifier));
|
|
48
64
|
}
|
|
49
65
|
|
|
66
|
+
function looksLikeUnsupportedInternalAlias(specifier) {
|
|
67
|
+
return /^(?:(?:~|#|\$)\/(?:src\/)?|@(?:app|core|platform|modules)\/|(?:src\/)?(?:app|core|platform|modules)\/)/.test(specifier);
|
|
68
|
+
}
|
|
69
|
+
|
|
50
70
|
export function isTestFile(relative) {
|
|
51
71
|
return relative.includes("/__tests__/") || /\.(?:test|spec)\.[^.]+$/.test(relative);
|
|
52
72
|
}
|
|
@@ -86,6 +106,10 @@ function validateStructure(root, kind, fail) {
|
|
|
86
106
|
if (!allowed.includes(name)) fail(`Unexpected architecture ${entryKind}: ${relative}/${name}`);
|
|
87
107
|
}
|
|
88
108
|
};
|
|
109
|
+
const checkOptionalEntries = (relative, entryKind, allowed) => {
|
|
110
|
+
const target = path.join(root, relative);
|
|
111
|
+
if (fs.existsSync(target) && fs.statSync(target).isDirectory()) checkEntries(relative, entryKind, allowed);
|
|
112
|
+
};
|
|
89
113
|
|
|
90
114
|
if (kind === "service") {
|
|
91
115
|
checkEntries("src", "directory", ["__tests__", "app", "core", "modules", "platform"]);
|
|
@@ -95,8 +119,12 @@ function validateStructure(root, kind, fail) {
|
|
|
95
119
|
} else {
|
|
96
120
|
checkEntries("src", "directory", ["app", "core", "modules", "platform"]);
|
|
97
121
|
checkEntries("src", "file", ["main.ts"]);
|
|
98
|
-
checkEntries("src/app", "directory", ["__tests__", "components", "stores", "views"]);
|
|
122
|
+
checkEntries("src/app", "directory", ["__tests__", "components", "public", "stores", "views"]);
|
|
99
123
|
checkEntries("src/core", "directory", []);
|
|
124
|
+
checkOptionalEntries("src/app/public", "directory", []);
|
|
125
|
+
checkOptionalEntries("src/app/public", "file", APP_PUBLIC_FILES);
|
|
126
|
+
checkOptionalEntries("src/app/stores", "directory", []);
|
|
127
|
+
checkOptionalEntries("src/app/stores", "file", APP_STORE_FILES);
|
|
100
128
|
}
|
|
101
129
|
|
|
102
130
|
const platform = path.join(root, "src/platform");
|
|
@@ -108,15 +136,22 @@ function validateStructure(root, kind, fail) {
|
|
|
108
136
|
}
|
|
109
137
|
const allowedModuleDirectories = kind === "service"
|
|
110
138
|
? ["__tests__", "domain", "http", "persistence", "use-cases"]
|
|
111
|
-
: ["__tests__", "api", "components", "composables", "routes", "stores", "types", "views"];
|
|
139
|
+
: ["__tests__", "api", "components", "composables", "public", "routes", "stores", "types", "views"];
|
|
112
140
|
for (const moduleName of listEntries(modules, "directory")) {
|
|
113
|
-
if (kind === "ui" &&
|
|
141
|
+
if (kind === "ui" && ["auth", "theme"].includes(moduleName)) {
|
|
142
|
+
fail(`Application ${moduleName === "auth" ? "authentication" : "theme"} must live under src/app, not src/modules/${moduleName}`);
|
|
143
|
+
}
|
|
114
144
|
checkEntries(`src/modules/${moduleName}`, "directory", allowedModuleDirectories);
|
|
115
145
|
checkEntries(`src/modules/${moduleName}`, "file", []);
|
|
146
|
+
if (kind === "ui") checkOptionalEntries(`src/modules/${moduleName}/public`, "directory", []);
|
|
116
147
|
}
|
|
117
148
|
}
|
|
118
149
|
|
|
119
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
|
+
}
|
|
120
155
|
if (source === "src/server.ts" || source === "src/worker.ts") {
|
|
121
156
|
if (external) {
|
|
122
157
|
if (external !== "reflect-metadata") fail(`${source} may import only reflect-metadata and app bootstrap code, not ${external}`);
|
|
@@ -188,7 +223,7 @@ function validateServiceImport(source, target, external, fail, graph) {
|
|
|
188
223
|
}
|
|
189
224
|
}
|
|
190
225
|
|
|
191
|
-
function validateUiImport(source, target, external, fail) {
|
|
226
|
+
function validateUiImport(source, target, external, fail, graph) {
|
|
192
227
|
if (source === "src/main.ts") {
|
|
193
228
|
if (external || !target.startsWith("src/app/")) fail(`src/main.ts may import only app bootstrap code, not ${external ?? target}`);
|
|
194
229
|
return;
|
|
@@ -197,6 +232,22 @@ function validateUiImport(source, target, external, fail) {
|
|
|
197
232
|
if (external || !target.startsWith("src/core/")) fail(`${source} violates core purity by importing ${external ?? target}`);
|
|
198
233
|
return;
|
|
199
234
|
}
|
|
235
|
+
if (source.startsWith("src/app/stores/")) {
|
|
236
|
+
if (external || target.startsWith("src/core/") || target.startsWith("src/platform/")) return;
|
|
237
|
+
fail(`${source} is a canonical shell store and may import only framework packages, core, or platform, not ${target}`);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (source.startsWith("src/app/public/")) {
|
|
241
|
+
if (external || target.startsWith("src/core/") || target.startsWith("src/platform/") || isPublicAppStore(target)) return;
|
|
242
|
+
fail(`${source} is canonical public shell UI and may import only framework packages, core, platform, or a canonical app store, not ${target}`);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (/^src\/app\/(?:components|views)\//.test(source)) {
|
|
246
|
+
const targetModule = moduleParts(target ?? "");
|
|
247
|
+
if (!targetModule || targetModule.layer === "public") return;
|
|
248
|
+
fail(`${source} may compose a product module only through a named module public entry, not ${target}; route module views directly from src/app/router.ts`);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
200
251
|
if (source.startsWith("src/app/")) return;
|
|
201
252
|
if (source.startsWith("src/platform/")) {
|
|
202
253
|
if (external || target.startsWith("src/core/") || target.startsWith("src/platform/")) return;
|
|
@@ -205,20 +256,27 @@ function validateUiImport(source, target, external, fail) {
|
|
|
205
256
|
}
|
|
206
257
|
const sourceModule = moduleParts(source);
|
|
207
258
|
if (!sourceModule || external) return;
|
|
208
|
-
if (target.startsWith("src/core/") || target.startsWith("src/platform/")) return;
|
|
259
|
+
if (target.startsWith("src/core/") || target.startsWith("src/platform/") || isPublicAppStore(target) || isPublicAppUi(target)) return;
|
|
209
260
|
const targetModule = moduleParts(target);
|
|
210
261
|
if (targetModule?.module === sourceModule.module) return;
|
|
211
|
-
|
|
262
|
+
if (targetModule?.layer === "public" && targetModule.remainder && !targetModule.remainder.includes("/")) {
|
|
263
|
+
if (!isTestFile(source) && sourceModule.layer !== "__tests__") {
|
|
264
|
+
if (!graph.has(sourceModule.module)) graph.set(sourceModule.module, new Set());
|
|
265
|
+
graph.get(sourceModule.module).add(targetModule.module);
|
|
266
|
+
}
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
fail(`${source} may import only its own UI module, core, platform, the canonical auth/theme app stores, ThemeToggle, or a named file directly under another module's public directory, not ${target}`);
|
|
212
270
|
}
|
|
213
271
|
|
|
214
|
-
function detectCycles(graph, fail) {
|
|
272
|
+
function detectCycles(graph, fail, label) {
|
|
215
273
|
const visited = new Set();
|
|
216
274
|
const active = [];
|
|
217
275
|
const activeSet = new Set();
|
|
218
276
|
const visit = (moduleName) => {
|
|
219
277
|
if (activeSet.has(moduleName)) {
|
|
220
278
|
const start = active.indexOf(moduleName);
|
|
221
|
-
fail(
|
|
279
|
+
fail(`${label}: ${[...active.slice(start), moduleName].join(" -> ")}`);
|
|
222
280
|
return;
|
|
223
281
|
}
|
|
224
282
|
if (visited.has(moduleName)) return;
|
|
@@ -246,14 +304,18 @@ export function validateArchitecture(rootArgument, kind) {
|
|
|
246
304
|
const relative = toPosix(path.relative(root, absolute));
|
|
247
305
|
for (const specifier of extractImports(fs.readFileSync(absolute, "utf8"), relative)) {
|
|
248
306
|
const target = resolveInternal(relative, specifier);
|
|
307
|
+
if (!target && looksLikeUnsupportedInternalAlias(specifier)) {
|
|
308
|
+
fail(`${relative} uses unsupported internal import alias ${specifier}; use @/ or a relative import so boundaries remain enforceable`);
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
249
311
|
if (target && !target.startsWith("src/")) {
|
|
250
312
|
fail(`${relative} has a relative import outside src: ${specifier}`);
|
|
251
313
|
continue;
|
|
252
314
|
}
|
|
253
315
|
if (kind === "service") validateServiceImport(relative, target, target ? undefined : specifier, fail, graph);
|
|
254
|
-
else validateUiImport(relative, target, target ? undefined : specifier, fail);
|
|
316
|
+
else validateUiImport(relative, target, target ? undefined : specifier, fail, graph);
|
|
255
317
|
}
|
|
256
318
|
}
|
|
257
|
-
|
|
319
|
+
detectCycles(graph, fail, kind === "service" ? "Cross-module use-case dependency cycle" : "Cross-module UI public dependency cycle");
|
|
258
320
|
return errors;
|
|
259
321
|
}
|
package/src/conventions.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { toPosix, walk } from "./architecture.mjs";
|
|
4
|
+
import { authAppStoreSource, themeAppStoreSource, themeToggleSource } from "./generate.mjs";
|
|
4
5
|
|
|
5
6
|
const PASCAL = /^[A-Z][A-Za-z0-9]*$/;
|
|
6
7
|
const CAMEL = /^[a-z][A-Za-z0-9]*$/;
|
|
@@ -8,6 +9,16 @@ const KEBAB = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
|
8
9
|
const ROLES = new Set(["routes", "schemas", "api", "store", "types", "documents"]);
|
|
9
10
|
const NOT_PLURAL = new Set(["status", "analysis"]);
|
|
10
11
|
const BRAND_PATTERN = /\bBrand\s*<|unique symbol/;
|
|
12
|
+
const ROUTE_NAMES_FILE = "src/core/RouteNames.ts";
|
|
13
|
+
const ROUTE_NAMES_DECLARATION = /\b(?:enum|const|type)\s+RouteNames\b/;
|
|
14
|
+
const ROUTE_NAMES_CANONICAL_DECLARATION = /\bexport\s+enum\s+RouteNames\b/;
|
|
15
|
+
const ROUTE_NAMES_VALUE = /^\s*[A-Z][A-Z0-9_]*\s*=\s*["']([^"']+)["']/gm;
|
|
16
|
+
const ROUTE_NAME_REFERENCE = /\bname:\s*["']([^"']+)["']/g;
|
|
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
|
+
};
|
|
11
22
|
|
|
12
23
|
function parseName(fileName) {
|
|
13
24
|
if (fileName.endsWith(".d.ts")) return undefined;
|
|
@@ -59,6 +70,9 @@ export function validateConventions(rootArgument, kind) {
|
|
|
59
70
|
if ((relative.startsWith("src/core/") && name.extension === ".ts") && BRAND_PATTERN.test(fs.readFileSync(absolute, "utf8"))) {
|
|
60
71
|
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
72
|
}
|
|
73
|
+
if (kind === "ui" && relative.startsWith("src/app/public/") && relative.slice("src/app/public/".length).includes("/")) {
|
|
74
|
+
flag("U6", relative, `${relative} is nested under app/public; public shell entries are named files directly under src/app/public`);
|
|
75
|
+
}
|
|
62
76
|
continue;
|
|
63
77
|
}
|
|
64
78
|
|
|
@@ -89,15 +103,168 @@ export function validateConventions(rootArgument, kind) {
|
|
|
89
103
|
const layer = remainder.split("/")[0];
|
|
90
104
|
if (kind === "service") validateServiceFile(relative, moduleName, layer, remainder, name, flag);
|
|
91
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
|
+
}
|
|
92
109
|
|
|
93
110
|
if (kind === "service" && name.extension === ".ts" && relative !== `${modulePath}/domain/identifiers.ts` &&
|
|
94
111
|
BRAND_PATTERN.test(fs.readFileSync(absolute, "utf8"))) {
|
|
95
112
|
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
113
|
}
|
|
97
114
|
}
|
|
115
|
+
if (kind === "ui") {
|
|
116
|
+
validateRouteNames(root, src, flag);
|
|
117
|
+
validateCanonicalShell(root, flag);
|
|
118
|
+
} else {
|
|
119
|
+
validateServiceComposition(root, flag);
|
|
120
|
+
}
|
|
98
121
|
return violations;
|
|
99
122
|
}
|
|
100
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
|
+
|
|
198
|
+
function normalizeGeneratedSource(source) {
|
|
199
|
+
return source.replace(/\r\n/g, "\n");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function validateCanonicalShell(root, flag) {
|
|
203
|
+
const authRelative = "src/app/stores/auth.store.ts";
|
|
204
|
+
const authFile = path.join(root, authRelative);
|
|
205
|
+
if (fs.existsSync(authFile) && normalizeGeneratedSource(fs.readFileSync(authFile, "utf8")) !== authAppStoreSource()) {
|
|
206
|
+
flag("U7", authRelative, `${authRelative} differs from the canonical Tailframe auth store; regenerate it instead of maintaining a project-local variant`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const themeRelative = "src/app/stores/theme.store.ts";
|
|
210
|
+
const themeFile = path.join(root, themeRelative);
|
|
211
|
+
if (fs.existsSync(themeFile)) {
|
|
212
|
+
const source = normalizeGeneratedSource(fs.readFileSync(themeFile, "utf8"));
|
|
213
|
+
const storageKeyMatch = source.match(/useLocalStorage<Theme>\(("(?:\\.|[^"\\])*"), preferredTheme\(\)\)/);
|
|
214
|
+
let expected;
|
|
215
|
+
if (storageKeyMatch) {
|
|
216
|
+
try { expected = themeAppStoreSource(JSON.parse(storageKeyMatch[1])); } catch { /* reported below */ }
|
|
217
|
+
}
|
|
218
|
+
if (!expected || source !== expected) {
|
|
219
|
+
flag("U7", themeRelative, `${themeRelative} differs from the canonical Tailframe theme store; only its generated storage-key value may vary`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const toggleRelative = "src/app/public/ThemeToggle.vue";
|
|
224
|
+
const toggleFile = path.join(root, toggleRelative);
|
|
225
|
+
if (fs.existsSync(toggleFile)) {
|
|
226
|
+
if (!fs.existsSync(themeFile)) flag("U7", toggleRelative, `${toggleRelative} requires ${themeRelative}`);
|
|
227
|
+
if (normalizeGeneratedSource(fs.readFileSync(toggleFile, "utf8")) !== themeToggleSource()) {
|
|
228
|
+
flag("U7", toggleRelative, `${toggleRelative} differs from the canonical Tailframe theme toggle; regenerate it instead of maintaining a project-local variant`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Route names are the one piece of shell vocabulary a module legitimately needs, so they live in a
|
|
235
|
+
* core enum both sides may import. A module that cannot reach the enum hardcodes the string instead,
|
|
236
|
+
* and a later rename breaks navigation silently.
|
|
237
|
+
*/
|
|
238
|
+
function validateRouteNames(root, src, flag) {
|
|
239
|
+
const declarationFile = path.join(root, ROUTE_NAMES_FILE);
|
|
240
|
+
const declared = new Set();
|
|
241
|
+
if (!fs.existsSync(declarationFile)) {
|
|
242
|
+
flag("U5", ROUTE_NAMES_FILE, `${ROUTE_NAMES_FILE} is required; route names are one exported enum shared by the router and modules`);
|
|
243
|
+
} else {
|
|
244
|
+
const text = fs.readFileSync(declarationFile, "utf8");
|
|
245
|
+
if (!ROUTE_NAMES_CANONICAL_DECLARATION.test(text)) {
|
|
246
|
+
flag("U5", ROUTE_NAMES_FILE, `${ROUTE_NAMES_FILE} must declare export enum RouteNames`);
|
|
247
|
+
}
|
|
248
|
+
for (const match of text.matchAll(ROUTE_NAMES_VALUE)) declared.add(match[1]);
|
|
249
|
+
if (declared.size === 0) flag("U5", ROUTE_NAMES_FILE, `${ROUTE_NAMES_FILE} must declare at least one string-valued route name`);
|
|
250
|
+
}
|
|
251
|
+
for (const absolute of walk(src)) {
|
|
252
|
+
const relative = toPosix(path.relative(root, absolute));
|
|
253
|
+
if (![".ts", ".vue"].includes(path.extname(relative))) continue;
|
|
254
|
+
const text = fs.readFileSync(absolute, "utf8");
|
|
255
|
+
if (relative !== ROUTE_NAMES_FILE && ROUTE_NAMES_DECLARATION.test(text)) {
|
|
256
|
+
flag("U5", relative, `${relative} declares RouteNames outside ${ROUTE_NAMES_FILE}; route names are one core enum imported by the router and by modules`);
|
|
257
|
+
}
|
|
258
|
+
if (!relative.startsWith("src/modules/") || declared.size === 0) continue;
|
|
259
|
+
for (const match of text.matchAll(ROUTE_NAME_REFERENCE)) {
|
|
260
|
+
if (declared.has(match[1])) {
|
|
261
|
+
flag("U5", relative, `${relative} hardcodes the route name "${match[1]}"; import RouteNames from ${ROUTE_NAMES_FILE}`);
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
101
268
|
function validateShared(relative, name, flag) {
|
|
102
269
|
if (name.stem === "index") return;
|
|
103
270
|
const directory = path.posix.basename(path.posix.dirname(relative));
|
|
@@ -111,6 +278,10 @@ function validateShared(relative, name, flag) {
|
|
|
111
278
|
else if (directory === "views" && !name.stem.endsWith("View")) flag("U2", relative, `${relative} views are named <Name>View.vue`);
|
|
112
279
|
return;
|
|
113
280
|
}
|
|
281
|
+
if (relative.startsWith("src/app/stores/") && name.extension === ".ts") {
|
|
282
|
+
flag("U3", relative, `${relative} is not a canonical app store; app/stores is closed to auth.store.ts and theme.store.ts`);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
114
285
|
if (!PASCAL.test(name.stem) && !CAMEL.test(name.stem)) {
|
|
115
286
|
flag("G1", relative, `${relative} must be named after its primary export in that export's casing (PascalCase or camelCase)`);
|
|
116
287
|
}
|
|
@@ -178,6 +349,14 @@ function validateUiFile(relative, moduleName, layer, name, flag) {
|
|
|
178
349
|
if (name.role !== "store") flag("U3", relative, `${relative} stores are named <name>.store.ts`);
|
|
179
350
|
return;
|
|
180
351
|
}
|
|
352
|
+
if (layer === "public") {
|
|
353
|
+
const remainder = relative.slice(`src/modules/${moduleName}/public/`.length);
|
|
354
|
+
if (remainder.includes("/")) flag("U6", relative, `${relative} is nested; module public entries are named files directly under public/`);
|
|
355
|
+
else if (name.role !== undefined || (!PASCAL.test(name.stem) && !CAMEL.test(name.stem))) {
|
|
356
|
+
flag("U6", relative, `${relative} must be a named public entry in primary-export casing, not a role-suffix file or barrel`);
|
|
357
|
+
}
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
181
360
|
if (layer === "types") {
|
|
182
361
|
if (name.role !== undefined) {
|
|
183
362
|
if (name.role !== "types" || name.subject !== moduleName) flag("U3", relative, `${relative} the module types role file is ${moduleName}.types.ts`);
|