aact 2.1.5 → 3.0.0-beta.2

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,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, 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.CJGFUdeF.mjs';
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 pc from 'picocolors';
10
- import 'yaml';
11
- import 'plantuml-parser';
9
+ import { colors, box } from 'consola/utils';
12
10
 
13
- const version = "2.1.5";
11
+ const version = "3.0.0-beta.2";
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
- return v.parse(AactConfigSchema, config);
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
- switch (config.source.type) {
36
- case "plantuml": {
37
- const elements = await loadPlantumlElements(resolvedPath);
38
- return mapContainersFromPlantumlElements(elements);
39
- }
40
- case "structurizr": {
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 plantumlSyntax = {
111
- containerPattern: (name) => `(${name},`,
112
- containerDecl: (name, label, tags) => {
113
- const tagsPart = tags ? `, "", "", $tags="${tags}"` : "";
114
- return `Container(${name}, "${label}"${tagsPart})`;
115
- },
116
- relationPattern: (from, to) => `Rel(${from}, ${to}`,
117
- relationDecl: (from, to, tech, tags) => {
118
- const tagsPart = tags ? `, $tags="${tags}"` : "";
119
- return `Rel(${from}, ${to}, "${tech ?? ""}"${tagsPart})`;
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`
138
- );
139
- }
140
- const indent = /^(\s*)/.exec(lines[idx])?.[1] ?? "";
141
- switch (edit.type) {
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
- }
155
- }
156
- return lines.join("\n");
157
- };
158
-
159
- const detectNamingConvention = (model) => {
160
- const names = model.allContainers.map((c) => c.name);
161
- if (names.length === 0) return "snake";
162
- const withUnderscore = names.filter((n) => n.includes("_")).length;
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)) {
199
- consola.warn(
200
- `fix acl: skipping ${container.name} \u2014 ${aclName} already exists`
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).`
201
181
  );
202
- continue;
203
182
  }
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);
183
+ seen.set(r.name, "custom");
184
+ merged.push(r);
229
185
  }
230
- return results;
186
+ return merged;
231
187
  };
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
- }
239
- }
240
- return map;
241
- };
242
- const findPublicApiCandidate = (targetBoundary, dbType, ownerTags, model, containerBoundaryMap) => {
243
- const candidates = targetBoundary.containers.filter(
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)) {
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)) {
357
193
  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);
407
- const results = [];
408
- for (const violation of violations) {
409
- const container = model.allContainers.find(
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
194
+ `Unknown rule "${key}" in config.rules \u2014 ignored. Did you forget to add it to customRules?`
425
195
  );
426
- if (fix) results.push(fix);
427
196
  }
428
197
  }
