aact 2.0.2 → 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.
@@ -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, m as loadStructurizrElements, l as loadPlantumlElements, o as mapContainersFromPlantumlElements, a as analyzeArchitecture, c as checkAcl, b as checkAcyclic, d as checkApiGateway, f as checkCrud, g as checkDbPerService, e as checkCohesion, h as checkStableDependencies, j as generateKubernetes, k as generatePlantumlFromModel } from '../shared/aact.Dqryafrg.mjs';
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({ name: "aact" });
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) => `Container(${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
- let lines = source.split("\n");
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) continue;
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 externalType = options?.externalType ?? "System_Ext";
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 = `${container.name}_acl`;
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
- type: "add",
133
- search: syntax.containerPattern(container.name),
134
- content: syntax.containerDecl(aclName, `${container.label} ACL`, tag)
135
- });
136
- for (const rel of externalRels) {
137
- const tech = rel.technology ?? "";
138
- fix.edits.push(
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(container.name, rel.to.name),
142
- content: syntax.relationDecl(container.name, aclName, tech)
143
- },
144
- {
145
- type: "add",
146
- search: syntax.relationPattern(container.name, aclName),
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 ?? "ContainerDb";
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[0];
169
- const fix = {
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) continue;
177
- const tags = rel.tags && rel.tags.length > 0 ? rel.tags.join("+") : void 0;
178
- const oldRel = syntax.relationPattern(accessor.name, db.name);
179
- const newRel = syntax.relationDecl(
180
- accessor.name,
181
- owner.name,
182
- rel.technology ?? "",
183
- tags
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
- fix.edits.push({ type: "replace", search: oldRel, content: newRel });
186
- }
187
- results.push(fix);
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 = (sourceType) => {
234
- if (sourceType === "plantuml") {
525
+ const getSyntax = (config) => {
526
+ if (config.source.type === "plantuml") {
235
527
  return plantumlSyntax;
236
528
  }
237
- throw new Error(`Write-back not supported for ${sourceType}`);
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 = ruleRegistry.find((r) => r.name === result.name);
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
- for (const result of results) {
253
- if (result.violations.length === 0) {
254
- consola.success(`${result.name} \u2014 passed`);
255
- } else {
256
- consola.error(
257
- `${result.name} \u2014 ${result.violations.length} violation(s)`
258
- );
259
- for (const v of result.violations) {
260
- consola.log(` ${v.container}: ${v.message}`);
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
- consola.info(`[${fix.rule}] ${fix.description}`);
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
- consola.log(` - ${edit.search}`);
611
+ console.log(pc.red(prefixContent(edit.search, " - ", " ")));
289
612
  break;
290
613
  }
291
614
  case "replace": {
292
- consola.log(` - ${edit.search}`);
293
- consola.log(` + ${edit.content}`);
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
- consola.log(` (after "${edit.search}")`);
298
- consola.log(` + ${edit.content}`);
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
- const format = detectFormat(args.format);
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
- if (!hasViolations) {
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
- try {
380
- const syntax = getSyntax(config.source.type);
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
- for (const output of outputs) {
411
- const filePath = path.join(targetDir, output.fileName);
412
- await fs.writeFile(filePath, output.content);
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: {