@zaaxch/tailframe 0.1.0 → 1.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,13 +1,12 @@
1
1
  {
2
2
  "name": "@zaaxch/tailframe",
3
- "version": "0.1.0",
3
+ "version": "1.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": {
7
7
  "tailframe": "bin/tailframe.mjs"
8
8
  },
9
9
  "files": [
10
- "assets",
11
10
  "bin",
12
11
  "src"
13
12
  ],
@@ -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,11 +136,14 @@ 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
 
@@ -188,7 +219,7 @@ function validateServiceImport(source, target, external, fail, graph) {
188
219
  }
189
220
  }
190
221
 
191
- function validateUiImport(source, target, external, fail) {
222
+ function validateUiImport(source, target, external, fail, graph) {
192
223
  if (source === "src/main.ts") {
193
224
  if (external || !target.startsWith("src/app/")) fail(`src/main.ts may import only app bootstrap code, not ${external ?? target}`);
194
225
  return;
@@ -197,6 +228,22 @@ function validateUiImport(source, target, external, fail) {
197
228
  if (external || !target.startsWith("src/core/")) fail(`${source} violates core purity by importing ${external ?? target}`);
198
229
  return;
199
230
  }
231
+ if (source.startsWith("src/app/stores/")) {
232
+ if (external || target.startsWith("src/core/") || target.startsWith("src/platform/")) return;
233
+ fail(`${source} is a canonical shell store and may import only framework packages, core, or platform, not ${target}`);
234
+ return;
235
+ }
236
+ if (source.startsWith("src/app/public/")) {
237
+ if (external || target.startsWith("src/core/") || target.startsWith("src/platform/") || isPublicAppStore(target)) return;
238
+ fail(`${source} is canonical public shell UI and may import only framework packages, core, platform, or a canonical app store, not ${target}`);
239
+ return;
240
+ }
241
+ if (/^src\/app\/(?:components|views)\//.test(source)) {
242
+ const targetModule = moduleParts(target ?? "");
243
+ if (!targetModule || targetModule.layer === "public") return;
244
+ 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`);
245
+ return;
246
+ }
200
247
  if (source.startsWith("src/app/")) return;
201
248
  if (source.startsWith("src/platform/")) {
202
249
  if (external || target.startsWith("src/core/") || target.startsWith("src/platform/")) return;
@@ -205,20 +252,27 @@ function validateUiImport(source, target, external, fail) {
205
252
  }
206
253
  const sourceModule = moduleParts(source);
207
254
  if (!sourceModule || external) return;
208
- if (target.startsWith("src/core/") || target.startsWith("src/platform/")) return;
255
+ if (target.startsWith("src/core/") || target.startsWith("src/platform/") || isPublicAppStore(target) || isPublicAppUi(target)) return;
209
256
  const targetModule = moduleParts(target);
210
257
  if (targetModule?.module === sourceModule.module) return;
211
- fail(`${source} may import only its own UI module, core, or platform, not ${target}`);
258
+ if (targetModule?.layer === "public" && targetModule.remainder && !targetModule.remainder.includes("/")) {
259
+ if (!isTestFile(source) && sourceModule.layer !== "__tests__") {
260
+ if (!graph.has(sourceModule.module)) graph.set(sourceModule.module, new Set());
261
+ graph.get(sourceModule.module).add(targetModule.module);
262
+ }
263
+ return;
264
+ }
265
+ 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
266
  }
213
267
 
214
- function detectCycles(graph, fail) {
268
+ function detectCycles(graph, fail, label) {
215
269
  const visited = new Set();
216
270
  const active = [];
217
271
  const activeSet = new Set();
218
272
  const visit = (moduleName) => {
219
273
  if (activeSet.has(moduleName)) {
220
274
  const start = active.indexOf(moduleName);
221
- fail(`Cross-module use-case dependency cycle: ${[...active.slice(start), moduleName].join(" -> ")}`);
275
+ fail(`${label}: ${[...active.slice(start), moduleName].join(" -> ")}`);
222
276
  return;
223
277
  }
224
278
  if (visited.has(moduleName)) return;
@@ -246,14 +300,18 @@ export function validateArchitecture(rootArgument, kind) {
246
300
  const relative = toPosix(path.relative(root, absolute));
247
301
  for (const specifier of extractImports(fs.readFileSync(absolute, "utf8"), relative)) {
248
302
  const target = resolveInternal(relative, specifier);
303
+ if (!target && looksLikeUnsupportedInternalAlias(specifier)) {
304
+ fail(`${relative} uses unsupported internal import alias ${specifier}; use @/ or a relative import so boundaries remain enforceable`);
305
+ continue;
306
+ }
249
307
  if (target && !target.startsWith("src/")) {
250
308
  fail(`${relative} has a relative import outside src: ${specifier}`);
251
309
  continue;
252
310
  }
253
311
  if (kind === "service") validateServiceImport(relative, target, target ? undefined : specifier, fail, graph);
254
- else validateUiImport(relative, target, target ? undefined : specifier, fail);
312
+ else validateUiImport(relative, target, target ? undefined : specifier, fail, graph);
255
313
  }
256
314
  }
257
- if (kind === "service") detectCycles(graph, fail);
315
+ detectCycles(graph, fail, kind === "service" ? "Cross-module use-case dependency cycle" : "Cross-module UI public dependency cycle");
258
316
  return errors;
259
317
  }
@@ -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,11 @@ 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;
11
17
 
12
18
  function parseName(fileName) {
13
19
  if (fileName.endsWith(".d.ts")) return undefined;
@@ -59,6 +65,9 @@ export function validateConventions(rootArgument, kind) {
59
65
  if ((relative.startsWith("src/core/") && name.extension === ".ts") && BRAND_PATTERN.test(fs.readFileSync(absolute, "utf8"))) {
60
66
  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
67
  }
68
+ if (kind === "ui" && relative.startsWith("src/app/public/") && relative.slice("src/app/public/".length).includes("/")) {
69
+ flag("U6", relative, `${relative} is nested under app/public; public shell entries are named files directly under src/app/public`);
70
+ }
62
71
  continue;
63
72
  }
64
73
 
@@ -95,9 +104,83 @@ export function validateConventions(rootArgument, kind) {
95
104
  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
105
  }
97
106
  }
107
+ if (kind === "ui") {
108
+ validateRouteNames(root, src, flag);
109
+ validateCanonicalShell(root, flag);
110
+ }
98
111
  return violations;
99
112
  }
100
113
 
114
+ function normalizeGeneratedSource(source) {
115
+ return source.replace(/\r\n/g, "\n");
116
+ }
117
+
118
+ function validateCanonicalShell(root, flag) {
119
+ const authRelative = "src/app/stores/auth.store.ts";
120
+ const authFile = path.join(root, authRelative);
121
+ if (fs.existsSync(authFile) && normalizeGeneratedSource(fs.readFileSync(authFile, "utf8")) !== authAppStoreSource()) {
122
+ flag("U7", authRelative, `${authRelative} differs from the canonical Tailframe auth store; regenerate it instead of maintaining a project-local variant`);
123
+ }
124
+
125
+ const themeRelative = "src/app/stores/theme.store.ts";
126
+ const themeFile = path.join(root, themeRelative);
127
+ if (fs.existsSync(themeFile)) {
128
+ const source = normalizeGeneratedSource(fs.readFileSync(themeFile, "utf8"));
129
+ const storageKeyMatch = source.match(/useLocalStorage<Theme>\(("(?:\\.|[^"\\])*"), preferredTheme\(\)\)/);
130
+ let expected;
131
+ if (storageKeyMatch) {
132
+ try { expected = themeAppStoreSource(JSON.parse(storageKeyMatch[1])); } catch { /* reported below */ }
133
+ }
134
+ if (!expected || source !== expected) {
135
+ flag("U7", themeRelative, `${themeRelative} differs from the canonical Tailframe theme store; only its generated storage-key value may vary`);
136
+ }
137
+ }
138
+
139
+ const toggleRelative = "src/app/public/ThemeToggle.vue";
140
+ const toggleFile = path.join(root, toggleRelative);
141
+ if (fs.existsSync(toggleFile)) {
142
+ if (!fs.existsSync(themeFile)) flag("U7", toggleRelative, `${toggleRelative} requires ${themeRelative}`);
143
+ if (normalizeGeneratedSource(fs.readFileSync(toggleFile, "utf8")) !== themeToggleSource()) {
144
+ flag("U7", toggleRelative, `${toggleRelative} differs from the canonical Tailframe theme toggle; regenerate it instead of maintaining a project-local variant`);
145
+ }
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Route names are the one piece of shell vocabulary a module legitimately needs, so they live in a
151
+ * core enum both sides may import. A module that cannot reach the enum hardcodes the string instead,
152
+ * and a later rename breaks navigation silently.
153
+ */
154
+ function validateRouteNames(root, src, flag) {
155
+ const declarationFile = path.join(root, ROUTE_NAMES_FILE);
156
+ const declared = new Set();
157
+ if (!fs.existsSync(declarationFile)) {
158
+ flag("U5", ROUTE_NAMES_FILE, `${ROUTE_NAMES_FILE} is required; route names are one exported enum shared by the router and modules`);
159
+ } else {
160
+ const text = fs.readFileSync(declarationFile, "utf8");
161
+ if (!ROUTE_NAMES_CANONICAL_DECLARATION.test(text)) {
162
+ flag("U5", ROUTE_NAMES_FILE, `${ROUTE_NAMES_FILE} must declare export enum RouteNames`);
163
+ }
164
+ for (const match of text.matchAll(ROUTE_NAMES_VALUE)) declared.add(match[1]);
165
+ if (declared.size === 0) flag("U5", ROUTE_NAMES_FILE, `${ROUTE_NAMES_FILE} must declare at least one string-valued route name`);
166
+ }
167
+ for (const absolute of walk(src)) {
168
+ const relative = toPosix(path.relative(root, absolute));
169
+ if (![".ts", ".vue"].includes(path.extname(relative))) continue;
170
+ const text = fs.readFileSync(absolute, "utf8");
171
+ if (relative !== ROUTE_NAMES_FILE && ROUTE_NAMES_DECLARATION.test(text)) {
172
+ flag("U5", relative, `${relative} declares RouteNames outside ${ROUTE_NAMES_FILE}; route names are one core enum imported by the router and by modules`);
173
+ }
174
+ if (!relative.startsWith("src/modules/") || declared.size === 0) continue;
175
+ for (const match of text.matchAll(ROUTE_NAME_REFERENCE)) {
176
+ if (declared.has(match[1])) {
177
+ flag("U5", relative, `${relative} hardcodes the route name "${match[1]}"; import RouteNames from ${ROUTE_NAMES_FILE}`);
178
+ break;
179
+ }
180
+ }
181
+ }
182
+ }
183
+
101
184
  function validateShared(relative, name, flag) {
102
185
  if (name.stem === "index") return;
103
186
  const directory = path.posix.basename(path.posix.dirname(relative));
@@ -111,6 +194,10 @@ function validateShared(relative, name, flag) {
111
194
  else if (directory === "views" && !name.stem.endsWith("View")) flag("U2", relative, `${relative} views are named <Name>View.vue`);
112
195
  return;
113
196
  }
197
+ if (relative.startsWith("src/app/stores/") && name.extension === ".ts") {
198
+ flag("U3", relative, `${relative} is not a canonical app store; app/stores is closed to auth.store.ts and theme.store.ts`);
199
+ return;
200
+ }
114
201
  if (!PASCAL.test(name.stem) && !CAMEL.test(name.stem)) {
115
202
  flag("G1", relative, `${relative} must be named after its primary export in that export's casing (PascalCase or camelCase)`);
116
203
  }
@@ -178,6 +265,14 @@ function validateUiFile(relative, moduleName, layer, name, flag) {
178
265
  if (name.role !== "store") flag("U3", relative, `${relative} stores are named <name>.store.ts`);
179
266
  return;
180
267
  }
268
+ if (layer === "public") {
269
+ const remainder = relative.slice(`src/modules/${moduleName}/public/`.length);
270
+ if (remainder.includes("/")) flag("U6", relative, `${relative} is nested; module public entries are named files directly under public/`);
271
+ else if (name.role !== undefined || (!PASCAL.test(name.stem) && !CAMEL.test(name.stem))) {
272
+ flag("U6", relative, `${relative} must be a named public entry in primary-export casing, not a role-suffix file or barrel`);
273
+ }
274
+ return;
275
+ }
181
276
  if (layer === "types") {
182
277
  if (name.role !== undefined) {
183
278
  if (name.role !== "types" || name.subject !== moduleName) flag("U3", relative, `${relative} the module types role file is ${moduleName}.types.ts`);
package/src/generate.mjs CHANGED
@@ -4,6 +4,7 @@ import { loadExceptions, isExcepted } from "./exceptions.mjs";
4
4
 
5
5
  const KEBAB = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
6
6
  const PASCAL = /^[A-Z][A-Za-z0-9]*$/;
7
+ const PRIMARY = /^(?:[A-Z][A-Za-z0-9]*|[a-z][A-Za-z0-9]*)$/;
7
8
  const NOT_PLURAL = new Set(["status", "analysis"]);
8
9
 
9
10
  export class GenerateError extends Error {}
@@ -30,6 +31,14 @@ function requireModuleName(root, name) {
30
31
  return name;
31
32
  }
32
33
 
34
+ function requireUiModuleName(root, name) {
35
+ requireModuleName(root, name);
36
+ if (["auth", "theme"].includes(name)) {
37
+ fail(`UI module "${name}" is reserved for application-shell state under src/app/stores`);
38
+ }
39
+ return name;
40
+ }
41
+
33
42
  function requirePascal(value, label, forbiddenSuffixes = []) {
34
43
  if (!value || !PASCAL.test(value)) fail(`${label} must be PascalCase, received "${value ?? ""}"`);
35
44
  for (const suffix of forbiddenSuffixes) {
@@ -38,6 +47,11 @@ function requirePascal(value, label, forbiddenSuffixes = []) {
38
47
  return value;
39
48
  }
40
49
 
50
+ function requirePrimary(value, label) {
51
+ if (!value || !PRIMARY.test(value)) fail(`${label} must use PascalCase or camelCase primary-export naming, received "${value ?? ""}"`);
52
+ return value;
53
+ }
54
+
41
55
  function operationName(moduleName, verbNoun) {
42
56
  const moduleSuffix = pascal(moduleName);
43
57
  if (verbNoun.endsWith(moduleSuffix) && verbNoun !== moduleSuffix) {
@@ -265,9 +279,11 @@ export async function get${pascal(moduleName)}() {
265
279
  }
266
280
  if (kindOptions.store) {
267
281
  files[`src/modules/${moduleName}/stores/${kindOptions.store}.store.ts`] =
268
- `import { reactive } from "vue";
282
+ `import { defineStore } from "pinia";
269
283
 
270
- export const ${camel(kindOptions.store)}Store = reactive({});
284
+ export const use${pascal(kindOptions.store)}Store = defineStore("${moduleName}/${kindOptions.store}", () => {
285
+ return {};
286
+ });
271
287
  `;
272
288
  }
273
289
  if (kindOptions.composable) {
@@ -275,56 +291,221 @@ export const ${camel(kindOptions.store)}Store = reactive({});
275
291
  `export function use${pascal(kindOptions.composable)}() {
276
292
  return {};
277
293
  }
294
+ `;
295
+ }
296
+ if (kindOptions.public) {
297
+ const exportName = kindOptions.public;
298
+ files[`src/modules/${moduleName}/public/${exportName}.ts`] = exportName[0] === exportName[0].toUpperCase()
299
+ ? `export interface ${exportName} {}\n`
300
+ : `export function ${exportName}() {\n\treturn {};\n}\n`;
301
+ }
302
+ if (kindOptions.publicComponent) {
303
+ files[`src/modules/${moduleName}/public/${kindOptions.publicComponent}.vue`] =
304
+ `<script setup lang="ts"></script>
305
+
306
+ <template>
307
+ <div></div>
308
+ </template>
278
309
  `;
279
310
  }
280
311
  return files;
281
312
  }
282
313
 
314
+ export function themeAppStoreSource(storageKey) {
315
+ return `import { useLocalStorage } from "@vueuse/core";
316
+ import { defineStore } from "pinia";
317
+ import { computed } from "vue";
318
+
319
+ export type Theme = "light" | "dark";
320
+
321
+ const preferredTheme = (): Theme =>
322
+ typeof window !== "undefined" && window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light";
323
+
324
+ export const useThemeStore = defineStore("theme", () => {
325
+ const theme = useLocalStorage<Theme>(${JSON.stringify(storageKey)}, preferredTheme());
326
+ const isDark = computed(() => theme.value === "dark");
327
+
328
+ function toggleTheme() {
329
+ theme.value = isDark.value ? "light" : "dark";
330
+ }
331
+
332
+ return { theme, isDark, toggleTheme };
333
+ });
334
+ `;
335
+ }
336
+
337
+ export function themeToggleSource() {
338
+ return `<template>
339
+ <button
340
+ type="button"
341
+ :aria-label="\`Switch to \${themeStore.isDark ? 'light' : 'dark'} mode\`"
342
+ :title="\`Switch to \${themeStore.isDark ? 'light' : 'dark'} mode\`"
343
+ @click="themeStore.toggleTheme"
344
+ >
345
+ <svg
346
+ v-if="themeStore.isDark"
347
+ aria-hidden="true"
348
+ class="size-5"
349
+ viewBox="0 0 24 24"
350
+ fill="none"
351
+ stroke="currentColor"
352
+ stroke-width="1.8"
353
+ >
354
+ <circle cx="12" cy="12" r="4" />
355
+ <path
356
+ d="M12 2v2m0 16v2M4.93 4.93l1.42 1.42m11.3 11.3 1.42 1.42M2 12h2m16 0h2M4.93 19.07l1.42-1.42m11.3-11.3 1.42-1.42"
357
+ />
358
+ </svg>
359
+ <svg
360
+ v-else
361
+ aria-hidden="true"
362
+ class="size-5"
363
+ viewBox="0 0 24 24"
364
+ fill="none"
365
+ stroke="currentColor"
366
+ stroke-width="1.8"
367
+ >
368
+ <path d="M20.5 15.2A8.5 8.5 0 0 1 8.8 3.5a8.5 8.5 0 1 0 11.7 11.7Z" />
369
+ </svg>
370
+ </button>
371
+ </template>
372
+
373
+ <script setup lang="ts">
374
+ import { useThemeStore } from "@/app/stores/theme.store";
375
+
376
+ const themeStore = useThemeStore();
377
+ </script>
378
+ `;
379
+ }
380
+
381
+ export function authAppStoreSource() {
382
+ return `import { auth, googleProvider } from "@/platform/firebase";
383
+ import { onIdTokenChanged, signInWithPopup, signOut, type User } from "firebase/auth";
384
+ import { defineStore } from "pinia";
385
+ import { ref } from "vue";
386
+
387
+ export const useAuthStore = defineStore("auth", () => {
388
+ const firebaseUser = ref<User | null>(null);
389
+ const ready = ref(false);
390
+
391
+ onIdTokenChanged(auth, (user) => {
392
+ firebaseUser.value = user;
393
+ ready.value = true;
394
+ });
395
+
396
+ const signInWithGoogle = () => signInWithPopup(auth, googleProvider);
397
+ const logout = () => signOut(auth);
398
+ const getIdToken = () => firebaseUser.value?.getIdToken();
399
+
400
+ return { firebaseUser, ready, signInWithGoogle, logout, getIdToken };
401
+ });
402
+ `;
403
+ }
404
+
405
+ function appStoreFiles(name, options) {
406
+ if (name === "theme") {
407
+ const storageKey = options.get("--storage-key");
408
+ if (!storageKey) fail("Theme app-store generation requires --storage-key <key>");
409
+ return { "src/app/stores/theme.store.ts": themeAppStoreSource(storageKey) };
410
+ }
411
+ if (name === "auth") return { "src/app/stores/auth.store.ts": authAppStoreSource() };
412
+ fail(`App store must be one of auth or theme, received "${name ?? ""}"`);
413
+ }
414
+
415
+ function appPublicComponentFiles(name) {
416
+ if (name !== "ThemeToggle") {
417
+ fail(`Public shell component must be ThemeToggle, received "${name ?? ""}"; product UI belongs in its owning module`);
418
+ }
419
+ return {
420
+ "src/app/public/ThemeToggle.vue": themeToggleSource()
421
+ };
422
+ }
423
+
283
424
  const uiSchematics = {
284
425
  module(root, [name], options) {
285
- requireModuleName(root, name);
426
+ requireUiModuleName(root, name);
286
427
  const artifacts = {
287
428
  api: options.has("--api"),
288
429
  view: options.get("--view"),
289
430
  component: options.get("--component"),
290
431
  store: options.get("--store"),
291
- composable: options.get("--composable")
432
+ composable: options.get("--composable"),
433
+ public: options.get("--public"),
434
+ publicComponent: options.get("--public-component")
292
435
  };
293
436
  if (artifacts.view) requirePascal(artifacts.view, "View");
294
437
  if (artifacts.component) requirePascal(artifacts.component, "Component");
295
438
  if (artifacts.store && !KEBAB.test(artifacts.store)) fail(`Store name must be lowercase kebab-case, received "${artifacts.store}"`);
296
439
  if (artifacts.composable && !KEBAB.test(artifacts.composable)) fail(`Composable name must be lowercase kebab-case, received "${artifacts.composable}"`);
440
+ if (artifacts.public) requirePrimary(artifacts.public, "Public entry");
441
+ if (artifacts.publicComponent) requirePascal(artifacts.publicComponent, "Public component");
297
442
  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)");
443
+ if (!Object.keys(files).length) fail("A UI module needs at least one artifact: pass --api, --view <Name>, --component <Name>, --store <name>, --composable <name>, --public <name>, or --public-component <Name> (empty layers are forbidden)");
299
444
  return [plan(files), [
300
445
  ...(artifacts.view ? [`Register the view in a route and mount it from src/app/router.ts`] : []),
301
446
  ...(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`
447
+ ...(artifacts.public ? [`Implement ${artifacts.public}.ts as one stable public capability; keep internal module paths private`] : []),
448
+ ...(artifacts.publicComponent ? [`Implement ${artifacts.publicComponent}.vue as one stable public component backed only by this module's internals`] : []),
449
+ `Compose the module from src/app; sibling modules may use only named files directly under this module's public directory`
303
450
  ]];
304
451
  },
305
452
  api(root, [moduleName]) {
306
- requireModuleName(root, moduleName);
453
+ requireUiModuleName(root, moduleName);
307
454
  return [plan(uiArtifacts(moduleName, { api: true })), [`Point ${moduleName}.api.ts at the real RPC operation`]];
308
455
  },
309
456
  view(root, [moduleName, name]) {
310
- requireModuleName(root, moduleName);
457
+ requireUiModuleName(root, moduleName);
311
458
  requirePascal(name, "View");
312
459
  return [plan(uiArtifacts(moduleName, { view: name })), [`Register the view in a route and mount it from src/app/router.ts`]];
313
460
  },
314
461
  component(root, [moduleName, name]) {
315
- requireModuleName(root, moduleName);
462
+ requireUiModuleName(root, moduleName);
316
463
  requirePascal(name, "Component");
317
464
  return [plan(uiArtifacts(moduleName, { component: name })), [`Use the component from this module's views only`]];
318
465
  },
319
466
  store(root, [moduleName, name]) {
320
- requireModuleName(root, moduleName);
467
+ requireUiModuleName(root, moduleName);
321
468
  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`]];
469
+ return [plan(uiArtifacts(moduleName, { store: name })), [`Keep product state in this owning module; expose a deliberate facade with tailframe generate public ${moduleName} use${pascal(name)}Store only when another module needs it`]];
323
470
  },
324
471
  composable(root, [moduleName, name]) {
325
- requireModuleName(root, moduleName);
472
+ requireUiModuleName(root, moduleName);
326
473
  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`]];
474
+ return [plan(uiArtifacts(moduleName, { composable: name })), [`Use the composable inside this module; expose a named public facade only when another module needs it`]];
475
+ },
476
+ public(root, [moduleName, name]) {
477
+ requireUiModuleName(root, moduleName);
478
+ requirePrimary(name, "Public entry");
479
+ return [plan(uiArtifacts(moduleName, { public: name })), [
480
+ `Expose one stable capability from ${name}.ts; it may adapt this module's internal stores, composables, types, or API`,
481
+ `Keep UI module dependencies acyclic and import no sibling internals`
482
+ ]];
483
+ },
484
+ "public-component"(root, [moduleName, name]) {
485
+ requireUiModuleName(root, moduleName);
486
+ requirePascal(name, "Public component");
487
+ return [plan(uiArtifacts(moduleName, { publicComponent: name })), [
488
+ `Implement ${name}.vue as one stable public component backed only by ${moduleName} internals`,
489
+ `Keep UI module dependencies acyclic and import no sibling internals`
490
+ ]];
491
+ },
492
+ "app-store"(root, [name], options) {
493
+ if (name === "auth" && !fs.existsSync(path.join(root, "src/platform/firebase.ts"))) {
494
+ fail("Auth app-store generation requires src/platform/firebase.ts exporting auth and googleProvider");
495
+ }
496
+ return [plan(appStoreFiles(name, options)), [
497
+ `Use the generated ${name}.store.ts as emitted; do not fork its implementation per project`,
498
+ `Modules may import the store directly; every other app store name is forbidden`
499
+ ]];
500
+ },
501
+ "app-component"(root, [name]) {
502
+ if (name === "ThemeToggle" && !fs.existsSync(path.join(root, "src/app/stores/theme.store.ts"))) {
503
+ fail("ThemeToggle generation requires src/app/stores/theme.store.ts; generate the theme app store first");
504
+ }
505
+ return [plan(appPublicComponentFiles(name)), [
506
+ `Use ThemeToggle as emitted; do not fork its implementation per project`,
507
+ `Keep every other reusable UI capability in its owning module`
508
+ ]];
328
509
  }
329
510
  };
330
511
 
package/src/new.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { GenerateError } from "./generate.mjs";
3
+ import { authAppStoreSource, GenerateError } from "./generate.mjs";
4
4
 
5
5
  function fail(message) {
6
6
  throw new GenerateError(message);
@@ -33,7 +33,9 @@ const svc = `${options.name}-svc`;
33
33
  const ui = `${options.name}-ui`;
34
34
  const files = {};
35
35
  const add = (relative, content) => { files[relative] = content.endsWith("\n") ? content : `${content}\n`; };
36
- const architectureValidatorSource = fs.readFileSync(new URL("../assets/validate-architecture.mjs", import.meta.url), "utf8");
36
+ // Generated repositories depend on the published toolkit rather than copying a validator, and they
37
+ // pin the exact contract version they were generated against.
38
+ const contractVersion = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
37
39
  const prettierConfig = {
38
40
  useTabs: true,
39
41
  tabWidth: 4,
@@ -130,15 +132,15 @@ Use this workflow for any new domain capability or operation. Add only the layer
130
132
  1. Identify the module, requested operations, entry points, and repositories in scope. Classify every intended file with the applicable AGENTS.md placement table before writing. Do not assume full CRUD.
131
133
  2. Read the root and every applicable child \`AGENTS.md\`.
132
134
  3. Inspect the nearest working module and tests.
133
- 4. Create architecture files only with the tailframe CLI: \`tailframe generate\` creates service modules, use cases, http files, ports, adapters, identifiers, and integrations, and UI modules, api clients, views, components, stores, and composables on canonical paths with canonical names. Hand-creating architecture files is a conformance violation; complete the printed wiring checklist after each generation.
134
- 5. Colocate product capability behavior under \`src/modules/<module>\` in both the service and UI. Never substitute \`features\` or global \`apis\`, \`views\`, \`services\`, \`stores\`, or \`types\` directories. Theme state, its toggle, tests, the router, and the application shell always belong under UI \`src/app\`; never create \`src/modules/theme\`. Start backend modules with \`use-cases/\`, \`http/\`, and tests; add \`domain/\` or \`persistence/\` only when required. Add only the UI module directories earned by the capability.
135
+ 4. Create architecture files only with the tailframe CLI: \`tailframe generate\` creates service modules, use cases, http files, ports, adapters, identifiers, integrations, UI modules, and the closed UI shell catalog on canonical paths with canonical names. Hand-creating architecture files or editing generated shell-catalog implementations is a conformance violation; complete the printed wiring checklist after each generation.
136
+ 5. Colocate product capability behavior under \`src/modules/<module>\` in both the service and UI. Never substitute \`features\` or global \`apis\`, \`views\`, \`services\`, \`stores\`, or \`types\` directories. UI \`app/stores\` is closed to generated \`auth.store.ts\` and \`theme.store.ts\`; \`app/public\` is closed to generated \`ThemeToggle.vue\`. Never create \`src/modules/theme\` or \`src/modules/auth\`. Product records, current-user domain records, selections, filters, and workflows stay in their owning module. Start backend modules with \`use-cases/\`, \`http/\`, and tests; add \`domain/\` or \`persistence/\` only when required. Add only the UI module directories earned by the capability.
135
137
  6. Define use cases as \`execute(context, input)\`. Keep trusted \`RequestContext\` separate from client input.
136
138
  7. Put ownership, RBAC, entitlements, and domain policies in use-case or domain code, not client bodies or route metadata.
137
139
  8. Define repository contracts in the owning module and implement them in persistence adapters.
138
140
  9. For persisted capabilities, prefer branded, domain-owned string IDs at domain and repository-port boundaries; generate the module's identifier file with \`tailframe generate identifiers <module> <NameId...>\`. Keep MongoDB \`ObjectId\` conversion inside MongoDB persistence adapters, using persistence-only document types rather than \`any\` to bypass the boundary.
139
141
  10. Let HTTP routes, jobs, workers, scheduled tasks, and CLIs invoke use cases directly; do not call one entry point from another.
140
- 11. Keep operation names, payloads, authentication, response contracts, and client types synchronized. A service module may import another only through a named file directly under the provider's use-cases directory and must keep the module dependency graph acyclic. UI modules never import one another; compose them in UI app.
141
- 12. Add required container, route, client, navigation, worker, or process registration and focused tests.
142
+ 11. Keep operation names, payloads, authentication, response contracts, and client types synchronized. A service module may import another only through a named file directly under the provider's use-cases directory. A UI module may import another only through a named file directly under the provider's public directory. Both graphs must remain acyclic. UI modules may import only the generated auth/theme app stores and \`ThemeToggle\` from app; every other app path is private.
143
+ 12. Add required container, route, client, navigation, worker, or process registration and focused tests. Route module views directly from \`app/router.ts\`. App views and shell components consume product modules only through named module \`public/\` entries; they never import module views or other internals.
142
144
  13. Run npm run validate:architecture in every affected repository, then run focused type checks and tests. Report repositories, operations, entry points, checks, placement decisions, and gaps.
143
145
 
144
146
  Do not create empty architectural layers, speculative operations, or mandatory controllers. Do not claim behavior from static files, weaken trusted context or persistence ownership, or change unrelated background behavior.
@@ -158,6 +160,7 @@ const svcDeps = {
158
160
  };
159
161
  const svcDevDeps = {
160
162
  "@types/cors": "^2.8.17", "@types/express": "^5.0.1", "@types/jest": "^30.0.0", "@types/node": "^22.13.14", "@types/supertest": "^7.2.1",
163
+ "@zaaxch/tailframe": contractVersion,
161
164
  jest: "^30.0.0", nodemon: "^3.1.9", prettier: "3.5.3", "ts-jest": "^29.4.11", "ts-node": "^10.9.2",
162
165
  "tsc-alias": "^1.8.16", "tsconfig-paths": "^4.2.0", typescript: "^5.8.2", supertest: "^7.2.2"
163
166
  };
@@ -169,14 +172,13 @@ add(`${svc}/package.json`, JSON.stringify({
169
172
  ...(options.worker ? { "dev:worker": "nodemon --legacy-watch --exec ts-node -r tsconfig-paths/register src/worker.ts" } : {}),
170
173
  build: "tsc && tsc-alias", start: "node dist/server.js",
171
174
  ...(options.worker ? { worker: "node dist/worker.js" } : {}),
172
- test: "npm run test:unit && npm run test:integration", "test:unit": "jest --selectProjects unit --runInBand", "test:integration": "jest --selectProjects integration --runInBand", "validate:architecture": "node scripts/validate-architecture.mjs --kind service .", format: "prettier --write src/",
175
+ test: "npm run test:unit && npm run test:integration", "test:unit": "jest --selectProjects unit --runInBand", "test:integration": "jest --selectProjects integration --runInBand", "validate:architecture": "tailframe validate --kind service .", format: "prettier --write src/",
173
176
  "test:db:up": "docker compose -f docker-compose.test.yml up -d", "test:db:down": "docker compose -f docker-compose.test.yml down -v",
174
177
  "docker:dev": "docker compose -f docker-compose.dev.yml up",
175
178
  "docker:prod": "docker compose -f docker-compose.yml up -d",
176
179
  "docker:push": "bash scripts/build_and_push.sh"
177
180
  }, dependencies: svcDeps, devDependencies: svcDevDeps
178
181
  }, null, "\t"));
179
- add(`${svc}/scripts/validate-architecture.mjs`, architectureValidatorSource);
180
182
  add(`${svc}/tsconfig.json`, JSON.stringify({
181
183
  compilerOptions: { target: "ES2022", module: "commonjs", rootDir: "src", outDir: "dist", strict: true, esModuleInterop: true, experimentalDecorators: true, emitDecoratorMetadata: true, baseUrl: ".", paths: { "@/*": ["src/*"] }, skipLibCheck: true },
182
184
  include: ["src/**/*.ts"]
@@ -396,7 +398,7 @@ These conventions apply to \`${svc}\`. This service is a modular monolith organi
396
398
  | Cross-entry-point technology-neutral contract | Flat \`src/core\` |
397
399
  | Root executable | Bootstrap of matching \`src/app\` assembly only |
398
400
 
399
- Product vocabulary stays in the module that owns its meaning; never move it into \`core\`. A module may import another only through a named file directly under the provider's \`use-cases/\`; its domain, HTTP, persistence, ports, and tests remain private, and cross-module use-case dependencies must be acyclic.
401
+ Product vocabulary stays in the module that owns its meaning; never move it into \`core\`. A module may import another only through a named file directly under the provider's \`use-cases/\`; its domain, HTTP, persistence, ports, and tests remain private, and cross-module use-case dependencies must be acyclic. Use \`@/\` or relative internal imports; do not add another source alias.
400
402
 
401
403
  ## Public API contract
402
404
  - Use singular RPC-shaped \`POST /api/v1/<module>.<operation>\` operations and the \`{ result: ... }\` success envelope.
@@ -442,9 +444,8 @@ Run \`npm run validate:architecture\` after changing files or imports. The gener
442
444
 
443
445
  if (options.ui) {
444
446
  const uiDeps = { "@tailwindcss/vite": "^4.1.18", "@vueuse/core": "^14.3.0", axios: "^1.9.0", pinia: "^3.0.1", primevue: "^4.2.5", vue: "^3.5.13", "vue-router": "^4.5.0", ...(options.auth === "firebase" ? { firebase: "^11.0.0" } : {}) };
445
- const uiDevDeps = { "@tsconfig/node22": "^22.0.1", "@types/node": "^22.13.14", "@vitejs/plugin-vue": "^5.2.3", "@vue/test-utils": "^2.4.10", "@vue/tsconfig": "^0.7.0", jsdom: "^29.1.1", "npm-run-all2": "^7.0.2", prettier: "3.5.3", typescript: "~5.8.0", vite: "^6.2.4", vitest: "^4.1.7", "vue-tsc": "^2.2.8" };
446
- add(`${ui}/package.json`, JSON.stringify({ name: ui, version: "0.1.0", private: true, type: "module", scripts: { dev: "vite --host 0.0.0.0", build: "run-p type-check \"build-only {@}\" --", "build-only": "vite build", "type-check": "vue-tsc --build", test: "vitest", "validate:architecture": "node scripts/validate-architecture.mjs --kind ui .", format: "prettier --write src/" }, dependencies: uiDeps, devDependencies: uiDevDeps }, null, "\t"));
447
- add(`${ui}/scripts/validate-architecture.mjs`, architectureValidatorSource);
447
+ const uiDevDeps = { "@tsconfig/node22": "^22.0.1", "@zaaxch/tailframe": contractVersion, "@types/node": "^22.13.14", "@vitejs/plugin-vue": "^5.2.3", "@vue/test-utils": "^2.4.10", "@vue/tsconfig": "^0.7.0", jsdom: "^29.1.1", "npm-run-all2": "^7.0.2", prettier: "3.5.3", typescript: "~5.8.0", vite: "^6.2.4", vitest: "^4.1.7", "vue-tsc": "^2.2.8" };
448
+ add(`${ui}/package.json`, JSON.stringify({ name: ui, version: "0.1.0", private: true, type: "module", scripts: { dev: "vite --host 0.0.0.0", build: "run-p type-check \"build-only {@}\" --", "build-only": "vite build", "type-check": "vue-tsc --build", test: "vitest", "validate:architecture": "tailframe validate --kind ui .", format: "prettier --write src/" }, dependencies: uiDeps, devDependencies: uiDevDeps }, null, "\t"));
448
449
  add(`${ui}/.prettierrc.json`, JSON.stringify(prettierConfig, null, "\t"));
449
450
  add(`${ui}/tsconfig.json`, JSON.stringify({ files: [], references: [{ path: "./tsconfig.app.json" }, { path: "./tsconfig.node.json" }] }, null, "\t"));
450
451
  add(`${ui}/tsconfig.app.json`, JSON.stringify({ extends: "@vue/tsconfig/tsconfig.dom.json", include: ["env.d.ts", "src/**/*", "src/**/*.vue"], compilerOptions: { composite: true, baseUrl: ".", paths: { "@/*": ["./src/*"] } } }, null, "\t"));
@@ -468,12 +469,13 @@ add(`${ui}/.gitignore`, `node_modules/\ndist/\n.env*\n!.env.example\ncerts/\n*.l
468
469
  if (options.auth === "firebase") add(`${ui}/.env.example`, `VITE_FIREBASE_API_KEY=\nVITE_FIREBASE_AUTH_DOMAIN=\nVITE_FIREBASE_PROJECT_ID=\nVITE_FIREBASE_APP_ID=\n`);
469
470
  add(`${ui}/src/core/rpc.ts`, `export interface RpcResponse<T> { result: T; }\nexport interface RpcError { error: { message: string; code?: string }; }`);
470
471
  if (options.auth === "firebase") {
471
- add(`${ui}/src/platform/firebase.ts`, `import { initializeApp } from "firebase/app";\nexport const firebaseApp = initializeApp({ apiKey: import.meta.env.VITE_FIREBASE_API_KEY, authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN, projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID, appId: import.meta.env.VITE_FIREBASE_APP_ID });`);
472
- add(`${ui}/src/app/stores/auth.ts`, `import { defineStore } from "pinia";\nimport { getAuth, onAuthStateChanged, signOut, type User } from "firebase/auth";\nimport { ref } from "vue";\nimport { firebaseApp } from "@/platform/firebase";\nexport const useAuthStore = defineStore("auth", () => { const firebaseUser = ref<User|null>(null); const ready = ref(false); const auth = getAuth(firebaseApp); onAuthStateChanged(auth, user => { firebaseUser.value = user; ready.value = true; }); const getIdToken = () => firebaseUser.value?.getIdToken(); const logout = () => signOut(auth); return { firebaseUser, ready, getIdToken, logout }; });`);
472
+ add(`${ui}/src/platform/firebase.ts`, `import { initializeApp } from "firebase/app";\nimport { getAuth, GoogleAuthProvider } from "firebase/auth";\n\nconst firebaseApp = initializeApp({\n\tapiKey: import.meta.env.VITE_FIREBASE_API_KEY,\n\tauthDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,\n\tprojectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,\n\tappId: import.meta.env.VITE_FIREBASE_APP_ID\n});\n\nexport const auth = getAuth(firebaseApp);\nexport const googleProvider = new GoogleAuthProvider();`);
473
+ add(`${ui}/src/app/stores/auth.store.ts`, authAppStoreSource());
473
474
  }
474
- add(`${ui}/src/app/router.ts`, `import { createRouter, createWebHistory } from "vue-router";\nexport enum RouteNames { HOME = "Home" }\nexport default createRouter({ history: createWebHistory(), routes: [{ path: "/", name: RouteNames.HOME, component: () => import("@/app/views/HomeView.vue") }] });`);
475
+ add(`${ui}/src/core/RouteNames.ts`, `/** Route vocabulary shared by the router and by modules. Modules never import the router itself. */\nexport enum RouteNames {\n\tHOME = "Home"\n}\n`);
476
+ add(`${ui}/src/app/router.ts`, `import { createRouter, createWebHistory } from "vue-router";\nimport { RouteNames } from "@/core/RouteNames";\nexport default createRouter({ history: createWebHistory(), routes: [{ path: "/", name: RouteNames.HOME, component: () => import("@/app/views/HomeView.vue") }] });`);
475
477
  add(`${ui}/src/platform/http.ts`, `import axios from "axios";\nexport const http = axios.create({ baseURL: \`\${window.location.origin}/api/v1\`, headers: { "Content-Type": "application/json" } });`);
476
- if (options.auth === "firebase") add(`${ui}/src/app/configureHttp.ts`, `import type { Pinia } from "pinia";\nimport router, { RouteNames } from "@/app/router";\nimport { useAuthStore } from "@/app/stores/auth";\nimport { http } from "@/platform/http";\nexport function configureHttp(pinia: Pinia) {\n\tconst auth = useAuthStore(pinia);\n\thttp.interceptors.request.use(async config => { const token = await auth.getIdToken(); if (token) config.headers.Authorization = \`Bearer \${token}\`; return config; });\n\thttp.interceptors.response.use(response => response, async error => { if (error?.response?.status === 401) { await auth.logout(); await router.push({ name: RouteNames.HOME }); } return Promise.reject(error); });\n}\n`);
478
+ if (options.auth === "firebase") add(`${ui}/src/app/configureHttp.ts`, `import type { Pinia } from "pinia";\nimport router from "@/app/router";\nimport { RouteNames } from "@/core/RouteNames";\nimport { useAuthStore } from "@/app/stores/auth.store";\nimport { http } from "@/platform/http";\nexport function configureHttp(pinia: Pinia) {\n\tconst auth = useAuthStore(pinia);\n\thttp.interceptors.request.use(async config => { const token = await auth.getIdToken(); if (token) config.headers.Authorization = \`Bearer \${token}\`; return config; });\n\thttp.interceptors.response.use(response => response, async error => { if (error?.response?.status === 401) { await auth.logout(); await router.push({ name: RouteNames.HOME }); } return Promise.reject(error); });\n}\n`);
477
479
  add(`${ui}/src/modules/health/api/health.api.ts`, `import { http } from "@/platform/http";\nimport type { RpcResponse } from "@/core/rpc";\nexport async function getHealth() { return (await http.post<RpcResponse<{ status: string }>>("health.get")).data.result; }`);
478
480
  add(`${ui}/src/app/views/HomeView.vue`, `<template><main class="mx-auto flex min-h-screen max-w-5xl flex-col justify-center gap-4 px-6 py-16"><p class="font-bold uppercase tracking-[0.12em] text-emerald-800">Project foundation</p><h1 class="m-0 text-5xl font-bold sm:text-7xl">${title}</h1><p>The product domain is intentionally undefined.</p></main></template>`);
479
481
  add(`${ui}/src/app/App.vue`, `<template><RouterView /></template><script setup lang="ts">import { RouterView } from "vue-router";</script>`);
@@ -487,26 +489,30 @@ add(`${ui}/AGENTS.md`, `# ${title} UI guidance
487
489
  These conventions apply to \`${ui}/src\`. Inspect the nearest working module before adding files.
488
490
 
489
491
  ## Architecture vocabulary
490
- - Use \`src/app\` for Vue bootstrap, the application shell, routing, global styles, and truly application-wide state.
492
+ - Use \`src/app\` for Vue bootstrap, the application shell, routing, global styles, and the closed Tailframe auth/theme catalog.
491
493
  - Use \`src/core\` only for small technology-neutral client contracts.
492
494
  - Use \`src/platform\` for Axios, Firebase, browser APIs, and other technical adapters.
493
495
  - Use \`src/modules/<module>\` for every product capability. Never substitute \`src/features\` or global \`apis\`, \`views\`, \`services\`, \`stores\`, or \`types\` directories.
494
- - These are the only UI architecture roots. Keep \`src/core\` flat. Shared capability code belongs to the module that owns it; reusable application-shell presentation belongs under \`src/app\`, whose optional directories are limited to \`components\`, \`stores\`, \`views\`, and \`__tests__\`.
495
- - Keep application-wide theme state at \`src/app/stores/theme.store.ts\`, its toggle at \`src/app/components/ThemeToggle.vue\`, and its tests under \`src/app/__tests__\`. Never create \`src/modules/theme\`.
496
+ - These are the only UI architecture roots. Keep \`src/core\` flat. Shared capability code belongs to the module that owns it. \`src/app/stores\` is closed to generated \`auth.store.ts\` and \`theme.store.ts\`; \`src/app/public\` is closed to generated \`ThemeToggle.vue\`. Do not edit their implementations; only the generated theme storage key varies. Internal shell components remain private under \`src/app/components\`.
497
+ - Never create \`src/modules/theme\` or \`src/modules/auth\`. Product records, current-user domain records, selections, filters, workflows, and module API calls always belong to the owning product module.
496
498
 
497
499
  ## Change placement
498
500
  | Change | Canonical owner |
499
501
  | --- | --- |
500
502
  | Capability API, component, composable, state, type, or view | \`src/modules/<module>\` |
503
+ | Capability intentionally consumed by another module | Named file directly under provider \`src/modules/<module>/public\` |
501
504
  | Axios, Firebase, browser, or vendor mechanism | \`src/platform\` |
502
- | Shell, router, global state/theme, or cross-capability composition | \`src/app\` |
505
+ | Firebase identity/readiness or theme preference | Generated \`src/app/stores/auth.store.ts\` or \`theme.store.ts\` |
506
+ | Canonical theme control consumed by modules | Generated \`src/app/public/ThemeToggle.vue\` |
507
+ | Any other state or reusable presentation | Owning \`src/modules/<module>\` |
508
+ | Shell, router, global styles, or cross-capability composition | \`src/app\` |
503
509
  | Technology-neutral client contract | Flat \`src/core\` |
504
510
  | Root executable | Bootstrap of \`src/app\` only |
505
511
 
506
- UI modules never import one another. Compose capabilities in \`src/app\`; platform code never imports app or module code.
512
+ UI modules may import a sibling only through a named file directly under the provider's \`public/\` directory; keep the graph acyclic. From app, modules may import only generated \`auth.store.ts\`, \`theme.store.ts\`, and \`ThemeToggle.vue\`; every other app path is private. App views and shell components consume modules only through named module \`public/\` entries; route module views directly from \`app/router.ts\`. Use \`@/\` or relative internal imports; do not add another source alias. Platform imports neither app nor modules.
507
513
 
508
514
  ## Module shape
509
- Under \`src/modules/<module>\`, add only needed \`api/\`, \`components/\`, \`composables/\`, \`routes/\`, \`stores/\`, \`types/\`, \`views/\`, and \`__tests__/\` directories. Do not invent alternative layer names or generate speculative CRUD. Create module files only with \`tailframe generate\`.
515
+ Under \`src/modules/<module>\`, add only needed \`api/\`, \`components/\`, \`composables/\`, \`public/\`, \`routes/\`, \`stores/\`, \`types/\`, \`views/\`, and \`__tests__/\` directories. Public entries are named direct files, never nested directories or barrels. Do not invent alternative layer names or generate speculative CRUD. Create module and public shell files only with \`tailframe generate\`.
510
516
 
511
517
  ## API contract
512
518
  - Use the shared Axios client and singular \`POST <module>.<operation>\` calls.
@@ -518,6 +524,7 @@ Use named routes.${options.auth === "firebase" ? " Preserve Firebase authenticat
518
524
 
519
525
  ## State management
520
526
  Keep local state local. Use Pinia only for state shared across routes or unrelated components.
527
+ Keep all product state in the owning module. The auth/theme stores and ThemeToggle are Tailframe-owned generated sources shared byte-for-byte across projects apart from the explicit theme storage key.
521
528
 
522
529
  ## UI behavior
523
530
  Reuse Vue, PrimeVue, Tailwind, loading, error, and accessibility conventions. Provide explicit loading, empty, error, and authorization states.
package/src/validate.mjs CHANGED
@@ -2,6 +2,8 @@ import { validateArchitecture } from "./architecture.mjs";
2
2
  import { validateConventions } from "./conventions.mjs";
3
3
  import { isExcepted, loadExceptions } from "./exceptions.mjs";
4
4
 
5
+ const NON_EXCEPTABLE_RULES = new Set(["U5", "U6", "U7"]);
6
+
5
7
  export function runValidate(root, kind) {
6
8
  const structural = validateArchitecture(root, kind);
7
9
  if (structural.some((error) => error.startsWith("Architecture root") || error.startsWith("Architecture kind"))) {
@@ -9,7 +11,7 @@ export function runValidate(root, kind) {
9
11
  }
10
12
  const exceptions = loadExceptions(root);
11
13
  const conventions = validateConventions(root, kind)
12
- .filter((violation) => !isExcepted(violation.path, exceptions))
14
+ .filter((violation) => NON_EXCEPTABLE_RULES.has(violation.rule) || !isExcepted(violation.path, exceptions))
13
15
  .map((violation) => `${violation.rule}: ${violation.message}`);
14
16
  return [...structural, ...conventions];
15
17
  }
@@ -1,279 +0,0 @@
1
- #!/usr/bin/env node
2
- import fs from "node:fs";
3
- import path from "node:path";
4
- import { fileURLToPath } from "node:url";
5
-
6
- const SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".vue"]);
7
-
8
- function toPosix(value) {
9
- return value.split(path.sep).join("/");
10
- }
11
-
12
- function listEntries(target, kind) {
13
- return fs.readdirSync(target, { withFileTypes: true })
14
- .filter((entry) => kind === "directory" ? entry.isDirectory() : entry.isFile())
15
- .map((entry) => entry.name)
16
- .sort();
17
- }
18
-
19
- function walk(target) {
20
- const results = [];
21
- for (const entry of fs.readdirSync(target, { withFileTypes: true })) {
22
- if (["node_modules", "dist", ".git"].includes(entry.name)) continue;
23
- const absolute = path.join(target, entry.name);
24
- if (entry.isDirectory()) results.push(...walk(absolute));
25
- else if (SOURCE_EXTENSIONS.has(path.extname(entry.name))) results.push(absolute);
26
- }
27
- return results;
28
- }
29
-
30
- function extractImports(source, relative) {
31
- const code = relative.endsWith(".vue")
32
- ? [...source.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/gi)].map((match) => match[1]).join("\n")
33
- : source;
34
- const script = code.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
35
- const imports = [];
36
- const staticPattern = /(?:^|[;\n])\s*(?:import|export)\s+(?:type\s+)?(?:[^"'`;]*?\s+from\s+)?["']([^"']+)["']/g;
37
- const dynamicPattern = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g;
38
- for (const pattern of [staticPattern, dynamicPattern]) {
39
- for (const match of script.matchAll(pattern)) imports.push(match[1]);
40
- }
41
- return [...new Set(imports)];
42
- }
43
-
44
- function resolveInternal(relativeFile, specifier) {
45
- if (specifier.startsWith("@/")) return path.posix.normalize(`src/${specifier.slice(2)}`);
46
- if (!specifier.startsWith(".")) return undefined;
47
- return path.posix.normalize(path.posix.join(path.posix.dirname(relativeFile), specifier));
48
- }
49
-
50
- function isTestFile(relative) {
51
- return relative.includes("/__tests__/") || /\.(?:test|spec)\.[^.]+$/.test(relative);
52
- }
53
-
54
- function moduleParts(relative) {
55
- const match = relative.match(/^src\/modules\/([^/]+)\/([^/]+)(?:\/(.*))?$/);
56
- if (!match) return undefined;
57
- return { module: match[1], layer: match[2], remainder: match[3] ?? "" };
58
- }
59
-
60
- function isNamedUseCase(relative, expectedModule) {
61
- const target = moduleParts(relative);
62
- return Boolean(
63
- target &&
64
- target.module === expectedModule &&
65
- target.layer === "use-cases" &&
66
- target.remainder &&
67
- !target.remainder.includes("/") &&
68
- !isTestFile(relative)
69
- );
70
- }
71
-
72
- function validateStructure(root, kind, fail) {
73
- const src = path.join(root, "src");
74
- if (!fs.existsSync(src) || !fs.statSync(src).isDirectory()) {
75
- fail("Missing architecture directory: src");
76
- return;
77
- }
78
-
79
- const checkEntries = (relative, entryKind, allowed) => {
80
- const target = path.join(root, relative);
81
- if (!fs.existsSync(target) || !fs.statSync(target).isDirectory()) {
82
- fail(`Missing architecture directory: ${relative}`);
83
- return;
84
- }
85
- for (const name of listEntries(target, entryKind)) {
86
- if (!allowed.includes(name)) fail(`Unexpected architecture ${entryKind}: ${relative}/${name}`);
87
- }
88
- };
89
-
90
- if (kind === "service") {
91
- checkEntries("src", "directory", ["__tests__", "app", "core", "modules", "platform"]);
92
- checkEntries("src", "file", ["server.ts", "worker.ts"]);
93
- checkEntries("src/app", "directory", ["__tests__", "cli", "jobs", "mcp", "scheduled-tasks", "workers"]);
94
- checkEntries("src/core", "directory", []);
95
- } else {
96
- checkEntries("src", "directory", ["app", "core", "modules", "platform"]);
97
- checkEntries("src", "file", ["main.ts"]);
98
- checkEntries("src/app", "directory", ["__tests__", "components", "stores", "views"]);
99
- checkEntries("src/core", "directory", []);
100
- }
101
-
102
- const platform = path.join(root, "src/platform");
103
- if (!fs.existsSync(platform) || !fs.statSync(platform).isDirectory()) fail("Missing architecture directory: src/platform");
104
- const modules = path.join(root, "src/modules");
105
- if (!fs.existsSync(modules) || !fs.statSync(modules).isDirectory()) {
106
- fail("Missing architecture directory: src/modules");
107
- return;
108
- }
109
- const allowedModuleDirectories = kind === "service"
110
- ? ["__tests__", "domain", "http", "persistence", "use-cases"]
111
- : ["__tests__", "api", "components", "composables", "routes", "stores", "types", "views"];
112
- 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");
114
- checkEntries(`src/modules/${moduleName}`, "directory", allowedModuleDirectories);
115
- checkEntries(`src/modules/${moduleName}`, "file", []);
116
- }
117
- }
118
-
119
- function validateServiceImport(source, target, external, fail, graph) {
120
- if (source === "src/server.ts" || source === "src/worker.ts") {
121
- if (external) {
122
- if (external !== "reflect-metadata") fail(`${source} may import only reflect-metadata and app bootstrap code, not ${external}`);
123
- } else {
124
- const expectedAssembly = source === "src/server.ts" ? "src/app/server" : "src/app/workers/";
125
- if (!target.startsWith(expectedAssembly)) fail(`${source} may import only its matching app assembly, not ${target}`);
126
- }
127
- return;
128
- }
129
- if (source.startsWith("src/core/")) {
130
- if (external || !target.startsWith("src/core/")) fail(`${source} violates core purity by importing ${external ?? target}`);
131
- return;
132
- }
133
- if (source.startsWith("src/app/") || source.startsWith("src/__tests__/")) return;
134
- if (source.startsWith("src/platform/")) {
135
- if (external) return;
136
- if (target.startsWith("src/core/") || target.startsWith("src/platform/")) return;
137
- if (source.startsWith("src/platform/integrations/") && /^src\/modules\/[^/]+\/use-cases\/ports\//.test(target)) return;
138
- fail(`${source} may import only core, platform, or a module-owned integration port, not ${target}`);
139
- return;
140
- }
141
-
142
- const sourceModule = moduleParts(source);
143
- if (!sourceModule) return;
144
- const moduleTest = isTestFile(source) || sourceModule.layer === "__tests__";
145
- if (external) {
146
- if (!moduleTest && ["domain", "use-cases"].includes(sourceModule.layer)) {
147
- fail(`${source} in ${sourceModule.layer} may not import framework or vendor package ${external}`);
148
- }
149
- return;
150
- }
151
- if (target.startsWith("src/core/")) return;
152
- const targetModule = moduleParts(target);
153
- if (target.startsWith("src/platform/")) {
154
- if (["http", "persistence"].includes(sourceModule.layer) || moduleTest) return;
155
- fail(`${source} cannot import platform code from ${sourceModule.layer}`);
156
- return;
157
- }
158
- if (target.startsWith("src/app/") || target.startsWith("src/__tests__/")) {
159
- if (moduleTest) return;
160
- fail(`${source} cannot import application assembly or cross-boundary test support`);
161
- return;
162
- }
163
- if (!targetModule) {
164
- fail(`${source} imports unsupported internal path ${target}`);
165
- return;
166
- }
167
- if (targetModule.module !== sourceModule.module) {
168
- if ((sourceModule.layer === "use-cases" || moduleTest) && isNamedUseCase(target, targetModule.module)) {
169
- if (!moduleTest) {
170
- if (!graph.has(sourceModule.module)) graph.set(sourceModule.module, new Set());
171
- graph.get(sourceModule.module).add(targetModule.module);
172
- }
173
- return;
174
- }
175
- fail(`${source} may import another module only through a named use-case file, not ${target}`);
176
- return;
177
- }
178
-
179
- if (moduleTest) return;
180
- const allowedLayers = {
181
- domain: ["domain"],
182
- "use-cases": ["domain", "use-cases"],
183
- http: ["domain", "http", "use-cases"],
184
- persistence: ["domain", "persistence", "use-cases"]
185
- }[sourceModule.layer] ?? [];
186
- if (!allowedLayers.includes(targetModule.layer)) {
187
- fail(`${source} in ${sourceModule.layer} cannot import its module's ${targetModule.layer} layer`);
188
- }
189
- }
190
-
191
- function validateUiImport(source, target, external, fail) {
192
- if (source === "src/main.ts") {
193
- if (external || !target.startsWith("src/app/")) fail(`src/main.ts may import only app bootstrap code, not ${external ?? target}`);
194
- return;
195
- }
196
- if (source.startsWith("src/core/")) {
197
- if (external || !target.startsWith("src/core/")) fail(`${source} violates core purity by importing ${external ?? target}`);
198
- return;
199
- }
200
- if (source.startsWith("src/app/")) return;
201
- if (source.startsWith("src/platform/")) {
202
- if (external || target.startsWith("src/core/") || target.startsWith("src/platform/")) return;
203
- fail(`${source} may not import app or module code: ${target}`);
204
- return;
205
- }
206
- const sourceModule = moduleParts(source);
207
- if (!sourceModule || external) return;
208
- if (target.startsWith("src/core/") || target.startsWith("src/platform/")) return;
209
- const targetModule = moduleParts(target);
210
- if (targetModule?.module === sourceModule.module) return;
211
- fail(`${source} may import only its own UI module, core, or platform, not ${target}`);
212
- }
213
-
214
- function detectCycles(graph, fail) {
215
- const visited = new Set();
216
- const active = [];
217
- const activeSet = new Set();
218
- const visit = (moduleName) => {
219
- if (activeSet.has(moduleName)) {
220
- const start = active.indexOf(moduleName);
221
- fail(`Cross-module use-case dependency cycle: ${[...active.slice(start), moduleName].join(" -> ")}`);
222
- return;
223
- }
224
- if (visited.has(moduleName)) return;
225
- visited.add(moduleName);
226
- active.push(moduleName);
227
- activeSet.add(moduleName);
228
- for (const dependency of graph.get(moduleName) ?? []) visit(dependency);
229
- active.pop();
230
- activeSet.delete(moduleName);
231
- };
232
- for (const moduleName of graph.keys()) visit(moduleName);
233
- }
234
-
235
- export function validateArchitecture(rootArgument, kind) {
236
- const root = path.resolve(rootArgument);
237
- const errors = [];
238
- const fail = (message) => errors.push(message);
239
- if (!["service", "ui"].includes(kind)) return [`Architecture kind must be service or ui, received ${kind ?? "nothing"}`];
240
- if (!fs.existsSync(root)) return [`Architecture root does not exist: ${root}`];
241
- validateStructure(root, kind, fail);
242
- const src = path.join(root, "src");
243
- if (!fs.existsSync(src)) return errors;
244
- const graph = new Map();
245
- for (const absolute of walk(src)) {
246
- const relative = toPosix(path.relative(root, absolute));
247
- for (const specifier of extractImports(fs.readFileSync(absolute, "utf8"), relative)) {
248
- const target = resolveInternal(relative, specifier);
249
- if (target && !target.startsWith("src/")) {
250
- fail(`${relative} has a relative import outside src: ${specifier}`);
251
- continue;
252
- }
253
- if (kind === "service") validateServiceImport(relative, target, target ? undefined : specifier, fail, graph);
254
- else validateUiImport(relative, target, target ? undefined : specifier, fail);
255
- }
256
- }
257
- if (kind === "service") detectCycles(graph, fail);
258
- return errors;
259
- }
260
-
261
- function parseCli(argv) {
262
- const args = [...argv];
263
- let kind;
264
- let root = ".";
265
- while (args.length) {
266
- const value = args.shift();
267
- if (value === "--kind") kind = args.shift();
268
- else root = value;
269
- }
270
- return { kind, root };
271
- }
272
-
273
- if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
274
- const { kind, root } = parseCli(process.argv.slice(2));
275
- const errors = validateArchitecture(root, kind);
276
- for (const error of errors) console.error(error);
277
- if (errors.length) process.exit(1);
278
- console.log(`Validated ${kind} architecture: ${path.resolve(root)}`);
279
- }