@zaaxch/tailframe 2.2.0 → 4.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
@@ -1,30 +1,52 @@
1
1
  #!/usr/bin/env node
2
2
  import path from "node:path";
3
3
  import { createRequire } from "node:module";
4
- import { runValidate } from "../src/validate.mjs";
4
+ import { APP_KINDS } from "../src/config.mjs";
5
+ import { runConfiguredValidate } from "../src/validate.mjs";
5
6
  import { runGenerate, GenerateError } from "../src/generate.mjs";
6
7
  import { createProject } from "../src/new.mjs";
8
+ import { runSync } from "../src/sync.mjs";
7
9
 
8
10
  const require = createRequire(import.meta.url);
9
11
 
10
12
  function usage() {
11
- console.error("Usage: tailframe validate --kind service|ui [root]");
12
- console.error(" tailframe new <name> [--path <parent>] [--auth none|firebase] [--ui] [--redis] [--worker]");
13
- console.error(" tailframe generate <schematic> <args...> [--options]");
13
+ console.error("Usage: tailframe validate [root] [--app service|ui]");
14
+ console.error(" tailframe sync --check|--write [root]");
15
+ console.error(" tailframe new <name> [--path <parent>] [--db mongo|postgres] [--auth none|firebase] [--ui] [--redis] [--worker]");
16
+ console.error(" tailframe generate --app service|ui <schematic> <args...> [--options]");
14
17
  console.error(" service schematics: module <name> <VerbNoun> [--no-http], use-case <module> <VerbNoun>,");
15
18
  console.error(" http <module> <VerbNoun>, port <module> <Name>, adapter <module> <PortName> --db <technology>,");
16
19
  console.error(" identifiers <module> <NameId...>, integration <provider>");
17
20
  console.error(" ui schematics: module <name> --api|--view <Name>|--component <Name>|--store <name>|--composable <name>|--public <name>|--public-component <Name>,");
18
21
  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");
22
+ console.error(" public <module> <name>, public-component <module> <Name>, app-store auth|notification, app-store theme --storage-key <key>, app-component ThemeToggle");
20
23
  process.exit(2);
21
24
  }
22
25
 
23
26
  const args = process.argv.slice(2);
24
27
  const command = args.shift();
28
+ const contractVersion = require("../package.json").version;
25
29
 
26
30
  if (command === "--version" || command === "-v") {
27
- console.log(require("../package.json").version);
31
+ console.log(contractVersion);
32
+ process.exit(0);
33
+ }
34
+ if (command === "sync") {
35
+ let mode;
36
+ let root = ".";
37
+ while (args.length) {
38
+ const value = args.shift();
39
+ if (value === "--check") mode = "check";
40
+ else if (value === "--write") mode = "write";
41
+ else if (value.startsWith("-")) usage();
42
+ else root = value;
43
+ }
44
+ if (!mode) usage();
45
+ const result = runSync(root, mode, contractVersion);
46
+ for (const error of result.errors) console.error(error);
47
+ for (const file of result.changed) console.log(`Updated ${file}`);
48
+ if (result.errors.length) process.exit(1);
49
+ if (mode === "check") console.log(`Canonical Tailframe sources match ${contractVersion}: ${path.resolve(root)}`);
28
50
  process.exit(0);
29
51
  }
