aact 2.0.1 → 2.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/README.md +1 -1
- package/dist/cli/index.mjs +489 -138
- package/dist/index.d.mts +60 -4
- package/dist/index.d.ts +60 -4
- package/dist/index.mjs +22 -21
- package/dist/shared/{aact.Dqryafrg.mjs → aact.mCX-x14V.mjs} +214 -82
- package/package.json +2 -1
package/dist/cli/index.mjs
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
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, H as loadStructurizrElements, G as loadPlantumlElements, J as mapContainersFromPlantumlElements, q as analyzeArchitecture, E as EXTERNAL_SYSTEM_TYPE, b as CONTAINER_DB_TYPE, r as checkAcl, s as checkAcyclic, t as checkApiGateway, w as checkCrud, x as checkDbPerService, u as checkCohesion, y as checkStableDependencies, v as checkCommonReuse, L as structurizrDslSyntax, D as generateKubernetes, F as generatePlantumlFromModel } from '../shared/aact.mCX-x14V.mjs';
|
|
5
5
|
import { loadConfig } from 'c12';
|
|
6
6
|
import * as v from 'valibot';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import fs, { readFile, writeFile } from 'node:fs/promises';
|
|
9
|
+
import pc from 'picocolors';
|
|
9
10
|
import 'yaml';
|
|
10
11
|
import 'plantuml-parser';
|
|
11
12
|
|
|
12
|
-
const loadAndValidateConfig = async () => {
|
|
13
|
-
const { config } = await loadConfig({
|
|
13
|
+
const loadAndValidateConfig = async (configPath) => {
|
|
14
|
+
const { config } = await loadConfig({
|
|
15
|
+
name: "aact",
|
|
16
|
+
...configPath ? { configFile: configPath } : {}
|
|
17
|
+
});
|
|
14
18
|
if (!config) {
|
|
15
19
|
throw new Error("No source configured. Create an aact.config.ts file.");
|
|
16
20
|
}
|
|
@@ -37,13 +41,17 @@ const loadModel = async (config) => {
|
|
|
37
41
|
const analyze = defineCommand({
|
|
38
42
|
meta: { description: "Analyze architecture metrics" },
|
|
39
43
|
args: {
|
|
44
|
+
config: {
|
|
45
|
+
type: "string",
|
|
46
|
+
description: "Path to aact config file"
|
|
47
|
+
},
|
|
40
48
|
format: {
|
|
41
49
|
type: "string",
|
|
42
50
|
description: "Output format: text, json"
|
|
43
51
|
}
|
|
44
52
|
},
|
|
45
53
|
async run({ args }) {
|
|
46
|
-
const config = await loadAndValidateConfig();
|
|
54
|
+
const config = await loadAndValidateConfig(args.config);
|
|
47
55
|
const model = await loadModel(config);
|
|
48
56
|
const { report } = analyzeArchitecture(model);
|
|
49
57
|
if (args.format === "json") {
|
|
@@ -70,7 +78,7 @@ const analyze = defineCommand({
|
|
|
70
78
|
});
|
|
71
79
|
|
|
72
80
|
const plantumlSyntax = {
|
|
73
|
-
containerPattern: (name) => `
|
|
81
|
+
containerPattern: (name) => `(${name},`,
|
|
74
82
|
containerDecl: (name, label, tags) => {
|
|
75
83
|
const tagsPart = tags ? `, "", "", $tags="${tags}"` : "";
|
|
76
84
|
return `Container(${name}, "${label}"${tagsPart})`;
|
|
@@ -82,26 +90,35 @@ const plantumlSyntax = {
|
|
|
82
90
|
}
|
|
83
91
|
};
|
|
84
92
|
|
|
93
|
+
const applyIndent = (content, indent) => content.split("\n").map((line) => line.trim() ? indent + line : line).join("\n");
|
|
85
94
|
const applyEdits = (source, edits) => {
|
|
86
|
-
|
|
95
|
+
const lines = source.split("\n");
|
|
87
96
|
for (const edit of edits) {
|
|
88
97
|
const idx = lines.findIndex((line) => line.includes(edit.search));
|
|
89
|
-
if (idx === -1)
|
|
98
|
+
if (idx === -1) {
|
|
99
|
+
consola.warn(`fix: pattern not found in source \u2014 "${edit.search}"`);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const matchCount = lines.filter(
|
|
103
|
+
(line) => line.includes(edit.search)
|
|
104
|
+
).length;
|
|
105
|
+
if (matchCount > 1) {
|
|
106
|
+
consola.warn(
|
|
107
|
+
`fix: ambiguous pattern "${edit.search}" matches ${matchCount} lines, using first`
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
const indent = /^(\s*)/.exec(lines[idx])?.[1] ?? "";
|
|
90
111
|
switch (edit.type) {
|
|
91
112
|
case "remove": {
|
|
92
113
|
lines.splice(idx, 1);
|
|
93
114
|
break;
|
|
94
115
|
}
|
|
95
116
|
case "replace": {
|
|
96
|
-
lines[idx] = edit.content ?? "";
|
|
117
|
+
lines[idx] = applyIndent(edit.content ?? "", indent);
|
|
97
118
|
break;
|
|
98
119
|
}
|
|
99
120
|
case "add": {
|
|
100
|
-
lines
|
|
101
|
-
...lines.slice(0, idx + 1),
|
|
102
|
-
edit.content ?? "",
|
|
103
|
-
...lines.slice(idx + 1)
|
|
104
|
-
];
|
|
121
|
+
lines.splice(idx + 1, 0, applyIndent(edit.content ?? "", indent));
|
|
105
122
|
break;
|
|
106
123
|
}
|
|
107
124
|
}
|
|
@@ -109,9 +126,34 @@ const applyEdits = (source, edits) => {
|
|
|
109
126
|
return lines.join("\n");
|
|
110
127
|
};
|
|
111
128
|
|
|
129
|
+
const detectNamingConvention = (model) => {
|
|
130
|
+
const names = model.allContainers.map((c) => c.name);
|
|
131
|
+
if (names.length === 0) return "snake";
|
|
132
|
+
const withUnderscore = names.filter((n) => n.includes("_")).length;
|
|
133
|
+
const withHyphen = names.filter((n) => n.includes("-")).length;
|
|
134
|
+
const withCamel = names.filter((n) => /[a-z][A-Z]/.test(n)).length;
|
|
135
|
+
if (withHyphen > withUnderscore && withHyphen > withCamel) return "kebab";
|
|
136
|
+
if (withCamel > withUnderscore) return "camel";
|
|
137
|
+
return "snake";
|
|
138
|
+
};
|
|
139
|
+
const joinName = (base, word, convention) => {
|
|
140
|
+
switch (convention) {
|
|
141
|
+
case "camel": {
|
|
142
|
+
return base + word.charAt(0).toUpperCase() + word.slice(1);
|
|
143
|
+
}
|
|
144
|
+
case "kebab": {
|
|
145
|
+
return `${base}-${word}`;
|
|
146
|
+
}
|
|
147
|
+
default: {
|
|
148
|
+
return `${base}_${word}`;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
112
153
|
const fixAcl = (model, violations, syntax, options) => {
|
|
113
154
|
const tag = options?.tag ?? "acl";
|
|
114
|
-
const
|
|
155
|
+
const convention = detectNamingConvention(model);
|
|
156
|
+
const externalType = options?.externalType ?? EXTERNAL_SYSTEM_TYPE;
|
|
115
157
|
const results = [];
|
|
116
158
|
for (const violation of violations) {
|
|
117
159
|
const container = model.allContainers.find(
|
|
@@ -122,39 +164,262 @@ const fixAcl = (model, violations, syntax, options) => {
|
|
|
122
164
|
(r) => r.to.type === externalType
|
|
123
165
|
);
|
|
124
166
|
if (externalRels.length === 0) continue;
|
|
125
|
-
const aclName =
|
|
167
|
+
const aclName = joinName(container.name, "acl", convention);
|
|
168
|
+
if (model.allContainers.some((c) => c.name === aclName)) {
|
|
169
|
+
consola.warn(
|
|
170
|
+
`fix acl: skipping ${container.name} \u2014 ${aclName} already exists`
|
|
171
|
+
);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
126
174
|
const fix = {
|
|
127
175
|
rule: "acl",
|
|
128
176
|
description: `Add ACL layer for ${container.name}`,
|
|
129
177
|
edits: []
|
|
130
178
|
};
|
|
131
|
-
fix.edits.push(
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
179
|
+
fix.edits.push(
|
|
180
|
+
// 1. Add ACL container after the violating container
|
|
181
|
+
{
|
|
182
|
+
type: "add",
|
|
183
|
+
search: syntax.containerPattern(container.name),
|
|
184
|
+
content: syntax.containerDecl(aclName, `${container.label} ACL`, tag)
|
|
185
|
+
},
|
|
186
|
+
// 2. Add single Rel(svc, acl) after the ACL container declaration
|
|
187
|
+
{
|
|
188
|
+
type: "add",
|
|
189
|
+
search: syntax.containerPattern(aclName),
|
|
190
|
+
content: syntax.relationDecl(container.name, aclName)
|
|
191
|
+
},
|
|
192
|
+
...externalRels.map((rel) => ({
|
|
193
|
+
type: "replace",
|
|
194
|
+
search: syntax.relationPattern(container.name, rel.to.name),
|
|
195
|
+
content: syntax.relationDecl(aclName, rel.to.name, rel.technology)
|
|
196
|
+
}))
|
|
197
|
+
);
|
|
198
|
+
results.push(fix);
|
|
199
|
+
}
|
|
200
|
+
return results;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const buildContainerBoundaryMap = (model) => {
|
|
204
|
+
const map = /* @__PURE__ */ new Map();
|
|
205
|
+
for (const boundary of model.boundaries) {
|
|
206
|
+
for (const container of boundary.containers) {
|
|
207
|
+
map.set(container.name, boundary);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return map;
|
|
211
|
+
};
|
|
212
|
+
const findPublicApiCandidate = (targetBoundary, dbType, ownerTags, model, containerBoundaryMap) => {
|
|
213
|
+
const candidates = targetBoundary.containers.filter(
|
|
214
|
+
(c) => c.type !== dbType && !ownerTags.some((t) => c.tags?.includes(t))
|
|
215
|
+
);
|
|
216
|
+
if (candidates.length === 0) return void 0;
|
|
217
|
+
if (candidates.length === 1) return candidates[0];
|
|
218
|
+
const candidateNames = new Set(candidates.map((c) => c.name));
|
|
219
|
+
const inDegree = new Map(candidates.map((c) => [c.name, 0]));
|
|
220
|
+
for (const container of model.allContainers) {
|
|
221
|
+
if (containerBoundaryMap.get(container.name) === targetBoundary) continue;
|
|
222
|
+
for (const rel of container.relations) {
|
|
223
|
+
if (candidateNames.has(rel.to.name)) {
|
|
224
|
+
inDegree.set(rel.to.name, (inDegree.get(rel.to.name) ?? 0) + 1);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return candidates.toSorted(
|
|
229
|
+
(a, b) => (inDegree.get(b.name) ?? 0) - (inDegree.get(a.name) ?? 0)
|
|
230
|
+
)[0];
|
|
231
|
+
};
|
|
232
|
+
const resolveRedirectTarget = (accessor, db, owner, dbType, ownerTags, model, containerBoundaryMap, ruleName) => {
|
|
233
|
+
const accessorBoundary = containerBoundaryMap.get(accessor.name);
|
|
234
|
+
const dbBoundary = containerBoundaryMap.get(db.name);
|
|
235
|
+
const isCrossBoundary = accessorBoundary !== void 0 && dbBoundary !== void 0 && accessorBoundary !== dbBoundary;
|
|
236
|
+
if (!isCrossBoundary) return owner;
|
|
237
|
+
const publicApi = findPublicApiCandidate(
|
|
238
|
+
dbBoundary,
|
|
239
|
+
dbType,
|
|
240
|
+
ownerTags,
|
|
241
|
+
model,
|
|
242
|
+
containerBoundaryMap
|
|
243
|
+
);
|
|
244
|
+
if (!publicApi) {
|
|
245
|
+
consola.warn(
|
|
246
|
+
`fix ${ruleName}: boundary "${dbBoundary.name}" has no public API \u2014 cannot auto-redirect "${accessor.name}" away from "${db.name}", fix manually`
|
|
247
|
+
);
|
|
248
|
+
return void 0;
|
|
249
|
+
}
|
|
250
|
+
if (publicApi === owner) {
|
|
251
|
+
consola.warn(
|
|
252
|
+
`fix ${ruleName}: the only public API candidate in "${dbBoundary.name}" is the repo owner \u2014 cross-boundary access from "${accessor.name}" requires manual review`
|
|
253
|
+
);
|
|
254
|
+
return void 0;
|
|
255
|
+
}
|
|
256
|
+
return publicApi;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const stripDbWord = (name) => {
|
|
260
|
+
const lower = name.toLowerCase();
|
|
261
|
+
for (const suffix of [
|
|
262
|
+
"_database",
|
|
263
|
+
"-database",
|
|
264
|
+
"database",
|
|
265
|
+
"_db",
|
|
266
|
+
"-db",
|
|
267
|
+
"db"
|
|
268
|
+
]) {
|
|
269
|
+
if (lower.endsWith(suffix)) {
|
|
270
|
+
return name.slice(0, -suffix.length);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return name;
|
|
274
|
+
};
|
|
275
|
+
const deriveRepoName = (dbName, convention) => {
|
|
276
|
+
const base = stripDbWord(dbName);
|
|
277
|
+
return joinName(base || dbName, "repo", convention);
|
|
278
|
+
};
|
|
279
|
+
const deriveRepoLabel = (dbName) => {
|
|
280
|
+
const base = stripDbWord(dbName);
|
|
281
|
+
const word = base || dbName;
|
|
282
|
+
return word.charAt(0).toUpperCase() + word.slice(1).replaceAll("_", " ") + " Repo";
|
|
283
|
+
};
|
|
284
|
+
const fixNonRepoAccessesDb = (accessor, model, syntax, dbType, ownerTags, convention) => {
|
|
285
|
+
const dbRels = accessor.relations.filter((r) => r.to.type === dbType);
|
|
286
|
+
if (dbRels.length === 0) return void 0;
|
|
287
|
+
const containerBoundaryMap = buildContainerBoundaryMap(model);
|
|
288
|
+
const edits = dbRels.flatMap((rel) => {
|
|
289
|
+
const db = rel.to;
|
|
290
|
+
const existingRepo = model.allContainers.find(
|
|
291
|
+
(c) => c !== accessor && c.relations.some((r) => r.to.name === db.name) && ownerTags.some((t) => c.tags?.includes(t))
|
|
292
|
+
);
|
|
293
|
+
if (existingRepo) {
|
|
294
|
+
const redirectTarget = resolveRedirectTarget(
|
|
295
|
+
accessor,
|
|
296
|
+
db,
|
|
297
|
+
existingRepo,
|
|
298
|
+
dbType,
|
|
299
|
+
ownerTags,
|
|
300
|
+
model,
|
|
301
|
+
containerBoundaryMap,
|
|
302
|
+
"crud"
|
|
303
|
+
);
|
|
304
|
+
if (!redirectTarget) return [];
|
|
305
|
+
return [
|
|
139
306
|
{
|
|
140
307
|
type: "replace",
|
|
141
|
-
search: syntax.relationPattern(
|
|
142
|
-
content: syntax.relationDecl(
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
content: syntax.relationDecl(aclName, rel.to.name, tech)
|
|
308
|
+
search: syntax.relationPattern(accessor.name, db.name),
|
|
309
|
+
content: syntax.relationDecl(
|
|
310
|
+
accessor.name,
|
|
311
|
+
redirectTarget.name,
|
|
312
|
+
rel.technology
|
|
313
|
+
)
|
|
148
314
|
}
|
|
315
|
+
];
|
|
316
|
+
}
|
|
317
|
+
const accessorBoundary = containerBoundaryMap.get(accessor.name);
|
|
318
|
+
const dbBoundary = containerBoundaryMap.get(db.name);
|
|
319
|
+
if (accessorBoundary !== void 0 && dbBoundary !== void 0 && accessorBoundary !== dbBoundary) {
|
|
320
|
+
consola.warn(
|
|
321
|
+
`fix crud: "${accessor.name}" accesses "${db.name}" cross-boundary with no existing repo \u2014 fix manually`
|
|
149
322
|
);
|
|
323
|
+
return [];
|
|
324
|
+
}
|
|
325
|
+
const repoName = deriveRepoName(db.name, convention);
|
|
326
|
+
if (model.allContainers.some((c) => c.name === repoName)) {
|
|
327
|
+
consola.warn(
|
|
328
|
+
`fix crud: cannot create repo for "${db.name}" \u2014 "${repoName}" already exists`
|
|
329
|
+
);
|
|
330
|
+
return [];
|
|
331
|
+
}
|
|
332
|
+
return [
|
|
333
|
+
{
|
|
334
|
+
type: "add",
|
|
335
|
+
search: syntax.containerPattern(db.name),
|
|
336
|
+
content: syntax.containerDecl(
|
|
337
|
+
repoName,
|
|
338
|
+
deriveRepoLabel(db.name),
|
|
339
|
+
ownerTags[0] ?? "repo"
|
|
340
|
+
)
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
type: "add",
|
|
344
|
+
search: syntax.containerPattern(repoName),
|
|
345
|
+
content: syntax.relationDecl(repoName, db.name, rel.technology)
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
type: "replace",
|
|
349
|
+
search: syntax.relationPattern(accessor.name, db.name),
|
|
350
|
+
content: syntax.relationDecl(accessor.name, repoName, rel.technology)
|
|
351
|
+
}
|
|
352
|
+
];
|
|
353
|
+
});
|
|
354
|
+
if (edits.length === 0) return void 0;
|
|
355
|
+
return {
|
|
356
|
+
rule: "crud",
|
|
357
|
+
description: `Add repo intermediary for ${accessor.name} \u2192 ${dbRels.map((r) => r.to.name).join(", ")}`,
|
|
358
|
+
edits
|
|
359
|
+
};
|
|
360
|
+
};
|
|
361
|
+
const fixRepoWithNonDbDeps = (repo, syntax, dbType) => {
|
|
362
|
+
const nonDbRels = repo.relations.filter((r) => r.to.type !== dbType);
|
|
363
|
+
if (nonDbRels.length === 0) return void 0;
|
|
364
|
+
return {
|
|
365
|
+
rule: "crud",
|
|
366
|
+
description: `Remove non-database dependencies from repo ${repo.name}`,
|
|
367
|
+
edits: nonDbRels.map((rel) => ({
|
|
368
|
+
type: "remove",
|
|
369
|
+
search: syntax.relationPattern(repo.name, rel.to.name)
|
|
370
|
+
}))
|
|
371
|
+
};
|
|
372
|
+
};
|
|
373
|
+
const fixCrud = (model, violations, syntax, options) => {
|
|
374
|
+
const dbType = options?.dbType ?? CONTAINER_DB_TYPE;
|
|
375
|
+
const ownerTags = options?.repoTags ?? ["repo", "relay"];
|
|
376
|
+
const convention = detectNamingConvention(model);
|
|
377
|
+
const results = [];
|
|
378
|
+
for (const violation of violations) {
|
|
379
|
+
const container = model.allContainers.find(
|
|
380
|
+
(c) => c.name === violation.container
|
|
381
|
+
);
|
|
382
|
+
if (!container) continue;
|
|
383
|
+
const isRepo = ownerTags.some((t) => container.tags?.includes(t));
|
|
384
|
+
if (isRepo) {
|
|
385
|
+
const fix = fixRepoWithNonDbDeps(container, syntax, dbType);
|
|
386
|
+
if (fix) results.push(fix);
|
|
387
|
+
} else {
|
|
388
|
+
const fix = fixNonRepoAccessesDb(
|
|
389
|
+
container,
|
|
390
|
+
model,
|
|
391
|
+
syntax,
|
|
392
|
+
dbType,
|
|
393
|
+
ownerTags,
|
|
394
|
+
convention
|
|
395
|
+
);
|
|
396
|
+
if (fix) results.push(fix);
|
|
150
397
|
}
|
|
151
|
-
results.push(fix);
|
|
152
398
|
}
|
|
153
399
|
return results;
|
|
154
400
|
};
|
|
155
401
|
|
|
402
|
+
const resolveOwner = (dbName, accessors, ownerTags) => {
|
|
403
|
+
const tagged = accessors.filter(
|
|
404
|
+
(c) => c.tags?.some((t) => ownerTags.includes(t))
|
|
405
|
+
);
|
|
406
|
+
if (tagged.length === 0) {
|
|
407
|
+
consola.warn(
|
|
408
|
+
`Cannot determine owner of ${dbName}: no ${ownerTags.join("/")} tagged accessor found, using ${accessors[0].name}`
|
|
409
|
+
);
|
|
410
|
+
return accessors[0];
|
|
411
|
+
}
|
|
412
|
+
if (tagged.length > 1) {
|
|
413
|
+
consola.warn(
|
|
414
|
+
`Cannot determine owner of ${dbName}: multiple tagged accessors (${tagged.map((c) => c.name).join(", ")}), using ${tagged[0].name}`
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
return tagged[0];
|
|
418
|
+
};
|
|
156
419
|
const fixDbPerService = (model, violations, syntax, options) => {
|
|
157
|
-
const dbType = options?.dbType ??
|
|
420
|
+
const dbType = options?.dbType ?? CONTAINER_DB_TYPE;
|
|
421
|
+
const ownerTags = options?.ownerTags ?? ["repo", "relay"];
|
|
422
|
+
const containerBoundaryMap = buildContainerBoundaryMap(model);
|
|
158
423
|
const results = [];
|
|
159
424
|
for (const violation of violations) {
|
|
160
425
|
const db = model.allContainers.find(
|
|
@@ -165,26 +430,46 @@ const fixDbPerService = (model, violations, syntax, options) => {
|
|
|
165
430
|
(c) => c.relations.some((r) => r.to.name === db.name)
|
|
166
431
|
);
|
|
167
432
|
if (accessors.length <= 1) continue;
|
|
168
|
-
const owner = accessors
|
|
169
|
-
const
|
|
170
|
-
rule: "dbPerService",
|
|
171
|
-
description: `Redirect access to ${db.name} through ${owner.name}`,
|
|
172
|
-
edits: []
|
|
173
|
-
};
|
|
174
|
-
for (const accessor of accessors.slice(1)) {
|
|
433
|
+
const owner = resolveOwner(db.name, accessors, ownerTags);
|
|
434
|
+
const edits = accessors.filter((c) => c !== owner).flatMap((accessor) => {
|
|
175
435
|
const rel = accessor.relations.find((r) => r.to.name === db.name);
|
|
176
|
-
if (!rel)
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
436
|
+
if (!rel) {
|
|
437
|
+
consola.warn(
|
|
438
|
+
`fix dbPerService: relation from ${accessor.name} to ${db.name} not found, skipping`
|
|
439
|
+
);
|
|
440
|
+
return [];
|
|
441
|
+
}
|
|
442
|
+
const redirectTarget = resolveRedirectTarget(
|
|
443
|
+
accessor,
|
|
444
|
+
db,
|
|
445
|
+
owner,
|
|
446
|
+
dbType,
|
|
447
|
+
ownerTags,
|
|
448
|
+
model,
|
|
449
|
+
containerBoundaryMap,
|
|
450
|
+
"dbPerService"
|
|
184
451
|
);
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
452
|
+
if (!redirectTarget) return [];
|
|
453
|
+
const tags = rel.tags && rel.tags.length > 0 ? rel.tags.join("+") : void 0;
|
|
454
|
+
return [
|
|
455
|
+
{
|
|
456
|
+
type: "replace",
|
|
457
|
+
search: syntax.relationPattern(accessor.name, db.name),
|
|
458
|
+
content: syntax.relationDecl(
|
|
459
|
+
accessor.name,
|
|
460
|
+
redirectTarget.name,
|
|
461
|
+
rel.technology ?? "",
|
|
462
|
+
tags
|
|
463
|
+
)
|
|
464
|
+
}
|
|
465
|
+
];
|
|
466
|
+
});
|
|
467
|
+
if (edits.length === 0) continue;
|
|
468
|
+
results.push({
|
|
469
|
+
rule: "dbPerService",
|
|
470
|
+
description: `Redirect access to ${db.name} through ${owner.name}`,
|
|
471
|
+
edits
|
|
472
|
+
});
|
|
188
473
|
}
|
|
189
474
|
return results;
|
|
190
475
|
};
|
|
@@ -203,7 +488,8 @@ const ruleRegistry = [
|
|
|
203
488
|
}),
|
|
204
489
|
defineRule({
|
|
205
490
|
name: "crud",
|
|
206
|
-
check: (m, o) => checkCrud(m.allContainers, o)
|
|
491
|
+
check: (m, o) => checkCrud(m.allContainers, o),
|
|
492
|
+
fix: fixCrud
|
|
207
493
|
}),
|
|
208
494
|
defineRule({
|
|
209
495
|
name: "dbPerService",
|
|
@@ -217,9 +503,15 @@ const ruleRegistry = [
|
|
|
217
503
|
defineRule({
|
|
218
504
|
name: "stableDependencies",
|
|
219
505
|
check: (m, o) => checkStableDependencies(m.allContainers, o)
|
|
506
|
+
}),
|
|
507
|
+
defineRule({
|
|
508
|
+
name: "commonReuse",
|
|
509
|
+
check: (m) => checkCommonReuse(m)
|
|
220
510
|
})
|
|
221
511
|
];
|
|
222
512
|
|
|
513
|
+
const ruleMap = new Map(ruleRegistry.map((r) => [r.name, r]));
|
|
514
|
+
const exitWithViolations = () => process.exit(1);
|
|
223
515
|
const runRules = (model, rules) => {
|
|
224
516
|
const results = [];
|
|
225
517
|
for (const rule of ruleRegistry) {
|
|
@@ -230,17 +522,26 @@ const runRules = (model, rules) => {
|
|
|
230
522
|
}
|
|
231
523
|
return results;
|
|
232
524
|
};
|
|
233
|
-
const getSyntax = (
|
|
234
|
-
if (
|
|
525
|
+
const getSyntax = (config) => {
|
|
526
|
+
if (config.source.type === "plantuml") {
|
|
235
527
|
return plantumlSyntax;
|
|
236
528
|
}
|
|
237
|
-
|
|
529
|
+
if (config.source.type === "structurizr") {
|
|
530
|
+
if (!config.source.writePath) {
|
|
531
|
+
consola.warn(
|
|
532
|
+
"To use --fix with structurizr, add source.writePath pointing to your workspace.dsl"
|
|
533
|
+
);
|
|
534
|
+
return null;
|
|
535
|
+
}
|
|
536
|
+
return structurizrDslSyntax;
|
|
537
|
+
}
|
|
538
|
+
return null;
|
|
238
539
|
};
|
|
239
540
|
const generateFixes = (model, results, rules, syntax) => {
|
|
240
541
|
const fixes = [];
|
|
241
542
|
for (const result of results) {
|
|
242
543
|
if (result.violations.length === 0) continue;
|
|
243
|
-
const ruleDef =
|
|
544
|
+
const ruleDef = ruleMap.get(result.name);
|
|
244
545
|
if (!ruleDef?.fix) continue;
|
|
245
546
|
const configValue = rules?.[ruleDef.name];
|
|
246
547
|
const options = typeof configValue === "object" ? configValue : void 0;
|
|
@@ -249,17 +550,37 @@ const generateFixes = (model, results, rules, syntax) => {
|
|
|
249
550
|
return fixes;
|
|
250
551
|
};
|
|
251
552
|
const formatText = (results) => {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
553
|
+
const failed = results.filter((r) => r.violations.length > 0);
|
|
554
|
+
const passed = results.filter((r) => r.violations.length === 0);
|
|
555
|
+
for (const result of failed) {
|
|
556
|
+
const count = result.violations.length;
|
|
557
|
+
const label = count === 1 ? "violation" : "violations";
|
|
558
|
+
const countLabel = pc.red(`${count} ${label}`);
|
|
559
|
+
console.log(`${pc.bold(pc.red(result.name))} ${countLabel}`);
|
|
560
|
+
const maxLen = Math.max(
|
|
561
|
+
...result.violations.map((v) => v.container.length)
|
|
562
|
+
);
|
|
563
|
+
for (const v of result.violations) {
|
|
564
|
+
console.log(` ${pc.bold(v.container.padEnd(maxLen))} ${v.message}`);
|
|
262
565
|
}
|
|
566
|
+
console.log();
|
|
567
|
+
}
|
|
568
|
+
if (passed.length > 0) {
|
|
569
|
+
console.log(
|
|
570
|
+
`${pc.dim("Passed")} ${passed.map((r) => pc.green(r.name)).join(pc.dim(" \xB7 "))}`
|
|
571
|
+
);
|
|
572
|
+
console.log();
|
|
573
|
+
}
|
|
574
|
+
const total = failed.reduce((n, r) => n + r.violations.length, 0);
|
|
575
|
+
if (total === 0) {
|
|
576
|
+
console.log(pc.green("No violations found."));
|
|
577
|
+
} else {
|
|
578
|
+
const rulesLabel = failed.length === 1 ? "rule" : "rules";
|
|
579
|
+
console.log(
|
|
580
|
+
pc.red(
|
|
581
|
+
`Found ${total} ${total === 1 ? "violation" : "violations"} in ${failed.length} ${rulesLabel}`
|
|
582
|
+
) + pc.dim(" \u2014 run with --fix to apply suggested fixes")
|
|
583
|
+
);
|
|
263
584
|
}
|
|
264
585
|
};
|
|
265
586
|
const formatJson = (results) => {
|
|
@@ -279,27 +600,34 @@ const formatGithub = (results) => {
|
|
|
279
600
|
}
|
|
280
601
|
}
|
|
281
602
|
};
|
|
603
|
+
const prefixContent = (content, first, rest) => content.split("\n").map((line, i) => i === 0 ? first + line : rest + line).join("\n");
|
|
282
604
|
const formatFixes = (fixes) => {
|
|
283
605
|
for (const fix of fixes) {
|
|
284
|
-
|
|
606
|
+
const ruleTag = pc.bold(`[${fix.rule}]`);
|
|
607
|
+
console.log(` ${ruleTag} ${fix.description}`);
|
|
285
608
|
for (const edit of fix.edits) {
|
|
286
609
|
switch (edit.type) {
|
|
287
610
|
case "remove": {
|
|
288
|
-
|
|
611
|
+
console.log(pc.red(prefixContent(edit.search, " - ", " ")));
|
|
289
612
|
break;
|
|
290
613
|
}
|
|
291
614
|
case "replace": {
|
|
292
|
-
|
|
293
|
-
|
|
615
|
+
console.log(pc.red(prefixContent(edit.search, " - ", " ")));
|
|
616
|
+
console.log(
|
|
617
|
+
pc.green(prefixContent(edit.content ?? "", " + ", " "))
|
|
618
|
+
);
|
|
294
619
|
break;
|
|
295
620
|
}
|
|
296
621
|
case "add": {
|
|
297
|
-
|
|
298
|
-
|
|
622
|
+
console.log(pc.dim(` (after "${edit.search}")`));
|
|
623
|
+
console.log(
|
|
624
|
+
pc.green(prefixContent(edit.content ?? "", " + ", " "))
|
|
625
|
+
);
|
|
299
626
|
break;
|
|
300
627
|
}
|
|
301
628
|
}
|
|
302
629
|
}
|
|
630
|
+
console.log();
|
|
303
631
|
}
|
|
304
632
|
};
|
|
305
633
|
const detectFormat = (format) => {
|
|
@@ -307,9 +635,78 @@ const detectFormat = (format) => {
|
|
|
307
635
|
if (process.env.GITHUB_ACTIONS) return "github";
|
|
308
636
|
return "text";
|
|
309
637
|
};
|
|
638
|
+
const formatResults = (results, format) => {
|
|
639
|
+
switch (format) {
|
|
640
|
+
case "json": {
|
|
641
|
+
formatJson(results);
|
|
642
|
+
break;
|
|
643
|
+
}
|
|
644
|
+
case "github": {
|
|
645
|
+
formatGithub(results);
|
|
646
|
+
break;
|
|
647
|
+
}
|
|
648
|
+
default: {
|
|
649
|
+
formatText(results);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
};
|
|
653
|
+
const writeFixes = async (config, fixes) => {
|
|
654
|
+
const writePath = path.resolve(config.source.writePath ?? config.source.path);
|
|
655
|
+
let source = await readFile(writePath, "utf8");
|
|
656
|
+
for (const fix of fixes) {
|
|
657
|
+
source = applyEdits(source, fix.edits);
|
|
658
|
+
}
|
|
659
|
+
await writeFile(writePath, source, "utf8");
|
|
660
|
+
const isDslFix = config.source.writePath && config.source.writePath !== config.source.path;
|
|
661
|
+
if (isDslFix) {
|
|
662
|
+
consola.success(`Applied ${fixes.length} fix(es), wrote ${writePath}`);
|
|
663
|
+
consola.warn(
|
|
664
|
+
"DSL updated \u2014 regenerate workspace.json from workspace.dsl before re-checking"
|
|
665
|
+
);
|
|
666
|
+
} else {
|
|
667
|
+
const reModel = await loadModel(config);
|
|
668
|
+
const reResults = runRules(reModel, config.rules);
|
|
669
|
+
const remaining = reResults.reduce((n, r) => n + r.violations.length, 0);
|
|
670
|
+
consola.success(
|
|
671
|
+
`Applied ${fixes.length} fix(es), wrote ${writePath}` + (remaining > 0 ? ` (${remaining} violation(s) remain)` : "")
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
};
|
|
675
|
+
const handleFixMode = async (model, results, config, dryRun) => {
|
|
676
|
+
const hasViolations = results.some((r) => r.violations.length > 0);
|
|
677
|
+
if (!hasViolations) {
|
|
678
|
+
consola.success("No violations to fix");
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
const syntax = getSyntax(config);
|
|
682
|
+
if (!syntax) return exitWithViolations();
|
|
683
|
+
const fixes = generateFixes(model, results, config.rules, syntax);
|
|
684
|
+
if (fixes.length === 0) {
|
|
685
|
+
consola.info("No auto-fixes available for these violations");
|
|
686
|
+
exitWithViolations();
|
|
687
|
+
}
|
|
688
|
+
formatFixes(fixes);
|
|
689
|
+
if (!dryRun) {
|
|
690
|
+
await writeFixes(config, fixes);
|
|
691
|
+
}
|
|
692
|
+
};
|
|
693
|
+
const suggestFixes = (model, results, config) => {
|
|
694
|
+
const syntax = getSyntax(config);
|
|
695
|
+
if (!syntax) return;
|
|
696
|
+
const fixes = generateFixes(model, results, config.rules, syntax);
|
|
697
|
+
if (fixes.length > 0) {
|
|
698
|
+
console.log(pc.bold("Suggested fixes:"));
|
|
699
|
+
console.log();
|
|
700
|
+
formatFixes(fixes);
|
|
701
|
+
}
|
|
702
|
+
};
|
|
310
703
|
const check = defineCommand({
|
|
311
704
|
meta: { description: "Check architecture rules" },
|
|
312
705
|
args: {
|
|
706
|
+
config: {
|
|
707
|
+
type: "string",
|
|
708
|
+
description: "Path to aact config file"
|
|
709
|
+
},
|
|
313
710
|
format: {
|
|
314
711
|
type: "string",
|
|
315
712
|
description: "Output format: text, json, github"
|
|
@@ -323,75 +720,24 @@ const check = defineCommand({
|
|
|
323
720
|
description: "Show fixes without applying them"
|
|
324
721
|
}
|
|
325
722
|
},
|
|
326
|
-
// eslint-disable-next-line sonarjs/cognitive-complexity
|
|
327
723
|
async run({ args }) {
|
|
328
|
-
const config = await loadAndValidateConfig();
|
|
724
|
+
const config = await loadAndValidateConfig(args.config);
|
|
329
725
|
const model = await loadModel(config);
|
|
330
726
|
const results = runRules(model, config.rules);
|
|
331
|
-
|
|
332
|
-
switch (format) {
|
|
333
|
-
case "json": {
|
|
334
|
-
formatJson(results);
|
|
335
|
-
break;
|
|
336
|
-
}
|
|
337
|
-
case "github": {
|
|
338
|
-
formatGithub(results);
|
|
339
|
-
break;
|
|
340
|
-
}
|
|
341
|
-
default: {
|
|
342
|
-
formatText(results);
|
|
343
|
-
}
|
|
344
|
-
}
|
|
727
|
+
formatResults(results, detectFormat(args.format));
|
|
345
728
|
const hasViolations = results.some((r) => r.violations.length > 0);
|
|
346
729
|
if (args.fix || args["dry-run"]) {
|
|
347
|
-
|
|
348
|
-
consola.success("No violations to fix");
|
|
349
|
-
return;
|
|
350
|
-
}
|
|
351
|
-
const syntax = getSyntax(config.source.type);
|
|
352
|
-
const fixes = generateFixes(model, results, config.rules, syntax);
|
|
353
|
-
if (fixes.length === 0) {
|
|
354
|
-
consola.info("No auto-fixes available for these violations");
|
|
355
|
-
if (hasViolations) {
|
|
356
|
-
throw new Error("Architecture rule violations found");
|
|
357
|
-
}
|
|
358
|
-
return;
|
|
359
|
-
}
|
|
360
|
-
formatFixes(fixes);
|
|
361
|
-
if (args["dry-run"]) {
|
|
362
|
-
return;
|
|
363
|
-
}
|
|
364
|
-
const sourcePath = path.resolve(config.source.path);
|
|
365
|
-
let source = await readFile(sourcePath, "utf8");
|
|
366
|
-
for (const fix of fixes) {
|
|
367
|
-
source = applyEdits(source, fix.edits);
|
|
368
|
-
}
|
|
369
|
-
await writeFile(sourcePath, source, "utf8");
|
|
370
|
-
const reModel = await loadModel(config);
|
|
371
|
-
const reResults = runRules(reModel, config.rules);
|
|
372
|
-
const remaining = reResults.reduce((n, r) => n + r.violations.length, 0);
|
|
373
|
-
consola.success(
|
|
374
|
-
`Applied ${fixes.length} fix(es), wrote ${sourcePath}` + (remaining > 0 ? ` (${remaining} violation(s) remain)` : "")
|
|
375
|
-
);
|
|
730
|
+
await handleFixMode(model, results, config, args["dry-run"] ?? false);
|
|
376
731
|
return;
|
|
377
732
|
}
|
|
378
733
|
if (hasViolations) {
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
const fixes = generateFixes(model, results, config.rules, syntax);
|
|
382
|
-
if (fixes.length > 0) {
|
|
383
|
-
consola.info("Suggested fixes (run with --fix to apply):");
|
|
384
|
-
formatFixes(fixes);
|
|
385
|
-
}
|
|
386
|
-
} catch {
|
|
387
|
-
}
|
|
388
|
-
throw new Error("Architecture rule violations found");
|
|
734
|
+
suggestFixes(model, results, config);
|
|
735
|
+
exitWithViolations();
|
|
389
736
|
}
|
|
390
737
|
}
|
|
391
738
|
});
|
|
392
739
|
|
|
393
|
-
const runPlantuml = async (config, outputPath) => {
|
|
394
|
-
const model = await loadModel(config);
|
|
740
|
+
const runPlantuml = async (model, config, outputPath) => {
|
|
395
741
|
const puml = generatePlantumlFromModel(model, {
|
|
396
742
|
boundaryLabel: config.generate?.boundaryLabel
|
|
397
743
|
});
|
|
@@ -402,20 +748,24 @@ const runPlantuml = async (config, outputPath) => {
|
|
|
402
748
|
console.log(puml);
|
|
403
749
|
}
|
|
404
750
|
};
|
|
405
|
-
const runKubernetes = async (config, outputDir) => {
|
|
406
|
-
const model = await loadModel(config);
|
|
751
|
+
const runKubernetes = async (model, config, outputDir) => {
|
|
407
752
|
const outputs = generateKubernetes(model);
|
|
408
753
|
const targetDir = outputDir ?? config.generate?.kubernetes?.path ?? "resources/kubernetes/microservices";
|
|
409
754
|
await fs.mkdir(targetDir, { recursive: true });
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
755
|
+
await Promise.all(
|
|
756
|
+
outputs.map(
|
|
757
|
+
(output) => fs.writeFile(path.join(targetDir, output.fileName), output.content)
|
|
758
|
+
)
|
|
759
|
+
);
|
|
414
760
|
consola.success(`Generated ${outputs.length} file(s) in ${targetDir}`);
|
|
415
761
|
};
|
|
416
762
|
const generate = defineCommand({
|
|
417
763
|
meta: { description: "Generate architecture artifacts" },
|
|
418
764
|
args: {
|
|
765
|
+
config: {
|
|
766
|
+
type: "string",
|
|
767
|
+
description: "Path to aact config file"
|
|
768
|
+
},
|
|
419
769
|
output: {
|
|
420
770
|
type: "string",
|
|
421
771
|
description: "Output path (file for plantuml, directory for kubernetes)"
|
|
@@ -426,15 +776,16 @@ const generate = defineCommand({
|
|
|
426
776
|
}
|
|
427
777
|
},
|
|
428
778
|
async run({ args }) {
|
|
429
|
-
const config = await loadAndValidateConfig();
|
|
779
|
+
const config = await loadAndValidateConfig(args.config);
|
|
780
|
+
const model = await loadModel(config);
|
|
430
781
|
const format = args.format ?? "plantuml";
|
|
431
782
|
switch (format) {
|
|
432
783
|
case "plantuml": {
|
|
433
|
-
await runPlantuml(config, args.output);
|
|
784
|
+
await runPlantuml(model, config, args.output);
|
|
434
785
|
break;
|
|
435
786
|
}
|
|
436
787
|
case "kubernetes": {
|
|
437
|
-
await runKubernetes(config, args.output);
|
|
788
|
+
await runKubernetes(model, config, args.output);
|
|
438
789
|
break;
|
|
439
790
|
}
|
|
440
791
|
default: {
|
|
@@ -498,7 +849,7 @@ const init = defineCommand({
|
|
|
498
849
|
const main = defineCommand({
|
|
499
850
|
meta: {
|
|
500
851
|
name: "aact",
|
|
501
|
-
version: "2.0.
|
|
852
|
+
version: "2.0.2",
|
|
502
853
|
description: "Architecture analysis and compliance tool"
|
|
503
854
|
},
|
|
504
855
|
subCommands: { init, check, analyze, generate }
|