@zaaxch/tailframe 0.1.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/assets/validate-architecture.mjs +279 -0
- package/bin/tailframe.mjs +83 -0
- package/package.json +21 -0
- package/src/architecture.mjs +259 -0
- package/src/conventions.mjs +188 -0
- package/src/exceptions.mjs +36 -0
- package/src/generate.mjs +353 -0
- package/src/new.mjs +754 -0
- package/src/validate.mjs +15 -0
|
@@ -0,0 +1,279 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { runValidate } from "../src/validate.mjs";
|
|
5
|
+
import { runGenerate, GenerateError } from "../src/generate.mjs";
|
|
6
|
+
import { createProject } from "../src/new.mjs";
|
|
7
|
+
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
|
|
10
|
+
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]");
|
|
14
|
+
console.error(" service schematics: module <name> <VerbNoun> [--no-http], use-case <module> <VerbNoun>,");
|
|
15
|
+
console.error(" http <module> <VerbNoun>, port <module> <Name>, adapter <module> <PortName> --db <technology>,");
|
|
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>");
|
|
19
|
+
process.exit(2);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const args = process.argv.slice(2);
|
|
23
|
+
const command = args.shift();
|
|
24
|
+
|
|
25
|
+
if (command === "--version" || command === "-v") {
|
|
26
|
+
console.log(require("../package.json").version);
|
|
27
|
+
process.exit(0);
|
|
28
|
+
}
|
|
29
|
+
if (command === "new") {
|
|
30
|
+
try {
|
|
31
|
+
let name;
|
|
32
|
+
const rest = [];
|
|
33
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
34
|
+
const value = args[index];
|
|
35
|
+
if (value === "--path" || value === "--auth") rest.push(value, args[++index]);
|
|
36
|
+
else if (value.startsWith("--")) rest.push(value);
|
|
37
|
+
else if (name === undefined) name = value;
|
|
38
|
+
else rest.push(value);
|
|
39
|
+
}
|
|
40
|
+
if (!rest.includes("--path")) rest.push("--path", ".");
|
|
41
|
+
const summary = createProject([...(name ? ["--name", name] : []), ...rest]);
|
|
42
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
43
|
+
process.exit(0);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (error instanceof GenerateError) {
|
|
46
|
+
console.error(error.message);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (command === "generate") {
|
|
53
|
+
try {
|
|
54
|
+
const { created, checklist } = runGenerate(".", args);
|
|
55
|
+
console.log("Created:");
|
|
56
|
+
for (const file of created) console.log(` ${file}`);
|
|
57
|
+
console.log("Next steps:");
|
|
58
|
+
for (const step of checklist) console.log(` - ${step}`);
|
|
59
|
+
process.exit(0);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (error instanceof GenerateError) {
|
|
62
|
+
console.error(error.message);
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (command !== "validate") usage();
|
|
69
|
+
|
|
70
|
+
let kind;
|
|
71
|
+
let root = ".";
|
|
72
|
+
while (args.length) {
|
|
73
|
+
const value = args.shift();
|
|
74
|
+
if (value === "--kind") kind = args.shift();
|
|
75
|
+
else if (value.startsWith("-")) usage();
|
|
76
|
+
else root = value;
|
|
77
|
+
}
|
|
78
|
+
if (!["service", "ui"].includes(kind)) usage();
|
|
79
|
+
|
|
80
|
+
const errors = runValidate(root, kind);
|
|
81
|
+
for (const error of errors) console.error(error);
|
|
82
|
+
if (errors.length) process.exit(1);
|
|
83
|
+
console.log(`Validated ${kind} contract ${require("../package.json").version}: ${path.resolve(root)}`);
|
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zaaxch/tailframe",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Tailframe architecture toolkit: validates the Tailframe structure, import-boundary, and file-convention contracts. The package version is the contract version.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"tailframe": "bin/tailframe.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"assets",
|
|
11
|
+
"bin",
|
|
12
|
+
"src"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "node test/run-tests.mjs"
|
|
16
|
+
},
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=20"
|
|
19
|
+
},
|
|
20
|
+
"license": "UNLICENSED"
|
|
21
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
const SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".vue"]);
|
|
7
|
+
|
|
8
|
+
export 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
|
+
export 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
|
+
export function isTestFile(relative) {
|
|
51
|
+
return relative.includes("/__tests__/") || /\.(?:test|spec)\.[^.]+$/.test(relative);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export 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
|
+
}
|