@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 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.1.1",
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": {
@@ -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" && moduleName === "theme") fail("Application theme must live under src/app, not src/modules/theme");
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
- fail(`${source} may import only its own UI module, core, or platform, not ${target}`);
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(`Cross-module use-case dependency cycle: ${[...active.slice(start), moduleName].join(" -> ")}`);
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
- if (kind === "service") detectCycles(graph, fail);
319
+ detectCycles(graph, fail, kind === "service" ? "Cross-module use-case dependency cycle" : "Cross-module UI public dependency cycle");
258
320
  return errors;
259
321
  }
@@ -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`);