30
52
  if (command === "new") {
@@ -33,7 +55,7 @@ if (command === "new") {
33
55
  const rest = [];
34
56
  for (let index = 0; index < args.length; index += 1) {
35
57
  const value = args[index];
36
- if (value === "--path" || value === "--auth") rest.push(value, args[++index]);
58
+ if (value === "--path" || value === "--auth" || value === "--db") rest.push(value, args[++index]);
37
59
  else if (value.startsWith("--")) rest.push(value);
38
60
  else if (name === undefined) name = value;
39
61
  else rest.push(value);
@@ -52,7 +74,15 @@ if (command === "new") {
52
74
  }
53
75
  if (command === "generate") {
54
76
  try {
55
- const { created, checklist } = runGenerate(".", args);
77
+ let selectedKind;
78
+ const rest = [];
79
+ while (args.length) {
80
+ const value = args.shift();
81
+ if (value === "--app") selectedKind = args.shift();
82
+ else rest.push(value);
83
+ }
84
+ if (!APP_KINDS.has(selectedKind)) usage();
85
+ const { created, checklist } = runGenerate(".", rest, selectedKind);
56
86
  console.log("Created:");
57
87
  for (const file of created) console.log(` ${file}`);
58
88
  console.log("Next steps:");
@@ -68,17 +98,17 @@ if (command === "generate") {
68
98
  }
69
99
  if (command !== "validate") usage();
70
100
 
71
- let kind;
72
101
  let root = ".";
102
+ let selectedKind;
73
103
  while (args.length) {
74
104
  const value = args.shift();
75
- if (value === "--kind") kind = args.shift();
105
+ if (value === "--app") selectedKind = args.shift();
76
106
  else if (value.startsWith("-")) usage();
77
107
  else root = value;
78
108
  }
79
- if (!["service", "ui"].includes(kind)) usage();
109
+ if (selectedKind && !APP_KINDS.has(selectedKind)) usage();
80
110
 
81
- const errors = runValidate(root, kind);
111
+ const errors = runConfiguredValidate(root, contractVersion, selectedKind);
82
112
  for (const error of errors) console.error(error);
83
113
  if (errors.length) process.exit(1);
84
- console.log(`Validated ${kind} contract ${require("../package.json").version}: ${path.resolve(root)}`);
114
+ console.log(`Validated Tailframe contract ${contractVersion}${selectedKind ? ` for ${selectedKind}` : ""}: ${path.resolve(root)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zaaxch/tailframe",
3
- "version": "2.2.0",
3
+ "version": "4.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,9 +4,13 @@ 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"];
7
+ const APP_STORE_FILES = ["auth.store.ts", "notification.store.ts", "theme.store.ts"];
8
8
  const APP_PUBLIC_FILES = ["ThemeToggle.vue"];
9
- const APP_STORE_TARGETS = new Set(["src/app/stores/auth.store", "src/app/stores/theme.store"]);
9
+ const APP_STORE_TARGETS = new Set([
10
+ "src/app/stores/auth.store",
11
+ "src/app/stores/notification.store",
12
+ "src/app/stores/theme.store"
13
+ ]);
10
14
  const APP_PUBLIC_TARGETS = new Set(["src/app/public/ThemeToggle"]);
11
15
 
12
16
  function withoutSourceExtension(target) {
@@ -138,8 +142,8 @@ function validateStructure(root, kind, fail) {
138
142
  ? ["__tests__", "domain", "http", "persistence", "use-cases"]
139
143
  : ["__tests__", "api", "components", "composables", "public", "routes", "stores", "types", "views"];
140
144
  for (const moduleName of listEntries(modules, "directory")) {
141
- if (kind === "ui" && ["auth", "theme"].includes(moduleName)) {
142
- fail(`Application ${moduleName === "auth" ? "authentication" : "theme"} must live under src/app, not src/modules/${moduleName}`);
145
+ if (kind !== "service" && ["auth", "notification", "theme"].includes(moduleName)) {
146
+ fail(`Application ${moduleName} capability must live under src/app, not src/modules/${moduleName}`);
143
147
  }
144
148
  checkEntries(`src/modules/${moduleName}`, "directory", allowedModuleDirectories);
145
149
  checkEntries(`src/modules/${moduleName}`, "file", []);
@@ -223,9 +227,10 @@ function validateServiceImport(source, target, external, fail, graph) {
223
227
  }
224
228
  }
225
229
 
226
- function validateUiImport(source, target, external, fail, graph) {
227
- if (source === "src/main.ts") {
228
- if (external || !target.startsWith("src/app/")) fail(`src/main.ts may import only app bootstrap code, not ${external ?? target}`);
230
+ function validateUiImport(source, target, external, fail, graph, kind) {
231
+ const bootstraps = ["src/main.ts"];
232
+ if (bootstraps.includes(source)) {
233
+ if (external || !target.startsWith("src/app/")) fail(`${source} may import only app bootstrap code, not ${external ?? target}`);
229
234
  return;
230
235
  }
231
236
  if (source.startsWith("src/core/")) {
@@ -313,7 +318,7 @@ export function validateArchitecture(rootArgument, kind) {
313
318
  continue;
314
319
  }
315
320
  if (kind === "service") validateServiceImport(relative, target, target ? undefined : specifier, fail, graph);
316
- else validateUiImport(relative, target, target ? undefined : specifier, fail, graph);
321
+ else validateUiImport(relative, target, target ? undefined : specifier, fail, graph, kind);
317
322
  }
318
323
  }
319
324
  detectCycles(graph, fail, kind === "service" ? "Cross-module use-case dependency cycle" : "Cross-module UI public dependency cycle");
package/src/config.mjs ADDED
@@ -0,0 +1,93 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export const CONFIG_FILE = "tailframe.json";
5
+ export const CONFIG_SCHEMA_VERSION = 2;
6
+ export const PRODUCT_KIND = "product";
7
+ export const APP_KINDS = new Set(["service", "ui"]);
8
+ export const APP_PATHS = Object.freeze({
9
+ service: "apps/service",
10
+ ui: "apps/ui"
11
+ });
12
+ export const PROFILES = new Set([
13
+ "firebase",
14
+ "mongo",
15
+ "postgres",
16
+ "redis",
17
+ "worker",
18
+ "rate-limit",
19
+ "ui-host",
20
+ "notifications"
21
+ ]);
22
+
23
+ function validateProfiles(app, errors) {
24
+ if (!Array.isArray(app.profiles) || app.profiles.some((profile) => !PROFILES.has(profile))) {
25
+ errors.push(`${CONFIG_FILE} app ${app.kind ?? "unknown"} profiles contain an unsupported value`);
26
+ return;
27
+ }
28
+ if (new Set(app.profiles).size !== app.profiles.length) {
29
+ errors.push(`${CONFIG_FILE} app ${app.kind} profiles must not contain duplicates`);
30
+ }
31
+ const profiles = new Set(app.profiles);
32
+ if (app.kind === "service") {
33
+ if (profiles.has("mongo") === profiles.has("postgres")) {
34
+ errors.push("Service profiles must select exactly one of mongo or postgres");
35
+ }
36
+ if (profiles.has("worker") && !profiles.has("redis")) errors.push("The worker profile requires redis");
37
+ if (profiles.has("rate-limit") && !profiles.has("redis")) errors.push("The rate-limit profile requires redis");
38
+ if (profiles.has("notifications")) errors.push("The notifications profile is client-only");
39
+ } else {
40
+ for (const profile of ["mongo", "postgres", "redis", "worker", "rate-limit", "ui-host"]) {
41
+ if (profiles.has(profile)) errors.push(`${profile} is a service-only profile`);
42
+ }
43
+ }
44
+ }
45
+
46
+ export function loadConfig(rootArgument) {
47
+ const root = path.resolve(rootArgument);
48
+ const file = path.join(root, CONFIG_FILE);
49
+ if (!fs.existsSync(file)) return { root, errors: [`Missing ${CONFIG_FILE}`] };
50
+ let config;
51
+ try {
52
+ config = JSON.parse(fs.readFileSync(file, "utf8"));
53
+ } catch (error) {
54
+ return { root, errors: [`Invalid ${CONFIG_FILE}: ${error instanceof Error ? error.message : error}`] };
55
+ }
56
+ const errors = [];
57
+ if (config.schemaVersion !== CONFIG_SCHEMA_VERSION) errors.push(`${CONFIG_FILE} schemaVersion must be ${CONFIG_SCHEMA_VERSION}`);
58
+ if (config.kind !== PRODUCT_KIND) errors.push(`${CONFIG_FILE} kind must be product`);
59
+ if (typeof config.contractVersion !== "string" || !/^\d+\.\d+\.\d+$/.test(config.contractVersion)) {
60
+ errors.push(`${CONFIG_FILE} contractVersion must be an exact semantic version`);
61
+ }
62
+ if (!Array.isArray(config.apps) || config.apps.length === 0) {
63
+ errors.push(`${CONFIG_FILE} apps must be a non-empty array`);
64
+ } else {
65
+ const seen = new Set();
66
+ for (const app of config.apps) {
67
+ if (!app || !APP_KINDS.has(app.kind)) {
68
+ errors.push(`${CONFIG_FILE} app kind must be service or ui`);
69
+ continue;
70
+ }
71
+ if (seen.has(app.kind)) errors.push(`${CONFIG_FILE} contains duplicate ${app.kind} apps`);
72
+ seen.add(app.kind);
73
+ if (app.path !== APP_PATHS[app.kind]) {
74
+ errors.push(`${CONFIG_FILE} ${app.kind} path must be ${APP_PATHS[app.kind]}`);
75
+ }
76
+ validateProfiles(app, errors);
77
+ }
78
+ if (!seen.has("service")) errors.push(`${CONFIG_FILE} must declare exactly one service app`);
79
+ }
80
+ return { root, config, errors };
81
+ }
82
+
83
+ export function appConfig(productConfig, kind) {
84
+ return productConfig.apps.find((app) => app.kind === kind);
85
+ }
86
+
87
+ export function appRoot(productRoot, app) {
88
+ return path.join(productRoot, app.path);
89
+ }
90
+
91
+ export function configSource({ apps, contractVersion }) {
92
+ return `${JSON.stringify({ schemaVersion: CONFIG_SCHEMA_VERSION, contractVersion, kind: PRODUCT_KIND, apps }, null, "\t")}\n`;
93
+ }
@@ -115,7 +115,7 @@ export function validateConventions(rootArgument, kind) {
115
115
  if (kind === "ui") {
116
116
  validateRouteNames(root, src, flag);
117
117
  validateCanonicalShell(root, flag);
118
- } else {
118
+ } else if (kind === "service") {
119
119
  validateServiceComposition(root, flag);
120
120
  }
121
121
  return violations;
@@ -129,6 +129,12 @@ function validateServiceSource(relative, moduleName, layer, remainder, name, sou
129
129
  } else if (exportedClasses.length === 1 && exportedClasses[0] !== name.stem) {
130
130
  flag("G1", relative, `${relative} must be named ${exportedClasses[0]}.ts after its exported use-case class`);
131
131
  }
132
+ if (/^\s*(?:async\s+)?execute\s*\(/m.test(source) && !/^\s*(?:async\s+)?execute\s*\(\s*[_a-zA-Z][A-Za-z0-9]*\s*:\s*RequestContext\s*,\s*[_a-zA-Z][A-Za-z0-9]*\s*:/m.test(source)) {
133
+ flag("S1", relative, `${relative} execute must receive RequestContext first and an explicit input second; internal policies use a descriptive method instead`);
134
+ }
135
+ }
136
+ if (["domain", "use-cases"].includes(layer) && /\b_id\b/.test(source)) {
137
+ flag("S5", relative, `${relative} exposes MongoDB _id outside persistence; domain and use-case code use id`);
132
138
  }
133
139
 
134
140
  if (layer !== "http") return;
@@ -143,6 +149,11 @@ function validateServiceSource(relative, moduleName, layer, remainder, name, sou
143
149
  if (postCount > rpcHandlerCount) {
144
150
  flag("S8", relative, `${relative} must translate every RPC POST through rpcHandler; found ${postCount} POST routes and ${rpcHandlerCount} rpcHandler calls`);
145
151
  }
152
+ for (const match of source.matchAll(/\brouter\.post\s*\(\s*["']\/([^"']+)["']/g)) {
153
+ if (!match[1].startsWith(`${moduleName}.`)) {
154
+ flag("S8", relative, `${relative} exposes ${match[1]}; RPC operations use the owning module namespace ${moduleName}.*`);
155
+ }
156
+ }
146
157
  }
147
158
  if (name.role === "schemas" && name.subject === moduleName) {
148
159
  const schemas = [...source.matchAll(/^export\s+const\s+([A-Za-z][A-Za-z0-9]*)\s*=/gm)].map((match) => match[1]);
@@ -202,7 +213,8 @@ function normalizeGeneratedSource(source) {
202
213
  function validateCanonicalShell(root, flag) {
203
214
  const authRelative = "src/app/stores/auth.store.ts";
204
215
  const authFile = path.join(root, authRelative);
205
- if (fs.existsSync(authFile) && normalizeGeneratedSource(fs.readFileSync(authFile, "utf8")) !== authAppStoreSource()) {
216
+ const expectedAuth = authAppStoreSource();
217
+ if (fs.existsSync(authFile) && normalizeGeneratedSource(fs.readFileSync(authFile, "utf8")) !== expectedAuth) {
206
218
  flag("U7", authRelative, `${authRelative} differs from the canonical Tailframe auth store; regenerate it instead of maintaining a project-local variant`);
207
219
  }
208
220
 
@@ -279,7 +291,9 @@ function validateShared(relative, name, flag) {
279
291
  return;
280
292
  }
281
293
  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`);
294
+ if (!new Set(["auth.store", "notification.store", "theme.store"]).has(name.stem)) {
295
+ flag("U3", relative, `${relative} is not a canonical app store; app/stores is closed to auth.store.ts, notification.store.ts, and theme.store.ts`);
296
+ }
283
297
  return;
284
298
  }
285
299
  if (!PASCAL.test(name.stem) && !CAMEL.test(name.stem)) {
@@ -14,19 +14,29 @@ function parseExceptionSection(markdown) {
14
14
  return covered;
15
15
  }
16
16
 
17
+ function normalize(relative) {
18
+ return relative.split(path.sep).join("/");
19
+ }
20
+
17
21
  export function loadExceptions(rootArgument) {
18
22
  const root = path.resolve(rootArgument);
19
- const repositoryName = path.basename(root);
20
23
  const covered = [];
21
24
  const own = path.join(root, "AGENTS.md");
22
25
  if (fs.existsSync(own)) covered.push(...parseExceptionSection(fs.readFileSync(own, "utf8")));
23
- const project = path.join(path.dirname(root), "AGENTS.md");
24
- if (fs.existsSync(project)) {
25
- for (const entry of parseExceptionSection(fs.readFileSync(project, "utf8"))) {
26
- if (entry === repositoryName || entry.startsWith(`${repositoryName}/`)) {
27
- covered.push(entry.slice(repositoryName.length + 1) || ".");
26
+
27
+ let ancestor = path.dirname(root);
28
+ while (ancestor !== path.dirname(ancestor)) {
29
+ const guidance = path.join(ancestor, "AGENTS.md");
30
+ if (fs.existsSync(guidance)) {
31
+ const appPath = normalize(path.relative(ancestor, root));
32
+ for (const entry of parseExceptionSection(fs.readFileSync(guidance, "utf8"))) {
33
+ if (entry === appPath || entry.startsWith(`${appPath}/`)) {
34
+ covered.push(entry.slice(appPath.length + 1) || ".");
35
+ }
28
36
  }
29
37
  }
38
+ if (fs.existsSync(path.join(ancestor, "tailframe.json"))) break;
39
+ ancestor = path.dirname(ancestor);
30
40
  }
31
41
  return covered;
32
42
  }
package/src/generate.mjs CHANGED
@@ -1,8 +1,9 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { appRoot, loadConfig } from "./config.mjs";
3
4
  import { loadExceptions, isExcepted } from "./exceptions.mjs";
4
5
  import { serviceHttpFiles, serviceOperationName } from "./service-templates.mjs";
5
- import { moduleApiSource } from "./ui-templates.mjs";
6
+ import { moduleApiSource, notificationStoreSource } from "./ui-templates.mjs";
6
7
 
7
8
  const KEBAB = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
8
9
  const PASCAL = /^[A-Z][A-Za-z0-9]*$/;
@@ -470,7 +471,8 @@ function appStoreFiles(name, options) {
470
471
  return { "src/app/stores/theme.store.ts": themeAppStoreSource(storageKey) };
471
472
  }
472
473
  if (name === "auth") return { "src/app/stores/auth.store.ts": authAppStoreSource() };
473
- fail(`App store must be one of auth or theme, received "${name ?? ""}"`);
474
+ if (name === "notification") return { "src/app/stores/notification.store.ts": notificationStoreSource };
475
+ fail(`App store must be one of auth, notification, or theme, received "${name ?? ""}"`);
474
476
  }
475
477
 
476
478
  function appPublicComponentFiles(name) {
@@ -527,7 +529,7 @@ const uiSchematics = {
527
529
  store(root, [moduleName, name]) {
528
530
  requireUiModuleName(root, moduleName);
529
531
  if (!name || !KEBAB.test(name)) fail(`Store name must be lowercase kebab-case, received "${name ?? ""}"`);
530
- 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`]];
532
+ return [plan(uiArtifacts(moduleName, { store: name })), [`Keep product state in this owning module; when another module needs the capability, design the smallest stable public entry instead of re-exporting the store by default`]];
531
533
  },
532
534
  composable(root, [moduleName, name]) {
533
535
  requireUiModuleName(root, moduleName);
@@ -570,8 +572,13 @@ const uiSchematics = {
570
572
  }
571
573
  };
572
574
 
573
- export function runGenerate(rootArgument, argv) {
574
- const root = path.resolve(rootArgument);
575
+ export function runGenerate(rootArgument, argv, selectedKind) {
576
+ const loaded = loadConfig(rootArgument);
577
+ if (loaded.errors.length) fail(loaded.errors.join("\n"));
578
+ if (!selectedKind) fail("tailframe generate requires --app service or ui");
579
+ const app = loaded.config.apps.find((candidate) => candidate.kind === selectedKind);
580
+ if (!app) fail(`tailframe.json does not declare a ${selectedKind} app`);
581
+ const root = appRoot(loaded.root, app);
575
582
  const positionals = [];
576
583
  const options = new Map();
577
584
  const flags = new Set();
@@ -585,11 +592,14 @@ export function runGenerate(rootArgument, argv) {
585
592
  options.has = (key) => flags.has(key) || Map.prototype.has.call(options, key);
586
593
  const schematic = positionals.shift();
587
594
  const kind = detectKind(root);
595
+ if (kind !== selectedKind) {
596
+ fail(`Configured ${selectedKind} app at ${app.path} has ${kind} entry-point shape`);
597
+ }
588
598
  const registry = kind === "service" ? serviceSchematics : uiSchematics;
589
599
  if (!schematic || !registry[schematic]) {
590
600
  fail(`Unknown ${kind} schematic "${schematic ?? ""}". Available: ${Object.keys(registry).join(", ")}`);
591
601
  }
592
602
  const [filePlan, checklist] = registry[schematic](root, positionals, options);
593
603
  const created = filePlan.write(root);
594
- return { kind, created, checklist: [...checklist, "Run npm run validate:architecture, then focused type checks and tests"] };
604
+ return { kind, created: created.map((relative) => path.join(app.path, relative)), checklist: [...checklist, "Run pnpm validate:architecture, then focused type checks and tests"] };
595
605
  }