Skip to content

Commit 6c84024

Browse files
feat: add dependency graph visualization, layer grouping, violations highlighting, and CI pipeline
1 parent 5e501d3 commit 6c84024

21 files changed

Lines changed: 929 additions & 182 deletions

.github/workflows/ci.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
- dev
8+
- "**"
9+
pull_request:
10+
11+
jobs:
12+
test:
13+
runs-on: ubuntu-latest
14+
15+
steps:
16+
- name: Checkout repository
17+
uses: actions/checkout@v4
18+
19+
- name: Setup Node.js
20+
uses: actions/setup-node@v4
21+
with:
22+
node-version: 22
23+
cache: npm
24+
25+
- name: Install dependencies
26+
run: npm ci
27+
28+
- name: Run tests
29+
run: npm test

bin/truss.ts

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
#!/usr/bin/env node
22
import * as path from "node:path";
33
import { Command } from "commander";
4-
import { runCheck } from "../src/core/engine";
4+
import { runCheck, runAnalysis } from "../src/core/engine";
55
import {
66
renderHumanReport,
77
renderJsonError,
88
renderJsonReport,
99
} from "../src/core/reporter";
1010
import { ExitCode } from "../src/core/types";
11+
import { renderGraphAsDot } from "../src/graph/dotRenderer";
1112

1213
const program = new Command();
1314

@@ -21,11 +22,11 @@ program
2122
.description("Check repository for architectural violations")
2223
.option("-c, --config <path>", "Path to truss.yml", "truss.yml")
2324
.option("--repo <path>", "Repo root", ".")
24-
.option("--format <format>", "Output format: human|json", "human")
25+
.option("--format <format>", 'Output format: human|json', "human")
2526
.option(
2627
"--show-suppressed",
2728
"Print suppressed violations in full detail (human only)",
28-
false,
29+
false
2930
)
3031
.action(async (options) => {
3132
const format = options.format;
@@ -73,7 +74,7 @@ program
7374
console.log(
7475
renderHumanReport(result.report, {
7576
showSuppressed: Boolean(options.showSuppressed),
76-
}),
77+
})
7778
);
7879
}
7980

@@ -82,7 +83,7 @@ program
8283
const message = error instanceof Error ? error.message : String(error);
8384
if (format === "json") {
8485
console.log(
85-
renderJsonError(`Internal error: ${message}`, ExitCode.INTERNAL_ERROR),
86+
renderJsonError(`Internal error: ${message}`, ExitCode.INTERNAL_ERROR)
8687
);
8788
} else {
8889
console.error("Truss: Internal error");
@@ -92,4 +93,66 @@ program
9293
}
9394
});
9495

96+
program
97+
.command("graph")
98+
.description("Render the repository dependency graph as DOT")
99+
.option("-c, --config <path>", "Path to truss.yml", "truss.yml")
100+
.option("--repo <path>", "Repo root", ".")
101+
.option("--format <format>", 'Output format: dot', "dot")
102+
.action(async (options) => {
103+
const format = options.format;
104+
105+
try {
106+
const repoRoot = path.resolve(options.repo);
107+
const configPath = options.config;
108+
109+
if (format !== "dot") {
110+
const msg = `Invalid --format value "${format}". Expected "dot".`;
111+
console.error("Truss: Configuration error");
112+
console.error(msg);
113+
process.exitCode = ExitCode.CONFIG_ERROR;
114+
return;
115+
}
116+
117+
const {
118+
config,
119+
graph,
120+
} = runAnalysis({
121+
repoRoot,
122+
configPath,
123+
});
124+
125+
const result = await runCheck({
126+
repoRoot,
127+
configPath,
128+
format: "human",
129+
showSuppressed: false,
130+
});
131+
132+
const violations =
133+
"report" in result
134+
? result.report.unsuppressed.flatMap((v) =>
135+
v.edge.importKind === "internal"
136+
? [
137+
{
138+
from: v.edge.fromFile,
139+
to: v.edge.toFile,
140+
},
141+
]
142+
: []
143+
)
144+
: [];
145+
146+
const dot = renderGraphAsDot(graph, config.layers, violations);
147+
148+
process.stdout.write(`${dot}\n`);
149+
process.exitCode = ExitCode.OK;
150+
} catch (error) {
151+
const message = error instanceof Error ? error.message : String(error);
152+
console.error("Truss: Internal error");
153+
console.error(message);
154+
process.exitCode = ExitCode.INTERNAL_ERROR;
155+
}
156+
});
157+
95158
program.parse(process.argv);

package-lock.json

Lines changed: 41 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,11 @@
1515
},
1616
"dependencies": {
1717
"commander": "^12.1.0",
18+
"minimatch": "^10.2.5",
1819
"yaml": "^2.8.1"
1920
},
2021
"devDependencies": {
21-
"@types/node": "^22.15.18",
22+
"@types/node": "^22.19.17",
2223
"tsx": "^4.19.4",
2324
"typescript": "^5.8.3"
2425
}

src/config/configLoader.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@ function formatYamlError(err: Error, shownPath: string): string {
3030
return `Invalid YAML in ${shownPath}. Fix the syntax and try again.`;
3131
}
3232

33+
function normalizeLayerPattern(pattern: string): string {
34+
const trimmed = pattern.trim().split(path.sep).join("/");
35+
36+
if (!trimmed) return trimmed;
37+
if (trimmed.includes("*")) return trimmed;
38+
39+
return trimmed.endsWith("/") ? `${trimmed}**` : `${trimmed}/**`;
40+
}
41+
3342
// Loads the YAML file, validates the required shape, and returns it as a typed config.
3443
export function loadTrussConfig(
3544
configPath: string,
@@ -83,6 +92,13 @@ export function loadTrussConfig(
8392
}
8493
}
8594

95+
const normalizedLayers = Object.fromEntries(
96+
Object.entries(cfg.layers).map(([layerName, patterns]) => [
97+
layerName,
98+
patterns.map(normalizeLayerPattern),
99+
])
100+
);
101+
86102
if (!cfg.rules || !Array.isArray(cfg.rules) || cfg.rules.length === 0) {
87103
throw new ConfigError(
88104
`No rules defined in ${shownPath}. Add at least one rule under "rules".`,
@@ -116,6 +132,13 @@ export function loadTrussConfig(
116132
`Rule "${r.name}" in ${shownPath} must define "disallow" as a non-empty string[].`,
117133
);
118134
}
135+
136+
if ("message" in r && r.message !== undefined && typeof r.message !== "string") {
137+
throw new ConfigError(
138+
`Rule "${r.name}" in ${shownPath} has invalid "message": expected a string.`,
139+
);
140+
}
141+
119142
for (const target of r.disallow) {
120143
if (!knownLayers.has(target)) {
121144
throw new ConfigError(
@@ -125,5 +148,8 @@ export function loadTrussConfig(
125148
}
126149
}
127150

128-
return cfg as TrussConfig;
151+
return {
152+
...cfg,
153+
layers: normalizedLayers,
154+
} as TrussConfig;
129155
}

0 commit comments

Comments
 (0)