aact 2.1.5 → 3.0.0-beta.3
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/README.md +50 -18
- package/dist/chunks/index.mjs +422 -0
- package/dist/chunks/index2.mjs +248 -0
- package/dist/chunks/index3.mjs +72 -0
- package/dist/cli/index.mjs +338 -542
- package/dist/index.d.mts +520 -229
- package/dist/index.d.ts +520 -229
- package/dist/index.mjs +5 -89
- package/dist/shared/aact.BpV1UCZJ.mjs +3 -0
- package/dist/shared/aact.CfImn7en.mjs +138 -0
- package/dist/shared/aact.CxegP3pU.mjs +900 -0
- package/package.json +44 -14
- package/dist/shared/aact.CJGFUdeF.mjs +0 -886
package/dist/cli/index.mjs
CHANGED
|
@@ -1,17 +1,65 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { defineCommand, runMain } from 'citty';
|
|
3
3
|
import consola from 'consola';
|
|
4
|
-
import { A as AactConfigSchema,
|
|
4
|
+
import { A as AactConfigSchema, r as knownFormatNames, s as loadFormat, j as canLoad, e as analyzeArchitecture, t as ruleRegistry, h as canFix, g as applyEdits, i as canGenerate } from '../shared/aact.CxegP3pU.mjs';
|
|
5
5
|
import { loadConfig } from 'c12';
|
|
6
|
+
import path, { basename } from 'pathe';
|
|
6
7
|
import * as v from 'valibot';
|
|
7
|
-
import path from 'node:path';
|
|
8
8
|
import fs, { readFile, writeFile } from 'node:fs/promises';
|
|
9
|
-
import
|
|
10
|
-
import 'yaml';
|
|
11
|
-
import 'plantuml-parser';
|
|
9
|
+
import { colors, box } from 'consola/utils';
|
|
12
10
|
|
|
13
|
-
const version = "
|
|
11
|
+
const version = "3.0.0-beta.3";
|
|
14
12
|
|
|
13
|
+
const matchesPattern = (filePath, pattern) => {
|
|
14
|
+
if (pattern.startsWith("*")) {
|
|
15
|
+
return filePath.endsWith(pattern.slice(1));
|
|
16
|
+
}
|
|
17
|
+
return basename(filePath) === pattern;
|
|
18
|
+
};
|
|
19
|
+
const validateCustomRules = (entries) => {
|
|
20
|
+
const validated = [];
|
|
21
|
+
for (const [i, raw] of entries.entries()) {
|
|
22
|
+
if (!raw || typeof raw !== "object") {
|
|
23
|
+
throw new Error(
|
|
24
|
+
`customRules[${i}]: expected RuleDefinition object (got ${typeof raw})`
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
const rule = raw;
|
|
28
|
+
if (typeof rule.name !== "string" || !rule.name) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`customRules[${i}]: missing "name" (must be non-empty string)`
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
if (typeof rule.description !== "string") {
|
|
34
|
+
throw new TypeError(
|
|
35
|
+
`customRules[${i}] "${rule.name}": missing "description" string`
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
if (typeof rule.check !== "function") {
|
|
39
|
+
throw new TypeError(
|
|
40
|
+
`customRules[${i}] "${rule.name}": "check" must be a function`
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
if (rule.fix !== void 0 && typeof rule.fix !== "function") {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`customRules[${i}] "${rule.name}": "fix" must be a function if provided`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
validated.push(rule);
|
|
49
|
+
}
|
|
50
|
+
return validated;
|
|
51
|
+
};
|
|
52
|
+
const inferSourceType = async (filePath) => {
|
|
53
|
+
for (const name of knownFormatNames()) {
|
|
54
|
+
const fmt = await loadFormat(name);
|
|
55
|
+
if (!canLoad(fmt) || !fmt.defaultPattern) continue;
|
|
56
|
+
if (matchesPattern(filePath, fmt.defaultPattern)) return name;
|
|
57
|
+
}
|
|
58
|
+
const known = knownFormatNames().join(", ");
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Cannot infer source format from "${filePath}". Add explicit \`source.type\` to aact.config.ts (known: ${known}).`
|
|
61
|
+
);
|
|
62
|
+
};
|
|
15
63
|
const loadAndValidateConfig = async (configPath) => {
|
|
16
64
|
const { config } = await loadConfig({
|
|
17
65
|
name: "aact",
|
|
@@ -20,7 +68,24 @@ const loadAndValidateConfig = async (configPath) => {
|
|
|
20
68
|
if (!config) {
|
|
21
69
|
throw new Error("No source configured. Create an aact.config.ts file.");
|
|
22
70
|
}
|
|
23
|
-
|
|
71
|
+
const parsed = v.parse(AactConfigSchema, config);
|
|
72
|
+
const rawSource = typeof parsed.source === "string" ? { path: parsed.source } : parsed.source;
|
|
73
|
+
const type = rawSource.type ?? await inferSourceType(rawSource.path);
|
|
74
|
+
if (rawSource.type && !knownFormatNames().includes(type)) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`Unknown source.type "${type}" in aact.config.ts (known: ${knownFormatNames().join(", ")}).`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
const customRules = parsed.customRules ? validateCustomRules(parsed.customRules) : void 0;
|
|
80
|
+
return {
|
|
81
|
+
...parsed,
|
|
82
|
+
customRules,
|
|
83
|
+
source: {
|
|
84
|
+
path: rawSource.path,
|
|
85
|
+
type,
|
|
86
|
+
..."writePath" in rawSource && rawSource.writePath !== void 0 ? { writePath: rawSource.writePath } : {}
|
|
87
|
+
}
|
|
88
|
+
};
|
|
24
89
|
};
|
|
25
90
|
|
|
26
91
|
const isFileNotFound = (err) => typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
|
|
@@ -32,19 +97,14 @@ const exitWithError = (message, hint) => {
|
|
|
32
97
|
const loadModel = async (config) => {
|
|
33
98
|
const resolvedPath = path.resolve(config.source.path);
|
|
34
99
|
try {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
return await loadStructurizrElements(resolvedPath);
|
|
42
|
-
}
|
|
43
|
-
default: {
|
|
44
|
-
const sourceType = config.source.type;
|
|
45
|
-
throw new Error(`Unsupported source type: ${String(sourceType)}`);
|
|
46
|
-
}
|
|
100
|
+
const format = await loadFormat(config.source.type);
|
|
101
|
+
if (!canLoad(format)) {
|
|
102
|
+
return exitWithError(
|
|
103
|
+
`Format "${format.name}" doesn't support load`,
|
|
104
|
+
"Specify a source-capable format (plantuml, structurizr)."
|
|
105
|
+
);
|
|
47
106
|
}
|
|
107
|
+
return await format.load(resolvedPath);
|
|
48
108
|
} catch (error) {
|
|
49
109
|
if (isFileNotFound(error)) {
|
|
50
110
|
return exitWithError(
|
|
@@ -82,7 +142,7 @@ const analyze = defineCommand({
|
|
|
82
142
|
},
|
|
83
143
|
async run({ args }) {
|
|
84
144
|
const config = await loadAndValidateConfig(args.config);
|
|
85
|
-
const model = await loadModel(config);
|
|
145
|
+
const { model } = await loadModel(config);
|
|
86
146
|
const { report } = analyzeArchitecture(model);
|
|
87
147
|
if (args.format === "json") {
|
|
88
148
|
console.log(JSON.stringify(report, void 0, 2));
|
|
@@ -107,511 +167,121 @@ const analyze = defineCommand({
|
|
|
107
167
|
}
|
|
108
168
|
});
|
|
109
169
|
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
};
|
|
122
|
-
|
|
123
|
-
const applyIndent = (content, indent) => content.split("\n").map((line) => line.trim() ? indent + line : line).join("\n");
|
|
124
|
-
const applyEdits = (source, edits) => {
|
|
125
|
-
const lines = source.split("\n");
|
|
126
|
-
for (const edit of edits) {
|
|
127
|
-
const idx = lines.findIndex((line) => line.includes(edit.search));
|
|
128
|
-
if (idx === -1) {
|
|
129
|
-
consola.warn(`fix: pattern not found in source \u2014 "${edit.search}"`);
|
|
130
|
-
continue;
|
|
131
|
-
}
|
|
132
|
-
const matchCount = lines.filter(
|
|
133
|
-
(line) => line.includes(edit.search)
|
|
134
|
-
).length;
|
|
135
|
-
if (matchCount > 1) {
|
|
136
|
-
consola.warn(
|
|
137
|
-
`fix: ambiguous pattern "${edit.search}" matches ${matchCount} lines, using first`
|
|
170
|
+
const exitWithViolations = () => process.exit(1);
|
|
171
|
+
const buildEffectiveRules = (customRules) => {
|
|
172
|
+
if (!customRules || customRules.length === 0) return ruleRegistry;
|
|
173
|
+
const seen = /* @__PURE__ */ new Map();
|
|
174
|
+
for (const r of ruleRegistry) seen.set(r.name, "built-in");
|
|
175
|
+
const merged = [...ruleRegistry];
|
|
176
|
+
for (const r of customRules) {
|
|
177
|
+
const existing = seen.get(r.name);
|
|
178
|
+
if (existing) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
`customRules: rule "${r.name}" conflicts with existing ${existing} rule. Rename your custom rule (e.g. prefix with your project name).`
|
|
138
181
|
);
|
|
139
182
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
case "remove": {
|
|
143
|
-
lines.splice(idx, 1);
|
|
144
|
-
break;
|
|
145
|
-
}
|
|
146
|
-
case "replace": {
|
|
147
|
-
lines[idx] = applyIndent(edit.content ?? "", indent);
|
|
148
|
-
break;
|
|
149
|
-
}
|
|
150
|
-
case "add": {
|
|
151
|
-
lines.splice(idx + 1, 0, applyIndent(edit.content ?? "", indent));
|
|
152
|
-
break;
|
|
153
|
-
}
|
|
154
|
-
}
|
|
183
|
+
seen.set(r.name, "custom");
|
|
184
|
+
merged.push(r);
|
|
155
185
|
}
|
|
156
|
-
return
|
|
186
|
+
return merged;
|
|
157
187
|
};
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const withHyphen = names.filter((n) => n.includes("-")).length;
|
|
164
|
-
const withCamel = names.filter((n) => /[a-z][A-Z]/.test(n)).length;
|
|
165
|
-
if (withHyphen > withUnderscore && withHyphen > withCamel) return "kebab";
|
|
166
|
-
if (withCamel > withUnderscore) return "camel";
|
|
167
|
-
return "snake";
|
|
168
|
-
};
|
|
169
|
-
const joinName = (base, word, convention) => {
|
|
170
|
-
switch (convention) {
|
|
171
|
-
case "camel": {
|
|
172
|
-
return base + word.charAt(0).toUpperCase() + word.slice(1);
|
|
173
|
-
}
|
|
174
|
-
case "kebab": {
|
|
175
|
-
return `${base}-${word}`;
|
|
176
|
-
}
|
|
177
|
-
default: {
|
|
178
|
-
return `${base}_${word}`;
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
};
|
|
182
|
-
|
|
183
|
-
const fixAcl = (model, violations, syntax, options) => {
|
|
184
|
-
const tag = options?.tag ?? "acl";
|
|
185
|
-
const convention = detectNamingConvention(model);
|
|
186
|
-
const externalType = options?.externalType ?? EXTERNAL_SYSTEM_TYPE;
|
|
187
|
-
const results = [];
|
|
188
|
-
for (const violation of violations) {
|
|
189
|
-
const container = model.allContainers.find(
|
|
190
|
-
(c) => c.name === violation.container
|
|
191
|
-
);
|
|
192
|
-
if (!container) continue;
|
|
193
|
-
const externalRels = container.relations.filter(
|
|
194
|
-
(r) => r.to.type === externalType
|
|
195
|
-
);
|
|
196
|
-
if (externalRels.length === 0) continue;
|
|
197
|
-
const aclName = joinName(container.name, "acl", convention);
|
|
198
|
-
if (model.allContainers.some((c) => c.name === aclName)) {
|
|
188
|
+
const warnUnknownRuleNames = (rules, effective) => {
|
|
189
|
+
if (!rules) return;
|
|
190
|
+
const known = new Set(effective.map((r) => r.name));
|
|
191
|
+
for (const key of Object.keys(rules)) {
|
|
192
|
+
if (!known.has(key)) {
|
|
199
193
|
consola.warn(
|
|
200
|
-
`
|
|
194
|
+
`Unknown rule "${key}" in config.rules \u2014 ignored. Did you forget to add it to customRules?`
|
|
201
195
|
);
|
|
202
|
-
continue;
|
|
203
|
-
}
|
|
204
|
-
const fix = {
|
|
205
|
-
rule: "acl",
|
|
206
|
-
description: `Add ACL layer for ${container.name}`,
|
|
207
|
-
edits: []
|
|
208
|
-
};
|
|
209
|
-
fix.edits.push(
|
|
210
|
-
// 1. Add ACL container after the violating container
|
|
211
|
-
{
|
|
212
|
-
type: "add",
|
|
213
|
-
search: syntax.containerPattern(container.name),
|
|
214
|
-
content: syntax.containerDecl(aclName, `${container.label} ACL`, tag)
|
|
215
|
-
},
|
|
216
|
-
// 2. Add single Rel(svc, acl) after the ACL container declaration
|
|
217
|
-
{
|
|
218
|
-
type: "add",
|
|
219
|
-
search: syntax.containerPattern(aclName),
|
|
220
|
-
content: syntax.relationDecl(container.name, aclName)
|
|
221
|
-
},
|
|
222
|
-
...externalRels.map((rel) => ({
|
|
223
|
-
type: "replace",
|
|
224
|
-
search: syntax.relationPattern(container.name, rel.to.name),
|
|
225
|
-
content: syntax.relationDecl(aclName, rel.to.name, rel.technology)
|
|
226
|
-
}))
|
|
227
|
-
);
|
|
228
|
-
results.push(fix);
|
|
229
|
-
}
|
|
230
|
-
return results;
|
|
231
|
-
};
|
|
232
|
-
|
|
233
|
-
const buildContainerBoundaryMap = (model) => {
|
|
234
|
-
const map = /* @__PURE__ */ new Map();
|
|
235
|
-
for (const boundary of model.boundaries) {
|
|
236
|
-
for (const container of boundary.containers) {
|
|
237
|
-
map.set(container.name, boundary);
|
|
238
196
|
}
|
|
239
197
|
}
|
|
240
|
-
return map;
|
|
241
198
|
};
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
(c) => c.type !== dbType && !ownerTags.some((t) => c.tags?.includes(t))
|
|
245
|
-
);
|
|
246
|
-
if (candidates.length === 0) return void 0;
|
|
247
|
-
if (candidates.length === 1) return candidates[0];
|
|
248
|
-
const candidateNames = new Set(candidates.map((c) => c.name));
|
|
249
|
-
const inDegree = new Map(candidates.map((c) => [c.name, 0]));
|
|
250
|
-
for (const container of model.allContainers) {
|
|
251
|
-
if (containerBoundaryMap.get(container.name) === targetBoundary) continue;
|
|
252
|
-
for (const rel of container.relations) {
|
|
253
|
-
if (candidateNames.has(rel.to.name)) {
|
|
254
|
-
inDegree.set(rel.to.name, (inDegree.get(rel.to.name) ?? 0) + 1);
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
return candidates.toSorted(
|
|
259
|
-
(a, b) => (inDegree.get(b.name) ?? 0) - (inDegree.get(a.name) ?? 0)
|
|
260
|
-
)[0];
|
|
261
|
-
};
|
|
262
|
-
const resolveRedirectTarget = (accessor, db, owner, dbType, ownerTags, model, containerBoundaryMap, ruleName) => {
|
|
263
|
-
const accessorBoundary = containerBoundaryMap.get(accessor.name);
|
|
264
|
-
const dbBoundary = containerBoundaryMap.get(db.name);
|
|
265
|
-
const isCrossBoundary = accessorBoundary !== void 0 && dbBoundary !== void 0 && accessorBoundary !== dbBoundary;
|
|
266
|
-
if (!isCrossBoundary) return owner;
|
|
267
|
-
const publicApi = findPublicApiCandidate(
|
|
268
|
-
dbBoundary,
|
|
269
|
-
dbType,
|
|
270
|
-
ownerTags,
|
|
271
|
-
model,
|
|
272
|
-
containerBoundaryMap
|
|
273
|
-
);
|
|
274
|
-
if (!publicApi) {
|
|
275
|
-
consola.warn(
|
|
276
|
-
`fix ${ruleName}: boundary "${dbBoundary.name}" has no public API \u2014 cannot auto-redirect "${accessor.name}" away from "${db.name}", fix manually`
|
|
277
|
-
);
|
|
278
|
-
return void 0;
|
|
279
|
-
}
|
|
280
|
-
if (publicApi === owner) {
|
|
281
|
-
consola.warn(
|
|
282
|
-
`fix ${ruleName}: the only public API candidate in "${dbBoundary.name}" is the repo owner \u2014 cross-boundary access from "${accessor.name}" requires manual review`
|
|
283
|
-
);
|
|
284
|
-
return void 0;
|
|
285
|
-
}
|
|
286
|
-
return publicApi;
|
|
287
|
-
};
|
|
288
|
-
|
|
289
|
-
const stripDbWord = (name) => {
|
|
290
|
-
const lower = name.toLowerCase();
|
|
291
|
-
for (const suffix of [
|
|
292
|
-
"_database",
|
|
293
|
-
"-database",
|
|
294
|
-
"database",
|
|
295
|
-
"_db",
|
|
296
|
-
"-db",
|
|
297
|
-
"db"
|
|
298
|
-
]) {
|
|
299
|
-
if (lower.endsWith(suffix)) {
|
|
300
|
-
return name.slice(0, -suffix.length);
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
return name;
|
|
304
|
-
};
|
|
305
|
-
const deriveRepoName = (dbName, convention) => {
|
|
306
|
-
const base = stripDbWord(dbName);
|
|
307
|
-
return joinName(base || dbName, "repo", convention);
|
|
308
|
-
};
|
|
309
|
-
const deriveRepoLabel = (dbName) => {
|
|
310
|
-
const base = stripDbWord(dbName);
|
|
311
|
-
const word = base || dbName;
|
|
312
|
-
return word.charAt(0).toUpperCase() + word.slice(1).replaceAll("_", " ") + " Repo";
|
|
313
|
-
};
|
|
314
|
-
const fixNonRepoAccessesDb = (accessor, model, syntax, dbType, ownerTags, convention) => {
|
|
315
|
-
const dbRels = accessor.relations.filter((r) => r.to.type === dbType);
|
|
316
|
-
if (dbRels.length === 0) return void 0;
|
|
317
|
-
const containerBoundaryMap = buildContainerBoundaryMap(model);
|
|
318
|
-
const edits = dbRels.flatMap((rel) => {
|
|
319
|
-
const db = rel.to;
|
|
320
|
-
const existingRepo = model.allContainers.find(
|
|
321
|
-
(c) => c !== accessor && c.relations.some((r) => r.to.name === db.name) && ownerTags.some((t) => c.tags?.includes(t))
|
|
322
|
-
);
|
|
323
|
-
if (existingRepo) {
|
|
324
|
-
const redirectTarget = resolveRedirectTarget(
|
|
325
|
-
accessor,
|
|
326
|
-
db,
|
|
327
|
-
existingRepo,
|
|
328
|
-
dbType,
|
|
329
|
-
ownerTags,
|
|
330
|
-
model,
|
|
331
|
-
containerBoundaryMap,
|
|
332
|
-
"crud"
|
|
333
|
-
);
|
|
334
|
-
if (!redirectTarget) return [];
|
|
335
|
-
return [
|
|
336
|
-
{
|
|
337
|
-
type: "replace",
|
|
338
|
-
search: syntax.relationPattern(accessor.name, db.name),
|
|
339
|
-
content: syntax.relationDecl(
|
|
340
|
-
accessor.name,
|
|
341
|
-
redirectTarget.name,
|
|
342
|
-
rel.technology
|
|
343
|
-
)
|
|
344
|
-
}
|
|
345
|
-
];
|
|
346
|
-
}
|
|
347
|
-
const accessorBoundary = containerBoundaryMap.get(accessor.name);
|
|
348
|
-
const dbBoundary = containerBoundaryMap.get(db.name);
|
|
349
|
-
if (accessorBoundary !== void 0 && dbBoundary !== void 0 && accessorBoundary !== dbBoundary) {
|
|
350
|
-
consola.warn(
|
|
351
|
-
`fix crud: "${accessor.name}" accesses "${db.name}" cross-boundary with no existing repo \u2014 fix manually`
|
|
352
|
-
);
|
|
353
|
-
return [];
|
|
354
|
-
}
|
|
355
|
-
const repoName = deriveRepoName(db.name, convention);
|
|
356
|
-
if (model.allContainers.some((c) => c.name === repoName)) {
|
|
357
|
-
consola.warn(
|
|
358
|
-
`fix crud: cannot create repo for "${db.name}" \u2014 "${repoName}" already exists`
|
|
359
|
-
);
|
|
360
|
-
return [];
|
|
361
|
-
}
|
|
362
|
-
return [
|
|
363
|
-
{
|
|
364
|
-
type: "add",
|
|
365
|
-
search: syntax.containerPattern(db.name),
|
|
366
|
-
content: syntax.containerDecl(
|
|
367
|
-
repoName,
|
|
368
|
-
deriveRepoLabel(db.name),
|
|
369
|
-
ownerTags[0] ?? "repo"
|
|
370
|
-
)
|
|
371
|
-
},
|
|
372
|
-
{
|
|
373
|
-
type: "add",
|
|
374
|
-
search: syntax.containerPattern(repoName),
|
|
375
|
-
content: syntax.relationDecl(repoName, db.name, rel.technology)
|
|
376
|
-
},
|
|
377
|
-
{
|
|
378
|
-
type: "replace",
|
|
379
|
-
search: syntax.relationPattern(accessor.name, db.name),
|
|
380
|
-
content: syntax.relationDecl(accessor.name, repoName, rel.technology)
|
|
381
|
-
}
|
|
382
|
-
];
|
|
383
|
-
});
|
|
384
|
-
if (edits.length === 0) return void 0;
|
|
385
|
-
return {
|
|
386
|
-
rule: "crud",
|
|
387
|
-
description: `Add repo intermediary for ${accessor.name} \u2192 ${dbRels.map((r) => r.to.name).join(", ")}`,
|
|
388
|
-
edits
|
|
389
|
-
};
|
|
390
|
-
};
|
|
391
|
-
const fixRepoWithNonDbDeps = (repo, syntax, dbType) => {
|
|
392
|
-
const nonDbRels = repo.relations.filter((r) => r.to.type !== dbType);
|
|
393
|
-
if (nonDbRels.length === 0) return void 0;
|
|
394
|
-
return {
|
|
395
|
-
rule: "crud",
|
|
396
|
-
description: `Remove non-database dependencies from repo ${repo.name}`,
|
|
397
|
-
edits: nonDbRels.map((rel) => ({
|
|
398
|
-
type: "remove",
|
|
399
|
-
search: syntax.relationPattern(repo.name, rel.to.name)
|
|
400
|
-
}))
|
|
401
|
-
};
|
|
402
|
-
};
|
|
403
|
-
const fixCrud = (model, violations, syntax, options) => {
|
|
404
|
-
const dbType = options?.dbType ?? CONTAINER_DB_TYPE;
|
|
405
|
-
const ownerTags = options?.repoTags ?? ["repo", "relay"];
|
|
406
|
-
const convention = detectNamingConvention(model);
|
|
199
|
+
const getRuleConfigValue = (rules, ruleName) => rules?.[ruleName];
|
|
200
|
+
const runRules = (model, rules, effective) => {
|
|
407
201
|
const results = [];
|
|
408
|
-
for (const
|
|
409
|
-
const
|
|
410
|
-
(c) => c.name === violation.container
|
|
411
|
-
);
|
|
412
|
-
if (!container) continue;
|
|
413
|
-
const isRepo = ownerTags.some((t) => container.tags?.includes(t));
|
|
414
|
-
if (isRepo) {
|
|
415
|
-
const fix = fixRepoWithNonDbDeps(container, syntax, dbType);
|
|
416
|
-
if (fix) results.push(fix);
|
|
417
|
-
} else {
|
|
418
|
-
const fix = fixNonRepoAccessesDb(
|
|
419
|
-
container,
|
|
420
|
-
model,
|
|
421
|
-
syntax,
|
|
422
|
-
dbType,
|
|
423
|
-
ownerTags,
|
|
424
|
-
convention
|
|
425
|
-
);
|
|
426
|
-
if (fix) results.push(fix);
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
return results;
|
|
430
|
-
};
|
|
431
|
-
|
|
432
|
-
const resolveOwner = (dbName, accessors, ownerTags) => {
|
|
433
|
-
const tagged = accessors.filter(
|
|
434
|
-
(c) => c.tags?.some((t) => ownerTags.includes(t))
|
|
435
|
-
);
|
|
436
|
-
if (tagged.length === 0) {
|
|
437
|
-
consola.warn(
|
|
438
|
-
`Cannot determine owner of ${dbName}: no ${ownerTags.join("/")} tagged accessor found, using ${accessors[0].name}`
|
|
439
|
-
);
|
|
440
|
-
return accessors[0];
|
|
441
|
-
}
|
|
442
|
-
if (tagged.length > 1) {
|
|
443
|
-
consola.warn(
|
|
444
|
-
`Cannot determine owner of ${dbName}: multiple tagged accessors (${tagged.map((c) => c.name).join(", ")}), using ${tagged[0].name}`
|
|
445
|
-
);
|
|
446
|
-
}
|
|
447
|
-
return tagged[0];
|
|
448
|
-
};
|
|
449
|
-
const fixDbPerService = (model, violations, syntax, options) => {
|
|
450
|
-
const dbType = options?.dbType ?? CONTAINER_DB_TYPE;
|
|
451
|
-
const ownerTags = options?.ownerTags ?? ["repo", "relay"];
|
|
452
|
-
const containerBoundaryMap = buildContainerBoundaryMap(model);
|
|
453
|
-
const results = [];
|
|
454
|
-
for (const violation of violations) {
|
|
455
|
-
const db = model.allContainers.find(
|
|
456
|
-
(c) => c.name === violation.container && c.type === dbType
|
|
457
|
-
);
|
|
458
|
-
if (!db) continue;
|
|
459
|
-
const accessors = model.allContainers.filter(
|
|
460
|
-
(c) => c.relations.some((r) => r.to.name === db.name)
|
|
461
|
-
);
|
|
462
|
-
if (accessors.length <= 1) continue;
|
|
463
|
-
const owner = resolveOwner(db.name, accessors, ownerTags);
|
|
464
|
-
const edits = accessors.filter((c) => c !== owner).flatMap((accessor) => {
|
|
465
|
-
const rel = accessor.relations.find((r) => r.to.name === db.name);
|
|
466
|
-
if (!rel) {
|
|
467
|
-
consola.warn(
|
|
468
|
-
`fix dbPerService: relation from ${accessor.name} to ${db.name} not found, skipping`
|
|
469
|
-
);
|
|
470
|
-
return [];
|
|
471
|
-
}
|
|
472
|
-
const redirectTarget = resolveRedirectTarget(
|
|
473
|
-
accessor,
|
|
474
|
-
db,
|
|
475
|
-
owner,
|
|
476
|
-
dbType,
|
|
477
|
-
ownerTags,
|
|
478
|
-
model,
|
|
479
|
-
containerBoundaryMap,
|
|
480
|
-
"dbPerService"
|
|
481
|
-
);
|
|
482
|
-
if (!redirectTarget) return [];
|
|
483
|
-
const tags = rel.tags && rel.tags.length > 0 ? rel.tags.join("+") : void 0;
|
|
484
|
-
return [
|
|
485
|
-
{
|
|
486
|
-
type: "replace",
|
|
487
|
-
search: syntax.relationPattern(accessor.name, db.name),
|
|
488
|
-
content: syntax.relationDecl(
|
|
489
|
-
accessor.name,
|
|
490
|
-
redirectTarget.name,
|
|
491
|
-
rel.technology ?? "",
|
|
492
|
-
tags
|
|
493
|
-
)
|
|
494
|
-
}
|
|
495
|
-
];
|
|
496
|
-
});
|
|
497
|
-
if (edits.length === 0) continue;
|
|
498
|
-
results.push({
|
|
499
|
-
rule: "dbPerService",
|
|
500
|
-
description: `Redirect access to ${db.name} through ${owner.name}`,
|
|
501
|
-
edits
|
|
502
|
-
});
|
|
503
|
-
}
|
|
504
|
-
return results;
|
|
505
|
-
};
|
|
506
|
-
|
|
507
|
-
const defineRule = (def) => def;
|
|
508
|
-
const ruleRegistry = [
|
|
509
|
-
defineRule({
|
|
510
|
-
name: "acl",
|
|
511
|
-
check: (m, o) => checkAcl(m.allContainers, o),
|
|
512
|
-
fix: fixAcl
|
|
513
|
-
}),
|
|
514
|
-
defineRule({ name: "acyclic", check: (m) => checkAcyclic(m.allContainers) }),
|
|
515
|
-
defineRule({
|
|
516
|
-
name: "apiGateway",
|
|
517
|
-
check: (m, o) => checkApiGateway(m.allContainers, o)
|
|
518
|
-
}),
|
|
519
|
-
defineRule({
|
|
520
|
-
name: "crud",
|
|
521
|
-
check: (m, o) => checkCrud(m.allContainers, o),
|
|
522
|
-
fix: fixCrud
|
|
523
|
-
}),
|
|
524
|
-
defineRule({
|
|
525
|
-
name: "dbPerService",
|
|
526
|
-
check: (m, o) => checkDbPerService(m.allContainers, o),
|
|
527
|
-
fix: fixDbPerService
|
|
528
|
-
}),
|
|
529
|
-
defineRule({
|
|
530
|
-
name: "cohesion",
|
|
531
|
-
check: (m, o) => checkCohesion(m, o)
|
|
532
|
-
}),
|
|
533
|
-
defineRule({
|
|
534
|
-
name: "stableDependencies",
|
|
535
|
-
check: (m, o) => checkStableDependencies(m.allContainers, o)
|
|
536
|
-
}),
|
|
537
|
-
defineRule({
|
|
538
|
-
name: "commonReuse",
|
|
539
|
-
check: (m) => checkCommonReuse(m)
|
|
540
|
-
})
|
|
541
|
-
];
|
|
542
|
-
|
|
543
|
-
const ruleMap = new Map(ruleRegistry.map((r) => [r.name, r]));
|
|
544
|
-
const exitWithViolations = () => process.exit(1);
|
|
545
|
-
const runRules = (model, rules) => {
|
|
546
|
-
const results = [];
|
|
547
|
-
for (const rule of ruleRegistry) {
|
|
548
|
-
const configValue = rules?.[rule.name];
|
|
202
|
+
for (const rule of effective) {
|
|
203
|
+
const configValue = getRuleConfigValue(rules, rule.name);
|
|
549
204
|
if (configValue === false) continue;
|
|
550
205
|
const options = typeof configValue === "object" ? configValue : void 0;
|
|
551
206
|
results.push({ name: rule.name, violations: rule.check(model, options) });
|
|
552
207
|
}
|
|
553
208
|
return results;
|
|
554
209
|
};
|
|
555
|
-
const
|
|
556
|
-
|
|
557
|
-
|
|
210
|
+
const resolveFixCapability = async (config) => {
|
|
211
|
+
const format = await loadFormat(config.source.type);
|
|
212
|
+
if (!canFix(format)) {
|
|
213
|
+
consola.warn(`Format "${format.name}" doesn't support --fix`);
|
|
214
|
+
return null;
|
|
558
215
|
}
|
|
559
|
-
if (config.source.type === "structurizr") {
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
return null;
|
|
565
|
-
}
|
|
566
|
-
return structurizrDslSyntax;
|
|
216
|
+
if (config.source.type === "structurizr" && !config.source.writePath) {
|
|
217
|
+
consola.warn(
|
|
218
|
+
"To use --fix with structurizr, add source.writePath pointing to your workspace.dsl"
|
|
219
|
+
);
|
|
220
|
+
return null;
|
|
567
221
|
}
|
|
568
|
-
return
|
|
222
|
+
return format.fix;
|
|
569
223
|
};
|
|
570
|
-
const generateFixes = (model, results, rules, syntax) => {
|
|
224
|
+
const generateFixes = (model, results, rules, syntax, effective) => {
|
|
225
|
+
const ruleByName = new Map(effective.map((r) => [r.name, r]));
|
|
571
226
|
const fixes = [];
|
|
572
227
|
for (const result of results) {
|
|
573
228
|
if (result.violations.length === 0) continue;
|
|
574
|
-
const ruleDef =
|
|
229
|
+
const ruleDef = ruleByName.get(result.name);
|
|
575
230
|
if (!ruleDef?.fix) continue;
|
|
576
|
-
const configValue = rules
|
|
231
|
+
const configValue = getRuleConfigValue(rules, ruleDef.name);
|
|
577
232
|
const options = typeof configValue === "object" ? configValue : void 0;
|
|
578
|
-
fixes.push(
|
|
233
|
+
fixes.push(
|
|
234
|
+
...ruleDef.fix?.(model, result.violations, syntax, options) ?? []
|
|
235
|
+
);
|
|
579
236
|
}
|
|
580
237
|
return fixes;
|
|
581
238
|
};
|
|
582
|
-
const formatText = (results) => {
|
|
239
|
+
const formatText = (results, effective) => {
|
|
583
240
|
const failed = results.filter((r) => r.violations.length > 0);
|
|
584
241
|
const passed = results.filter((r) => r.violations.length === 0);
|
|
585
242
|
for (const result of failed) {
|
|
586
243
|
const count = result.violations.length;
|
|
587
244
|
const label = count === 1 ? "violation" : "violations";
|
|
588
|
-
const countLabel =
|
|
589
|
-
console.log(`${
|
|
245
|
+
const countLabel = colors.red(`${count} ${label}`);
|
|
246
|
+
console.log(`${colors.bold(colors.red(result.name))} ${countLabel}`);
|
|
590
247
|
const maxLen = Math.max(
|
|
591
248
|
...result.violations.map((v) => v.container.length)
|
|
592
249
|
);
|
|
593
250
|
for (const v of result.violations) {
|
|
594
|
-
console.log(` ${
|
|
251
|
+
console.log(` ${colors.bold(v.container.padEnd(maxLen))} ${v.message}`);
|
|
595
252
|
}
|
|
596
253
|
console.log();
|
|
597
254
|
}
|
|
598
255
|
if (passed.length > 0) {
|
|
599
256
|
console.log(
|
|
600
|
-
`${
|
|
257
|
+
`${colors.dim("Passed")} ${passed.map((r) => colors.green(r.name)).join(colors.dim(" \xB7 "))}`
|
|
601
258
|
);
|
|
602
259
|
console.log();
|
|
603
260
|
}
|
|
604
261
|
const total = failed.reduce((n, r) => n + r.violations.length, 0);
|
|
605
262
|
if (total === 0) {
|
|
606
|
-
console.log(pc.green("No violations found."));
|
|
607
|
-
} else {
|
|
608
|
-
const rulesLabel = failed.length === 1 ? "rule" : "rules";
|
|
609
263
|
console.log(
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
264
|
+
box(colors.green("No violations found."), {
|
|
265
|
+
title: colors.green("\u2713 check"),
|
|
266
|
+
style: { borderColor: "green" }
|
|
267
|
+
})
|
|
613
268
|
);
|
|
269
|
+
return;
|
|
614
270
|
}
|
|
271
|
+
const fixableRules = failed.filter(
|
|
272
|
+
(r) => typeof effective.find((rd) => rd.name === r.name)?.fix === "function"
|
|
273
|
+
).length;
|
|
274
|
+
const violationsLabel = total === 1 ? "violation" : "violations";
|
|
275
|
+
const rulesLabel = failed.length === 1 ? "rule" : "rules";
|
|
276
|
+
const fixableHas = fixableRules === 1 ? "rule has auto-fix" : "rules have auto-fix";
|
|
277
|
+
const fixableLine = fixableRules > 0 ? "\n" + colors.dim(`${fixableRules} ${fixableHas} \u2014 run with --fix`) : "";
|
|
278
|
+
const headline = colors.red(`${total} ${violationsLabel}`) + " " + colors.dim("in") + " " + colors.red(`${failed.length} ${rulesLabel}`) + fixableLine;
|
|
279
|
+
console.log(
|
|
280
|
+
box(headline, {
|
|
281
|
+
title: colors.red("\u2717 check"),
|
|
282
|
+
style: { borderColor: "red" }
|
|
283
|
+
})
|
|
284
|
+
);
|
|
615
285
|
};
|
|
616
286
|
const formatJson = (results) => {
|
|
617
287
|
const output = {
|
|
@@ -633,25 +303,29 @@ const formatGithub = (results) => {
|
|
|
633
303
|
const prefixContent = (content, first, rest) => content.split("\n").map((line, i) => i === 0 ? first + line : rest + line).join("\n");
|
|
634
304
|
const formatFixes = (fixes) => {
|
|
635
305
|
for (const fix of fixes) {
|
|
636
|
-
const ruleTag =
|
|
306
|
+
const ruleTag = colors.bold(`[${fix.rule}]`);
|
|
637
307
|
console.log(` ${ruleTag} ${fix.description}`);
|
|
638
308
|
for (const edit of fix.edits) {
|
|
639
309
|
switch (edit.type) {
|
|
640
310
|
case "remove": {
|
|
641
|
-
console.log(
|
|
311
|
+
console.log(
|
|
312
|
+
colors.red(prefixContent(edit.search, " - ", " "))
|
|
313
|
+
);
|
|
642
314
|
break;
|
|
643
315
|
}
|
|
644
316
|
case "replace": {
|
|
645
|
-
console.log(pc.red(prefixContent(edit.search, " - ", " ")));
|
|
646
317
|
console.log(
|
|
647
|
-
|
|
318
|
+
colors.red(prefixContent(edit.search, " - ", " "))
|
|
319
|
+
);
|
|
320
|
+
console.log(
|
|
321
|
+
colors.green(prefixContent(edit.content ?? "", " + ", " "))
|
|
648
322
|
);
|
|
649
323
|
break;
|
|
650
324
|
}
|
|
651
325
|
case "add": {
|
|
652
|
-
console.log(
|
|
326
|
+
console.log(colors.dim(` (after "${edit.search}")`));
|
|
653
327
|
console.log(
|
|
654
|
-
|
|
328
|
+
colors.green(prefixContent(edit.content ?? "", " + ", " "))
|
|
655
329
|
);
|
|
656
330
|
break;
|
|
657
331
|
}
|
|
@@ -665,7 +339,7 @@ const detectFormat = (format) => {
|
|
|
665
339
|
if (process.env.GITHUB_ACTIONS) return "github";
|
|
666
340
|
return "text";
|
|
667
341
|
};
|
|
668
|
-
const formatResults = (results, format) => {
|
|
342
|
+
const formatResults = (results, format, effective) => {
|
|
669
343
|
switch (format) {
|
|
670
344
|
case "json": {
|
|
671
345
|
formatJson(results);
|
|
@@ -676,11 +350,11 @@ const formatResults = (results, format) => {
|
|
|
676
350
|
break;
|
|
677
351
|
}
|
|
678
352
|
default: {
|
|
679
|
-
formatText(results);
|
|
353
|
+
formatText(results, effective);
|
|
680
354
|
}
|
|
681
355
|
}
|
|
682
356
|
};
|
|
683
|
-
const writeFixes = async (config, fixes) => {
|
|
357
|
+
const writeFixes = async (config, fixes, effective) => {
|
|
684
358
|
const writePath = path.resolve(config.source.writePath ?? config.source.path);
|
|
685
359
|
let source = await readFile(writePath, "utf8");
|
|
686
360
|
for (const fix of fixes) {
|
|
@@ -694,43 +368,55 @@ const writeFixes = async (config, fixes) => {
|
|
|
694
368
|
"DSL updated \u2014 regenerate workspace.json from workspace.dsl before re-checking"
|
|
695
369
|
);
|
|
696
370
|
} else {
|
|
697
|
-
const reModel = await loadModel(config);
|
|
698
|
-
const reResults = runRules(reModel, config.rules);
|
|
371
|
+
const { model: reModel } = await loadModel(config);
|
|
372
|
+
const reResults = runRules(reModel, config.rules, effective);
|
|
699
373
|
const remaining = reResults.reduce((n, r) => n + r.violations.length, 0);
|
|
700
374
|
consola.success(
|
|
701
375
|
`Applied ${fixes.length} fix(es), wrote ${writePath}` + (remaining > 0 ? ` (${remaining} violation(s) remain)` : "")
|
|
702
376
|
);
|
|
703
377
|
}
|
|
704
378
|
};
|
|
705
|
-
const handleFixMode = async (model, results, config, dryRun) => {
|
|
379
|
+
const handleFixMode = async (model, results, config, dryRun, effective) => {
|
|
706
380
|
const hasViolations = results.some((r) => r.violations.length > 0);
|
|
707
381
|
if (!hasViolations) {
|
|
708
382
|
consola.success("No violations to fix");
|
|
709
383
|
return;
|
|
710
384
|
}
|
|
711
|
-
const
|
|
712
|
-
if (!
|
|
713
|
-
const fixes = generateFixes(
|
|
385
|
+
const fixCapability = await resolveFixCapability(config);
|
|
386
|
+
if (!fixCapability) return exitWithViolations();
|
|
387
|
+
const fixes = generateFixes(
|
|
388
|
+
model,
|
|
389
|
+
results,
|
|
390
|
+
config.rules,
|
|
391
|
+
fixCapability.syntax,
|
|
392
|
+
effective
|
|
393
|
+
);
|
|
714
394
|
if (fixes.length === 0) {
|
|
715
395
|
consola.info("No auto-fixes available for these violations");
|
|
716
396
|
exitWithViolations();
|
|
717
397
|
}
|
|
718
398
|
console.log(
|
|
719
|
-
|
|
399
|
+
colors.bold(dryRun ? "Suggested fixes (dry run):" : "Applying fixes:")
|
|
720
400
|
);
|
|
721
401
|
console.log();
|
|
722
402
|
formatFixes(fixes);
|
|
723
403
|
console.log();
|
|
724
404
|
if (!dryRun) {
|
|
725
|
-
await writeFixes(config, fixes);
|
|
405
|
+
await writeFixes(config, fixes, effective);
|
|
726
406
|
}
|
|
727
407
|
};
|
|
728
|
-
const suggestFixes = (model, results, config) => {
|
|
729
|
-
const
|
|
730
|
-
if (!
|
|
731
|
-
const fixes = generateFixes(
|
|
408
|
+
const suggestFixes = async (model, results, config, effective) => {
|
|
409
|
+
const fixCapability = await resolveFixCapability(config);
|
|
410
|
+
if (!fixCapability) return;
|
|
411
|
+
const fixes = generateFixes(
|
|
412
|
+
model,
|
|
413
|
+
[...results],
|
|
414
|
+
config.rules,
|
|
415
|
+
fixCapability.syntax,
|
|
416
|
+
effective
|
|
417
|
+
);
|
|
732
418
|
if (fixes.length > 0) {
|
|
733
|
-
console.log(
|
|
419
|
+
console.log(colors.bold("Suggested fixes:"));
|
|
734
420
|
console.log();
|
|
735
421
|
formatFixes(fixes);
|
|
736
422
|
}
|
|
@@ -757,43 +443,32 @@ const check = defineCommand({
|
|
|
757
443
|
},
|
|
758
444
|
async run({ args }) {
|
|
759
445
|
const config = await loadAndValidateConfig(args.config);
|
|
760
|
-
const
|
|
761
|
-
|
|
762
|
-
|
|
446
|
+
const effective = buildEffectiveRules(config.customRules);
|
|
447
|
+
warnUnknownRuleNames(config.rules, effective);
|
|
448
|
+
const { model, issues } = await loadModel(config);
|
|
449
|
+
for (const issue of issues) {
|
|
450
|
+
consola.warn(`model: ${issue.kind}`, issue);
|
|
451
|
+
}
|
|
452
|
+
const results = runRules(model, config.rules, effective);
|
|
453
|
+
formatResults(results, detectFormat(args.format), effective);
|
|
763
454
|
const hasViolations = results.some((r) => r.violations.length > 0);
|
|
764
455
|
if (args.fix || args["dry-run"]) {
|
|
765
|
-
await handleFixMode(
|
|
456
|
+
await handleFixMode(
|
|
457
|
+
model,
|
|
458
|
+
results,
|
|
459
|
+
config,
|
|
460
|
+
args["dry-run"] ?? false,
|
|
461
|
+
effective
|
|
462
|
+
);
|
|
766
463
|
return;
|
|
767
464
|
}
|
|
768
465
|
if (hasViolations) {
|
|
769
|
-
suggestFixes(model, results, config);
|
|
466
|
+
await suggestFixes(model, results, config, effective);
|
|
770
467
|
exitWithViolations();
|
|
771
468
|
}
|
|
772
469
|
}
|
|
773
470
|
});
|
|
774
471
|
|
|
775
|
-
const runPlantuml = async (model, config, outputPath) => {
|
|
776
|
-
const puml = generatePlantumlFromModel(model, {
|
|
777
|
-
boundaryLabel: config.generate?.boundaryLabel
|
|
778
|
-
});
|
|
779
|
-
if (outputPath) {
|
|
780
|
-
await fs.writeFile(outputPath, puml);
|
|
781
|
-
consola.success(`Written to ${outputPath}`);
|
|
782
|
-
} else {
|
|
783
|
-
console.log(puml);
|
|
784
|
-
}
|
|
785
|
-
};
|
|
786
|
-
const runKubernetes = async (model, config, outputDir) => {
|
|
787
|
-
const outputs = generateKubernetes(model);
|
|
788
|
-
const targetDir = outputDir ?? config.generate?.kubernetes?.path ?? "resources/kubernetes/microservices";
|
|
789
|
-
await fs.mkdir(targetDir, { recursive: true });
|
|
790
|
-
await Promise.all(
|
|
791
|
-
outputs.map(
|
|
792
|
-
(output) => fs.writeFile(path.join(targetDir, output.fileName), output.content)
|
|
793
|
-
)
|
|
794
|
-
);
|
|
795
|
-
consola.success(`Generated ${outputs.length} file(s) in ${targetDir}`);
|
|
796
|
-
};
|
|
797
472
|
const generate = defineCommand({
|
|
798
473
|
meta: { description: "Generate architecture artifacts" },
|
|
799
474
|
args: {
|
|
@@ -803,30 +478,44 @@ const generate = defineCommand({
|
|
|
803
478
|
},
|
|
804
479
|
output: {
|
|
805
480
|
type: "string",
|
|
806
|
-
description: "Output path (file for
|
|
481
|
+
description: "Output path (file for single output, directory for multi)"
|
|
807
482
|
},
|
|
808
483
|
format: {
|
|
809
484
|
type: "string",
|
|
810
|
-
description: "
|
|
485
|
+
description: "Target format name (plantuml, kubernetes, ...)"
|
|
811
486
|
}
|
|
812
487
|
},
|
|
813
488
|
async run({ args }) {
|
|
814
489
|
const config = await loadAndValidateConfig(args.config);
|
|
815
|
-
const model = await loadModel(config);
|
|
816
|
-
const
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
490
|
+
const { model } = await loadModel(config);
|
|
491
|
+
const formatName = args.format ?? "plantuml";
|
|
492
|
+
const format = await loadFormat(formatName);
|
|
493
|
+
if (!canGenerate(format)) {
|
|
494
|
+
throw new Error(`Format "${format.name}" doesn't support generate`);
|
|
495
|
+
}
|
|
496
|
+
const output = format.generate(model);
|
|
497
|
+
if (output.files.length === 0) {
|
|
498
|
+
consola.warn("Generator produced no files");
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (output.files.length === 1) {
|
|
502
|
+
const file = output.files[0];
|
|
503
|
+
if (args.output) {
|
|
504
|
+
await fs.writeFile(args.output, file.content);
|
|
505
|
+
consola.success(`Written to ${args.output}`);
|
|
506
|
+
} else {
|
|
507
|
+
console.log(file.content);
|
|
828
508
|
}
|
|
509
|
+
return;
|
|
829
510
|
}
|
|
511
|
+
const targetDir = args.output ?? config.generate?.kubernetes?.path ?? "fixtures/kubernetes/microservices";
|
|
512
|
+
await fs.mkdir(targetDir, { recursive: true });
|
|
513
|
+
await Promise.all(
|
|
514
|
+
output.files.map(
|
|
515
|
+
(f) => fs.writeFile(path.join(targetDir, f.path), f.content)
|
|
516
|
+
)
|
|
517
|
+
);
|
|
518
|
+
consola.success(`Generated ${output.files.length} file(s) in ${targetDir}`);
|
|
830
519
|
}
|
|
831
520
|
});
|
|
832
521
|
|
|
@@ -851,9 +540,35 @@ const config: AactConfig = {
|
|
|
851
540
|
commonReuse: true, // Reuse all of a context's public API or none
|
|
852
541
|
},
|
|
853
542
|
|
|
543
|
+
// -----------------------------------------------------------------------
|
|
544
|
+
// Project-specific (custom) rules
|
|
545
|
+
//
|
|
546
|
+
// After \`npm install aact\` locally, switch from this type-only import to
|
|
547
|
+
// \`defineConfig\` to register your own checks alongside the built-ins:
|
|
548
|
+
//
|
|
549
|
+
// import { defineConfig } from "aact";
|
|
550
|
+
// import { bcIsolationRule } from "./rules/bcIsolation";
|
|
551
|
+
//
|
|
552
|
+
// export default defineConfig({
|
|
553
|
+
// source: { type: "plantuml", path: "./architecture.puml" },
|
|
554
|
+
//
|
|
555
|
+
// customRules: [bcIsolationRule],
|
|
556
|
+
//
|
|
557
|
+
// rules: {
|
|
558
|
+
// acl: true,
|
|
559
|
+
// // Configure custom rules with the same syntax as built-ins.
|
|
560
|
+
// // TypeScript autocompletes options based on the rule definition.
|
|
561
|
+
// bcIsolation: { apiSuffix: "_api" },
|
|
562
|
+
// },
|
|
563
|
+
// });
|
|
564
|
+
//
|
|
565
|
+
// Worked example with two rules and tests:
|
|
566
|
+
// https://github.com/Byndyusoft/aact/tree/main/examples/custom-rules
|
|
567
|
+
// -----------------------------------------------------------------------
|
|
568
|
+
|
|
854
569
|
// PlantUML generation from Kubernetes configs (aact generate)
|
|
855
570
|
// generate: {
|
|
856
|
-
// kubernetes: { path: "./
|
|
571
|
+
// kubernetes: { path: "./fixtures/kubernetes" },
|
|
857
572
|
// boundaryLabel: "Our system",
|
|
858
573
|
// },
|
|
859
574
|
};
|
|
@@ -912,12 +627,93 @@ const init = defineCommand({
|
|
|
912
627
|
}
|
|
913
628
|
});
|
|
914
629
|
|
|
630
|
+
const buildEffectiveSet = async () => {
|
|
631
|
+
const out = [];
|
|
632
|
+
let config;
|
|
633
|
+
try {
|
|
634
|
+
config = await loadAndValidateConfig();
|
|
635
|
+
} catch {
|
|
636
|
+
return ruleRegistry.map((rule2) => ({
|
|
637
|
+
rule: rule2,
|
|
638
|
+
source: "built-in",
|
|
639
|
+
enabled: true
|
|
640
|
+
}));
|
|
641
|
+
}
|
|
642
|
+
const rules = config.rules;
|
|
643
|
+
const isEnabled = (name) => rules?.[name] !== false;
|
|
644
|
+
for (const rule2 of ruleRegistry) {
|
|
645
|
+
out.push({ rule: rule2, source: "built-in", enabled: isEnabled(rule2.name) });
|
|
646
|
+
}
|
|
647
|
+
for (const rule2 of config.customRules ?? []) {
|
|
648
|
+
out.push({ rule: rule2, source: "custom", enabled: isEnabled(rule2.name) });
|
|
649
|
+
}
|
|
650
|
+
return out;
|
|
651
|
+
};
|
|
652
|
+
const listAction = defineCommand({
|
|
653
|
+
meta: { description: "List all effective rules (built-in + custom)" },
|
|
654
|
+
args: {
|
|
655
|
+
json: {
|
|
656
|
+
type: "boolean",
|
|
657
|
+
description: "Output in JSON format"
|
|
658
|
+
}
|
|
659
|
+
},
|
|
660
|
+
async run({ args }) {
|
|
661
|
+
const effective = await buildEffectiveSet();
|
|
662
|
+
if (args.json) {
|
|
663
|
+
console.log(
|
|
664
|
+
JSON.stringify(
|
|
665
|
+
effective.map((e) => ({
|
|
666
|
+
name: e.rule.name,
|
|
667
|
+
description: e.rule.description,
|
|
668
|
+
source: e.source,
|
|
669
|
+
enabled: e.enabled,
|
|
670
|
+
hasFix: typeof e.rule.fix === "function"
|
|
671
|
+
})),
|
|
672
|
+
void 0,
|
|
673
|
+
2
|
|
674
|
+
)
|
|
675
|
+
);
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
const groups = {
|
|
679
|
+
"built-in": [],
|
|
680
|
+
custom: []
|
|
681
|
+
};
|
|
682
|
+
for (const e of effective) groups[e.source].push(e);
|
|
683
|
+
const renderGroup = (label, items) => {
|
|
684
|
+
if (items.length === 0) return;
|
|
685
|
+
console.log(colors.bold(label));
|
|
686
|
+
const maxName = Math.max(...items.map((i) => i.rule.name.length));
|
|
687
|
+
for (const { rule: rule2, enabled: enabled2 } of items) {
|
|
688
|
+
const status = enabled2 ? colors.green("\u25CF") : colors.dim("\u25CB");
|
|
689
|
+
const fix = rule2.fix ? colors.dim(" [fix]") : "";
|
|
690
|
+
const name = enabled2 ? colors.bold(rule2.name.padEnd(maxName)) : colors.dim(rule2.name.padEnd(maxName));
|
|
691
|
+
console.log(
|
|
692
|
+
` ${status} ${name} ${colors.dim(rule2.description)}${fix}`
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
console.log();
|
|
696
|
+
};
|
|
697
|
+
renderGroup("Built-in", groups["built-in"]);
|
|
698
|
+
renderGroup("Custom", groups.custom);
|
|
699
|
+
const enabled = effective.filter((e) => e.enabled).length;
|
|
700
|
+
const total = effective.length;
|
|
701
|
+
console.log(
|
|
702
|
+
colors.dim(`${enabled}/${total} rules enabled \xB7 \u25CF enabled \xB7 \u25CB disabled`)
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
});
|
|
706
|
+
const rule = defineCommand({
|
|
707
|
+
meta: { description: "Inspect and manage architecture rules" },
|
|
708
|
+
subCommands: { list: listAction }
|
|
709
|
+
});
|
|
710
|
+
|
|
915
711
|
const main = defineCommand({
|
|
916
712
|
meta: {
|
|
917
713
|
name: "aact",
|
|
918
714
|
version,
|
|
919
715
|
description: "Architecture analysis and compliance tool"
|
|
920
716
|
},
|
|
921
|
-
subCommands: { init, check, analyze, generate }
|
|
717
|
+
subCommands: { init, check, analyze, generate, rule }
|
|
922
718
|
});
|
|
923
719
|
void runMain(main);
|