429
- return results;
430
198
  };
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);
199
+ const getRuleConfigValue = (rules, ruleName) => rules?.[ruleName];
200
+ const runRules = (model, rules, effective) => {
453
201
  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 getSyntax = (config) => {
556
- if (config.source.type === "plantuml") {
557
- return plantumlSyntax;
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
- if (!config.source.writePath) {
561
- consola.warn(
562
- "To use --fix with structurizr, add source.writePath pointing to your workspace.dsl"
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 null;
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 = ruleMap.get(result.name);
229
+ const ruleDef = ruleByName.get(result.name);
575
230
  if (!ruleDef?.fix) continue;
576
- const configValue = rules?.[ruleDef.name];
231
+ const configValue = getRuleConfigValue(rules, ruleDef.name);
577
232
  const options = typeof configValue === "object" ? configValue : void 0;
578
- fixes.push(...ruleDef.fix(model, result.violations, syntax, options));
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 = pc.red(`${count} ${label}`);
589
- console.log(`${pc.bold(pc.red(result.name))} ${countLabel}`);
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(` ${pc.bold(v.container.padEnd(maxLen))} ${v.message}`);
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
- `${pc.dim("Passed")} ${passed.map((r) => pc.green(r.name)).join(pc.dim(" \xB7 "))}`
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
- pc.red(
611
- `Found ${total} ${total === 1 ? "violation" : "violations"} in ${failed.length} ${rulesLabel}`
612
- ) + pc.dim(" \u2014 run with --fix to apply suggested fixes")
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 = pc.bold(`[${fix.rule}]`);
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(pc.red(prefixContent(edit.search, " - ", " ")));
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
- pc.green(prefixContent(edit.content ?? "", " + ", " "))
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(pc.dim(` (after "${edit.search}")`));
326
+ console.log(colors.dim(` (after "${edit.search}")`));
653
327
  console.log(
654
- pc.green(prefixContent(edit.content ?? "", " + ", " "))
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 syntax = getSyntax(config);
712
- if (!syntax) return exitWithViolations();
713
- const fixes = generateFixes(model, results, config.rules, syntax);
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
- pc.bold(dryRun ? "Suggested fixes (dry run):" : "Applying fixes:")
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 syntax = getSyntax(config);
730
- if (!syntax) return;
731
- const fixes = generateFixes(model, results, config.rules, syntax);
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(pc.bold("Suggested fixes:"));
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 model = await loadModel(config);
761
- const results = runRules(model, config.rules);
762
- formatResults(results, detectFormat(args.format));
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(model, results, config, args["dry-run"] ?? false);
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 plantuml, directory for kubernetes)"
481
+ description: "Output path (file for single output, directory for multi)"
807
482
  },
808
483
  format: {
809
484
  type: "string",
810
- description: "Output format: plantuml, kubernetes"
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 format = args.format ?? "plantuml";
817
- switch (format) {
818
- case "plantuml": {
819
- await runPlantuml(model, config, args.output);
820
- break;
821
- }
822
- case "kubernetes": {
823
- await runKubernetes(model, config, args.output);
824
- break;
825
- }
826
- default: {
827
- throw new Error(`Unknown format: ${format}`);
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
 
@@ -853,7 +542,7 @@ const config: AactConfig = {
853
542
 
854
543
  // PlantUML generation from Kubernetes configs (aact generate)
855
544
  // generate: {
856
- // kubernetes: { path: "./resources/kubernetes" },
545
+ // kubernetes: { path: "./fixtures/kubernetes" },
857
546
  // boundaryLabel: "Our system",
858
547
  // },
859
548
  };
@@ -912,12 +601,93 @@ const init = defineCommand({
912
601
  }
913
602
  });
914
603
 
604
+ const buildEffectiveSet = async () => {
605
+ const out = [];
606
+ let config;
607
+ try {
608
+ config = await loadAndValidateConfig();
609
+ } catch {
610
+ return ruleRegistry.map((rule2) => ({
611
+ rule: rule2,
612
+ source: "built-in",
613
+ enabled: true
614
+ }));
615
+ }
616
+ const rules = config.rules;
617
+ const isEnabled = (name) => rules?.[name] !== false;
618
+ for (const rule2 of ruleRegistry) {
619
+ out.push({ rule: rule2, source: "built-in", enabled: isEnabled(rule2.name) });
620
+ }
621
+ for (const rule2 of config.customRules ?? []) {
622
+ out.push({ rule: rule2, source: "custom", enabled: isEnabled(rule2.name) });
623
+ }
624
+ return out;
625
+ };
626
+ const listAction = defineCommand({
627
+ meta: { description: "List all effective rules (built-in + custom)" },
628
+ args: {
629
+ json: {
630
+ type: "boolean",
631
+ description: "Output in JSON format"
632
+ }
633
+ },
634
+ async run({ args }) {
635
+ const effective = await buildEffectiveSet();
636
+ if (args.json) {
637
+ console.log(
638
+ JSON.stringify(
639
+ effective.map((e) => ({
640
+ name: e.rule.name,
641
+ description: e.rule.description,
642
+ source: e.source,
643
+ enabled: e.enabled,
644
+ hasFix: typeof e.rule.fix === "function"
645
+ })),
646
+ void 0,
647
+ 2
648
+ )
649
+ );
650
+ return;
651
+ }
652
+ const groups = {
653
+ "built-in": [],
654
+ custom: []
655
+ };
656
+ for (const e of effective) groups[e.source].push(e);
657
+ const renderGroup = (label, items) => {
658
+ if (items.length === 0) return;
659
+ console.log(colors.bold(label));
660
+ const maxName = Math.max(...items.map((i) => i.rule.name.length));
661
+ for (const { rule: rule2, enabled: enabled2 } of items) {
662
+ const status = enabled2 ? colors.green("\u25CF") : colors.dim("\u25CB");
663
+ const fix = rule2.fix ? colors.dim(" [fix]") : "";
664
+ const name = enabled2 ? colors.bold(rule2.name.padEnd(maxName)) : colors.dim(rule2.name.padEnd(maxName));
665
+ console.log(
666
+ ` ${status} ${name} ${colors.dim(rule2.description)}${fix}`
667
+ );
668
+ }
669
+ console.log();
670
+ };
671
+ renderGroup("Built-in", groups["built-in"]);
672
+ renderGroup("Custom", groups.custom);
673
+ const enabled = effective.filter((e) => e.enabled).length;
674
+ const total = effective.length;
675
+ console.log(
676
+ colors.dim(`${enabled}/${total} rules enabled \xB7 \u25CF enabled \xB7 \u25CB disabled`)
677
+ );
678
+ }
679
+ });
680
+ const rule = defineCommand({
681
+ meta: { description: "Inspect and manage architecture rules" },
682
+ subCommands: { list: listAction }
683
+ });
684
+
915
685
  const main = defineCommand({
916
686
  meta: {
917
687
  name: "aact",
918
688
  version,
919
689
  description: "Architecture analysis and compliance tool"
920
690
  },
921
- subCommands: { init, check, analyze, generate }
691
+ subCommands: { init, check, analyze, generate, rule }
922
692
  });
923
693
  void runMain(main);