@zaaxch/tailframe 2.1.0 → 3.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 +30 -12
- package/package.json +1 -1
- package/src/architecture.mjs +15 -10
- package/src/config.mjs +59 -0
- package/src/conventions.mjs +22 -5
- package/src/flutter.mjs +111 -0
- package/src/generate.mjs +3 -2
- package/src/new.mjs +587 -66
- package/src/owned-guidance.mjs +15 -0
- package/src/owned-sources.mjs +479 -0
- package/src/service-templates.mjs +222 -19
- package/src/sync.mjs +57 -0
- package/src/ui-templates.mjs +180 -1
- package/src/validate.mjs +44 -0
package/bin/tailframe.mjs
CHANGED
|
@@ -1,30 +1,51 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
|
-
import {
|
|
4
|
+
import { runConfiguredValidate } from "../src/validate.mjs";
|
|
5
5
|
import { runGenerate, GenerateError } from "../src/generate.mjs";
|
|
6
6
|
import { createProject } from "../src/new.mjs";
|
|
7
|
+
import { runSync } from "../src/sync.mjs";
|
|
7
8
|
|
|
8
9
|
const require = createRequire(import.meta.url);
|
|
9
10
|
|
|
10
11
|
function usage() {
|
|
11
|
-
console.error("Usage: tailframe validate
|
|
12
|
-
console.error(" tailframe
|
|
12
|
+
console.error("Usage: tailframe validate [root]");
|
|
13
|
+
console.error(" tailframe sync --check|--write [root]");
|
|
14
|
+
console.error(" tailframe new <name> [--path <parent>] [--db mongo|postgres] [--auth none|firebase] [--ui] [--redis] [--worker]");
|
|
13
15
|
console.error(" tailframe generate <schematic> <args...> [--options]");
|
|
14
16
|
console.error(" service schematics: module <name> <VerbNoun> [--no-http], use-case <module> <VerbNoun>,");
|
|
15
17
|
console.error(" http <module> <VerbNoun>, port <module> <Name>, adapter <module> <PortName> --db <technology>,");
|
|
16
18
|
console.error(" identifiers <module> <NameId...>, integration <provider>");
|
|
17
19
|
console.error(" ui schematics: module <name> --api|--view <Name>|--component <Name>|--store <name>|--composable <name>|--public <name>|--public-component <Name>,");
|
|
18
20
|
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");
|
|
21
|
+
console.error(" public <module> <name>, public-component <module> <Name>, app-store auth|notification, app-store theme --storage-key <key>, app-component ThemeToggle");
|
|
20
22
|
process.exit(2);
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
const args = process.argv.slice(2);
|
|
24
26
|
const command = args.shift();
|
|
27
|
+
const contractVersion = require("../package.json").version;
|
|
25
28
|
|
|
26
29
|
if (command === "--version" || command === "-v") {
|
|
27
|
-
console.log(
|
|
30
|
+
console.log(contractVersion);
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
if (command === "sync") {
|
|
34
|
+
let mode;
|
|
35
|
+
let root = ".";
|
|
36
|
+
while (args.length) {
|
|
37
|
+
const value = args.shift();
|
|
38
|
+
if (value === "--check") mode = "check";
|
|
39
|
+
else if (value === "--write") mode = "write";
|
|
40
|
+
else if (value.startsWith("-")) usage();
|
|
41
|
+
else root = value;
|
|
42
|
+
}
|
|
43
|
+
if (!mode) usage();
|
|
44
|
+
const result = runSync(root, mode, contractVersion);
|
|
45
|
+
for (const error of result.errors) console.error(error);
|
|
46
|
+
for (const file of result.changed) console.log(`Updated ${file}`);
|
|
47
|
+
if (result.errors.length) process.exit(1);
|
|
48
|
+
if (mode === "check") console.log(`Canonical Tailframe sources match ${contractVersion}: ${path.resolve(root)}`);
|
|
28
49
|
process.exit(0);
|
|
29
50
|
}
|
|
30
51
|
if (command === "new") {
|
|
@@ -33,7 +54,7 @@ if (command === "new") {
|
|
|
33
54
|
const rest = [];
|
|
34
55
|
for (let index = 0; index < args.length; index += 1) {
|
|
35
56
|
const value = args[index];
|
|
36
|
-
if (value === "--path" || value === "--auth") rest.push(value, args[++index]);
|
|
57
|
+
if (value === "--path" || value === "--auth" || value === "--db") rest.push(value, args[++index]);
|
|
37
58
|
else if (value.startsWith("--")) rest.push(value);
|
|
38
59
|
else if (name === undefined) name = value;
|
|
39
60
|
else rest.push(value);
|
|
@@ -68,17 +89,14 @@ if (command === "generate") {
|
|
|
68
89
|
}
|
|
69
90
|
if (command !== "validate") usage();
|
|
70
91
|
|
|
71
|
-
let kind;
|
|
72
92
|
let root = ".";
|
|
73
93
|
while (args.length) {
|
|
74
94
|
const value = args.shift();
|
|
75
|
-
if (value
|
|
76
|
-
else if (value.startsWith("-")) usage();
|
|
95
|
+
if (value.startsWith("-")) usage();
|
|
77
96
|
else root = value;
|
|
78
97
|
}
|
|
79
|
-
if (!["service", "ui"].includes(kind)) usage();
|
|
80
98
|
|
|
81
|
-
const errors =
|
|
99
|
+
const errors = runConfiguredValidate(root, contractVersion);
|
|
82
100
|
for (const error of errors) console.error(error);
|
|
83
101
|
if (errors.length) process.exit(1);
|
|
84
|
-
console.log(`Validated
|
|
102
|
+
console.log(`Validated Tailframe contract ${contractVersion}: ${path.resolve(root)}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zaaxch/tailframe",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "Tailframe architecture toolkit: validates the Tailframe structure, import-boundary, and file-convention contracts. The package version is the contract version.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/architecture.mjs
CHANGED
|
@@ -4,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([
|
|
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) {
|
|
@@ -118,7 +122,7 @@ function validateStructure(root, kind, fail) {
|
|
|
118
122
|
checkEntries("src/core", "directory", []);
|
|
119
123
|
} else {
|
|
120
124
|
checkEntries("src", "directory", ["app", "core", "modules", "platform"]);
|
|
121
|
-
checkEntries("src", "file", ["main.ts"]);
|
|
125
|
+
checkEntries("src", "file", kind === "extension" ? ["background.ts", "content.ts"] : ["main.ts"]);
|
|
122
126
|
checkEntries("src/app", "directory", ["__tests__", "components", "public", "stores", "views"]);
|
|
123
127
|
checkEntries("src/core", "directory", []);
|
|
124
128
|
checkOptionalEntries("src/app/public", "directory", []);
|
|
@@ -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
|
|
142
|
-
fail(`Application ${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
|
-
|
|
228
|
-
|
|
230
|
+
function validateUiImport(source, target, external, fail, graph, kind) {
|
|
231
|
+
const bootstraps = kind === "extension" ? ["src/background.ts", "src/content.ts"] : ["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/")) {
|
|
@@ -294,7 +299,7 @@ export function validateArchitecture(rootArgument, kind) {
|
|
|
294
299
|
const root = path.resolve(rootArgument);
|
|
295
300
|
const errors = [];
|
|
296
301
|
const fail = (message) => errors.push(message);
|
|
297
|
-
if (!["service", "ui"].includes(kind)) return [`Architecture kind must be service or
|
|
302
|
+
if (!["service", "ui", "extension"].includes(kind)) return [`Architecture kind must be service, ui, or extension, received ${kind ?? "nothing"}`];
|
|
298
303
|
if (!fs.existsSync(root)) return [`Architecture root does not exist: ${root}`];
|
|
299
304
|
validateStructure(root, kind, fail);
|
|
300
305
|
const src = path.join(root, "src");
|
|
@@ -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,59 @@
|
|
|
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 = 1;
|
|
6
|
+
export const KINDS = new Set(["service", "ui", "extension", "flutter"]);
|
|
7
|
+
export const PROFILES = new Set([
|
|
8
|
+
"firebase",
|
|
9
|
+
"mongo",
|
|
10
|
+
"postgres",
|
|
11
|
+
"redis",
|
|
12
|
+
"worker",
|
|
13
|
+
"rate-limit",
|
|
14
|
+
"ui-host",
|
|
15
|
+
"notifications"
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
export function loadConfig(rootArgument) {
|
|
19
|
+
const root = path.resolve(rootArgument);
|
|
20
|
+
const file = path.join(root, CONFIG_FILE);
|
|
21
|
+
if (!fs.existsSync(file)) return { root, errors: [`Missing ${CONFIG_FILE}`] };
|
|
22
|
+
let config;
|
|
23
|
+
try {
|
|
24
|
+
config = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
25
|
+
} catch (error) {
|
|
26
|
+
return { root, errors: [`Invalid ${CONFIG_FILE}: ${error instanceof Error ? error.message : error}`] };
|
|
27
|
+
}
|
|
28
|
+
const errors = [];
|
|
29
|
+
if (config.schemaVersion !== CONFIG_SCHEMA_VERSION) {
|
|
30
|
+
errors.push(`${CONFIG_FILE} schemaVersion must be ${CONFIG_SCHEMA_VERSION}`);
|
|
31
|
+
}
|
|
32
|
+
if (typeof config.contractVersion !== "string" || !/^\d+\.\d+\.\d+$/.test(config.contractVersion)) {
|
|
33
|
+
errors.push(`${CONFIG_FILE} contractVersion must be an exact semantic version`);
|
|
34
|
+
}
|
|
35
|
+
if (!KINDS.has(config.kind)) errors.push(`${CONFIG_FILE} kind must be service, ui, extension, or flutter`);
|
|
36
|
+
if (!Array.isArray(config.profiles) || config.profiles.some((profile) => !PROFILES.has(profile))) {
|
|
37
|
+
errors.push(`${CONFIG_FILE} profiles contain an unsupported value`);
|
|
38
|
+
} else if (new Set(config.profiles).size !== config.profiles.length) {
|
|
39
|
+
errors.push(`${CONFIG_FILE} profiles must not contain duplicates`);
|
|
40
|
+
}
|
|
41
|
+
const profiles = new Set(config.profiles ?? []);
|
|
42
|
+
if (config.kind === "service") {
|
|
43
|
+
if (profiles.has("mongo") === profiles.has("postgres")) {
|
|
44
|
+
errors.push("Service profiles must select exactly one of mongo or postgres");
|
|
45
|
+
}
|
|
46
|
+
if (profiles.has("worker") && !profiles.has("redis")) errors.push("The worker profile requires redis");
|
|
47
|
+
if (profiles.has("rate-limit") && !profiles.has("redis")) errors.push("The rate-limit profile requires redis");
|
|
48
|
+
if (profiles.has("notifications")) errors.push("The notifications profile is client-only");
|
|
49
|
+
} else {
|
|
50
|
+
for (const profile of ["mongo", "postgres", "redis", "worker", "rate-limit", "ui-host"]) {
|
|
51
|
+
if (profiles.has(profile)) errors.push(`${profile} is a service-only profile`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { root, config, errors };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function configSource({ kind, profiles, contractVersion }) {
|
|
58
|
+
return `${JSON.stringify({ schemaVersion: CONFIG_SCHEMA_VERSION, contractVersion, kind, profiles }, null, "\t")}\n`;
|
|
59
|
+
}
|
package/src/conventions.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { toPosix, walk } from "./architecture.mjs";
|
|
4
4
|
import { authAppStoreSource, themeAppStoreSource, themeToggleSource } from "./generate.mjs";
|
|
5
|
+
import { extensionAuthStoreSource } from "./ui-templates.mjs";
|
|
5
6
|
|
|
6
7
|
const PASCAL = /^[A-Z][A-Za-z0-9]*$/;
|
|
7
8
|
const CAMEL = /^[a-z][A-Za-z0-9]*$/;
|
|
@@ -114,8 +115,10 @@ export function validateConventions(rootArgument, kind) {
|
|
|
114
115
|
}
|
|
115
116
|
if (kind === "ui") {
|
|
116
117
|
validateRouteNames(root, src, flag);
|
|
117
|
-
validateCanonicalShell(root, flag);
|
|
118
|
-
} else {
|
|
118
|
+
validateCanonicalShell(root, flag, kind);
|
|
119
|
+
} else if (kind === "extension") {
|
|
120
|
+
validateCanonicalShell(root, flag, kind);
|
|
121
|
+
} else if (kind === "service") {
|
|
119
122
|
validateServiceComposition(root, flag);
|
|
120
123
|
}
|
|
121
124
|
return violations;
|
|
@@ -129,6 +132,12 @@ function validateServiceSource(relative, moduleName, layer, remainder, name, sou
|
|
|
129
132
|
} else if (exportedClasses.length === 1 && exportedClasses[0] !== name.stem) {
|
|
130
133
|
flag("G1", relative, `${relative} must be named ${exportedClasses[0]}.ts after its exported use-case class`);
|
|
131
134
|
}
|
|
135
|
+
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)) {
|
|
136
|
+
flag("S1", relative, `${relative} execute must receive RequestContext first and an explicit input second; internal policies use a descriptive method instead`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (["domain", "use-cases"].includes(layer) && /\b_id\b/.test(source)) {
|
|
140
|
+
flag("S5", relative, `${relative} exposes MongoDB _id outside persistence; domain and use-case code use id`);
|
|
132
141
|
}
|
|
133
142
|
|
|
134
143
|
if (layer !== "http") return;
|
|
@@ -143,6 +152,11 @@ function validateServiceSource(relative, moduleName, layer, remainder, name, sou
|
|
|
143
152
|
if (postCount > rpcHandlerCount) {
|
|
144
153
|
flag("S8", relative, `${relative} must translate every RPC POST through rpcHandler; found ${postCount} POST routes and ${rpcHandlerCount} rpcHandler calls`);
|
|
145
154
|
}
|
|
155
|
+
for (const match of source.matchAll(/\brouter\.post\s*\(\s*["']\/([^"']+)["']/g)) {
|
|
156
|
+
if (!match[1].startsWith(`${moduleName}.`)) {
|
|
157
|
+
flag("S8", relative, `${relative} exposes ${match[1]}; RPC operations use the owning module namespace ${moduleName}.*`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
146
160
|
}
|
|
147
161
|
if (name.role === "schemas" && name.subject === moduleName) {
|
|
148
162
|
const schemas = [...source.matchAll(/^export\s+const\s+([A-Za-z][A-Za-z0-9]*)\s*=/gm)].map((match) => match[1]);
|
|
@@ -199,10 +213,11 @@ function normalizeGeneratedSource(source) {
|
|
|
199
213
|
return source.replace(/\r\n/g, "\n");
|
|
200
214
|
}
|
|
201
215
|
|
|
202
|
-
function validateCanonicalShell(root, flag) {
|
|
216
|
+
function validateCanonicalShell(root, flag, kind = "ui") {
|
|
203
217
|
const authRelative = "src/app/stores/auth.store.ts";
|
|
204
218
|
const authFile = path.join(root, authRelative);
|
|
205
|
-
|
|
219
|
+
const expectedAuth = kind === "extension" ? extensionAuthStoreSource : authAppStoreSource();
|
|
220
|
+
if (fs.existsSync(authFile) && normalizeGeneratedSource(fs.readFileSync(authFile, "utf8")) !== expectedAuth) {
|
|
206
221
|
flag("U7", authRelative, `${authRelative} differs from the canonical Tailframe auth store; regenerate it instead of maintaining a project-local variant`);
|
|
207
222
|
}
|
|
208
223
|
|
|
@@ -279,7 +294,9 @@ function validateShared(relative, name, flag) {
|
|
|
279
294
|
return;
|
|
280
295
|
}
|
|
281
296
|
if (relative.startsWith("src/app/stores/") && name.extension === ".ts") {
|
|
282
|
-
|
|
297
|
+
if (!new Set(["auth.store", "notification.store", "theme.store"]).has(name.stem)) {
|
|
298
|
+
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`);
|
|
299
|
+
}
|
|
283
300
|
return;
|
|
284
301
|
}
|
|
285
302
|
if (!PASCAL.test(name.stem) && !CAMEL.test(name.stem)) {
|
package/src/flutter.mjs
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const ALLOWED_ROOTS = new Set(["app", "core", "modules", "platform"]);
|
|
5
|
+
const ALLOWED_APP_DIRECTORIES = new Set(["components", "public", "state", "theme", "views"]);
|
|
6
|
+
const ALLOWED_MODULE_DIRECTORIES = new Set(["api", "components", "public", "state", "types", "views"]);
|
|
7
|
+
const IMPORT_PATTERN = /(?:import|export)\s+["']([^"']+)["']/g;
|
|
8
|
+
|
|
9
|
+
const toPosix = (value) => value.split(path.sep).join("/");
|
|
10
|
+
|
|
11
|
+
function walk(target) {
|
|
12
|
+
const results = [];
|
|
13
|
+
for (const entry of fs.readdirSync(target, { withFileTypes: true })) {
|
|
14
|
+
if ([".dart_tool", "build", ".git"].includes(entry.name)) continue;
|
|
15
|
+
const absolute = path.join(target, entry.name);
|
|
16
|
+
if (entry.isDirectory()) results.push(...walk(absolute));
|
|
17
|
+
else if (entry.name.endsWith(".dart")) results.push(absolute);
|
|
18
|
+
}
|
|
19
|
+
return results;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function resolveImport(source, specifier, packageName) {
|
|
23
|
+
if (specifier.startsWith(`package:${packageName}/`)) return specifier.slice(`package:${packageName}/`.length);
|
|
24
|
+
if (specifier.startsWith(".")) return path.posix.normalize(path.posix.join(path.posix.dirname(source), specifier));
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function readFlutterPackageName(rootArgument) {
|
|
29
|
+
try {
|
|
30
|
+
return fs.readFileSync(path.join(path.resolve(rootArgument), "pubspec.yaml"), "utf8").match(/^name:\s*([a-zA-Z0-9_]+)/m)?.[1];
|
|
31
|
+
} catch {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function validateFlutter(rootArgument) {
|
|
37
|
+
const root = path.resolve(rootArgument);
|
|
38
|
+
const lib = path.join(root, "lib");
|
|
39
|
+
const errors = [];
|
|
40
|
+
if (!fs.existsSync(lib)) return ["Missing Flutter architecture directory: lib"];
|
|
41
|
+
const packageName = readFlutterPackageName(root);
|
|
42
|
+
if (!packageName) errors.push("pubspec.yaml must declare a package name");
|
|
43
|
+
for (const entry of fs.readdirSync(lib, { withFileTypes: true })) {
|
|
44
|
+
if (entry.isDirectory() && !ALLOWED_ROOTS.has(entry.name)) errors.push(`lib/${entry.name} is not a recognized architecture root`);
|
|
45
|
+
if (entry.isFile() && entry.name !== "main.dart") errors.push(`lib/${entry.name} is not the root bootstrap lib/main.dart`);
|
|
46
|
+
}
|
|
47
|
+
const files = walk(lib);
|
|
48
|
+
const relativeFiles = new Set(files.map((file) => toPosix(path.relative(lib, file))));
|
|
49
|
+
const graph = new Map();
|
|
50
|
+
for (const relative of relativeFiles) {
|
|
51
|
+
const parts = relative.split("/");
|
|
52
|
+
if (relative === "main.dart") {
|
|
53
|
+
// Checked after imports resolve below.
|
|
54
|
+
} else if (!ALLOWED_ROOTS.has(parts[0])) {
|
|
55
|
+
errors.push(`lib/${relative} is outside an architecture root`);
|
|
56
|
+
}
|
|
57
|
+
if (parts[0] === "core" && parts.length !== 2) errors.push(`lib/${relative} violates the flat core contract`);
|
|
58
|
+
if (parts[0] === "app" && parts.length > 2 && !ALLOWED_APP_DIRECTORIES.has(parts[1])) {
|
|
59
|
+
errors.push(`lib/${relative} uses an unsupported app directory`);
|
|
60
|
+
}
|
|
61
|
+
if (parts[0] === "modules") {
|
|
62
|
+
if (parts.length < 4 || !ALLOWED_MODULE_DIRECTORIES.has(parts[2])) errors.push(`lib/${relative} is not in a canonical module layer`);
|
|
63
|
+
if (parts[2] === "public" && (parts.length !== 4 || parts.at(-1) === "index.dart")) errors.push(`lib/${relative} is not a named direct module public entry`);
|
|
64
|
+
}
|
|
65
|
+
const source = fs.readFileSync(path.join(lib, relative), "utf8");
|
|
66
|
+
for (const match of source.matchAll(IMPORT_PATTERN)) {
|
|
67
|
+
const specifier = match[1];
|
|
68
|
+
const target = packageName ? resolveImport(relative, specifier, packageName) : undefined;
|
|
69
|
+
if (!target) {
|
|
70
|
+
if (parts[0] === "core" && specifier.startsWith("package:")) {
|
|
71
|
+
errors.push(`lib/${relative} violates core purity by importing ${specifier}`);
|
|
72
|
+
}
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (!relativeFiles.has(target)) {
|
|
76
|
+
errors.push(`lib/${relative} references missing lib/${target}`);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (relative === "main.dart" && !target.startsWith("app/")) errors.push(`lib/main.dart may import only application bootstrap code, not lib/${target}`);
|
|
80
|
+
if (parts[0] === "core" && !target.startsWith("core/")) errors.push(`lib/${relative} crosses outward from core to lib/${target}`);
|
|
81
|
+
if (parts[0] === "platform" && (target.startsWith("app/") || target.startsWith("modules/"))) errors.push(`lib/${relative} crosses from platform to lib/${target}`);
|
|
82
|
+
if (parts[0] === "app" && target.startsWith("modules/")) {
|
|
83
|
+
const targetParts = target.split("/");
|
|
84
|
+
if (targetParts[2] !== "public" || targetParts.length !== 4) errors.push(`lib/${relative} imports private module file lib/${target}`);
|
|
85
|
+
}
|
|
86
|
+
if (parts[0] !== "modules") continue;
|
|
87
|
+
if (target.startsWith("app/") && !target.startsWith("app/public/")) errors.push(`lib/${relative} imports private app file lib/${target}`);
|
|
88
|
+
if (!target.startsWith("modules/")) continue;
|
|
89
|
+
const targetParts = target.split("/");
|
|
90
|
+
if (targetParts[1] === parts[1]) continue;
|
|
91
|
+
if (targetParts[2] !== "public" || targetParts.length !== 4) errors.push(`lib/${relative} imports private sibling file lib/${target}`);
|
|
92
|
+
else {
|
|
93
|
+
if (!graph.has(parts[1])) graph.set(parts[1], new Set());
|
|
94
|
+
graph.get(parts[1]).add(targetParts[1]);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const visiting = new Set();
|
|
99
|
+
const visited = new Set();
|
|
100
|
+
const visit = (module) => {
|
|
101
|
+
if (visiting.has(module)) return true;
|
|
102
|
+
if (visited.has(module)) return false;
|
|
103
|
+
visiting.add(module);
|
|
104
|
+
for (const dependency of graph.get(module) ?? []) if (visit(dependency)) return true;
|
|
105
|
+
visiting.delete(module);
|
|
106
|
+
visited.add(module);
|
|
107
|
+
return false;
|
|
108
|
+
};
|
|
109
|
+
for (const module of graph.keys()) if (visit(module)) errors.push(`Flutter module dependency graph contains a cycle involving ${module}`);
|
|
110
|
+
return [...new Set(errors)];
|
|
111
|
+
}
|
package/src/generate.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { loadExceptions, isExcepted } from "./exceptions.mjs";
|
|
4
4
|
import { serviceHttpFiles, serviceOperationName } from "./service-templates.mjs";
|
|
5
|
-
import { moduleApiSource } from "./ui-templates.mjs";
|
|
5
|
+
import { moduleApiSource, notificationStoreSource } from "./ui-templates.mjs";
|
|
6
6
|
|
|
7
7
|
const KEBAB = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
8
8
|
const PASCAL = /^[A-Z][A-Za-z0-9]*$/;
|
|
@@ -470,7 +470,8 @@ function appStoreFiles(name, options) {
|
|
|
470
470
|
return { "src/app/stores/theme.store.ts": themeAppStoreSource(storageKey) };
|
|
471
471
|
}
|
|
472
472
|
if (name === "auth") return { "src/app/stores/auth.store.ts": authAppStoreSource() };
|
|
473
|
-
|
|
473
|
+
if (name === "notification") return { "src/app/stores/notification.store.ts": notificationStoreSource };
|
|
474
|
+
fail(`App store must be one of auth, notification, or theme, received "${name ?? ""}"`);
|
|
474
475
|
}
|
|
475
476
|
|
|
476
477
|
function appPublicComponentFiles(name) {
|