@harness-engineering/core 0.10.0 → 0.11.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/dist/architecture/matchers.d.mts +2 -0
- package/dist/architecture/matchers.d.ts +2 -0
- package/dist/architecture/matchers.js +1784 -0
- package/dist/architecture/matchers.mjs +10 -0
- package/dist/chunk-ZHGBWFYD.mjs +1827 -0
- package/dist/index.d.mts +892 -23
- package/dist/index.d.ts +892 -23
- package/dist/index.js +2244 -320
- package/dist/index.mjs +1164 -1026
- package/dist/matchers-D20x48U9.d.mts +353 -0
- package/dist/matchers-D20x48U9.d.ts +353 -0
- package/package.json +11 -3
|
@@ -0,0 +1,1784 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/architecture/matchers.ts
|
|
21
|
+
var matchers_exports = {};
|
|
22
|
+
__export(matchers_exports, {
|
|
23
|
+
archMatchers: () => archMatchers,
|
|
24
|
+
archModule: () => archModule,
|
|
25
|
+
architecture: () => architecture
|
|
26
|
+
});
|
|
27
|
+
module.exports = __toCommonJS(matchers_exports);
|
|
28
|
+
|
|
29
|
+
// src/architecture/types.ts
|
|
30
|
+
var import_zod = require("zod");
|
|
31
|
+
var ArchMetricCategorySchema = import_zod.z.enum([
|
|
32
|
+
"circular-deps",
|
|
33
|
+
"layer-violations",
|
|
34
|
+
"complexity",
|
|
35
|
+
"coupling",
|
|
36
|
+
"forbidden-imports",
|
|
37
|
+
"module-size",
|
|
38
|
+
"dependency-depth"
|
|
39
|
+
]);
|
|
40
|
+
var ViolationSchema = import_zod.z.object({
|
|
41
|
+
id: import_zod.z.string(),
|
|
42
|
+
// stable hash: sha256(relativePath + ':' + category + ':' + normalizedDetail)
|
|
43
|
+
file: import_zod.z.string(),
|
|
44
|
+
// relative to project root
|
|
45
|
+
category: ArchMetricCategorySchema.optional(),
|
|
46
|
+
// context for baseline reporting
|
|
47
|
+
detail: import_zod.z.string(),
|
|
48
|
+
// human-readable description
|
|
49
|
+
severity: import_zod.z.enum(["error", "warning"])
|
|
50
|
+
});
|
|
51
|
+
var MetricResultSchema = import_zod.z.object({
|
|
52
|
+
category: ArchMetricCategorySchema,
|
|
53
|
+
scope: import_zod.z.string(),
|
|
54
|
+
// e.g., 'project', 'src/services', 'src/api/routes.ts'
|
|
55
|
+
value: import_zod.z.number(),
|
|
56
|
+
// numeric metric (violation count, complexity score, etc.)
|
|
57
|
+
violations: import_zod.z.array(ViolationSchema),
|
|
58
|
+
metadata: import_zod.z.record(import_zod.z.unknown()).optional()
|
|
59
|
+
});
|
|
60
|
+
var CategoryBaselineSchema = import_zod.z.object({
|
|
61
|
+
value: import_zod.z.number(),
|
|
62
|
+
// aggregate metric value at baseline time
|
|
63
|
+
violationIds: import_zod.z.array(import_zod.z.string())
|
|
64
|
+
// stable IDs of known violations (the allowlist)
|
|
65
|
+
});
|
|
66
|
+
var ArchBaselineSchema = import_zod.z.object({
|
|
67
|
+
version: import_zod.z.literal(1),
|
|
68
|
+
updatedAt: import_zod.z.string().datetime(),
|
|
69
|
+
// ISO 8601
|
|
70
|
+
updatedFrom: import_zod.z.string(),
|
|
71
|
+
// commit hash
|
|
72
|
+
metrics: import_zod.z.record(ArchMetricCategorySchema, CategoryBaselineSchema)
|
|
73
|
+
});
|
|
74
|
+
var CategoryRegressionSchema = import_zod.z.object({
|
|
75
|
+
category: ArchMetricCategorySchema,
|
|
76
|
+
baselineValue: import_zod.z.number(),
|
|
77
|
+
currentValue: import_zod.z.number(),
|
|
78
|
+
delta: import_zod.z.number()
|
|
79
|
+
});
|
|
80
|
+
var ArchDiffResultSchema = import_zod.z.object({
|
|
81
|
+
passed: import_zod.z.boolean(),
|
|
82
|
+
newViolations: import_zod.z.array(ViolationSchema),
|
|
83
|
+
// in current but not in baseline -> FAIL
|
|
84
|
+
resolvedViolations: import_zod.z.array(import_zod.z.string()),
|
|
85
|
+
// in baseline but not in current -> celebrate
|
|
86
|
+
preExisting: import_zod.z.array(import_zod.z.string()),
|
|
87
|
+
// in both -> allowed, tracked
|
|
88
|
+
regressions: import_zod.z.array(CategoryRegressionSchema)
|
|
89
|
+
// aggregate value exceeded baseline
|
|
90
|
+
});
|
|
91
|
+
var ThresholdConfigSchema = import_zod.z.record(
|
|
92
|
+
ArchMetricCategorySchema,
|
|
93
|
+
import_zod.z.union([import_zod.z.number(), import_zod.z.record(import_zod.z.string(), import_zod.z.number())])
|
|
94
|
+
);
|
|
95
|
+
var ArchConfigSchema = import_zod.z.object({
|
|
96
|
+
enabled: import_zod.z.boolean().default(true),
|
|
97
|
+
baselinePath: import_zod.z.string().default(".harness/arch/baselines.json"),
|
|
98
|
+
thresholds: ThresholdConfigSchema.default({}),
|
|
99
|
+
modules: import_zod.z.record(import_zod.z.string(), ThresholdConfigSchema).default({})
|
|
100
|
+
});
|
|
101
|
+
var ConstraintRuleSchema = import_zod.z.object({
|
|
102
|
+
id: import_zod.z.string(),
|
|
103
|
+
// stable hash: sha256(category + ':' + scope + ':' + description)
|
|
104
|
+
category: ArchMetricCategorySchema,
|
|
105
|
+
description: import_zod.z.string(),
|
|
106
|
+
// e.g., "Layer 'services' must not import from 'ui'"
|
|
107
|
+
scope: import_zod.z.string(),
|
|
108
|
+
// e.g., 'src/services/', 'project'
|
|
109
|
+
targets: import_zod.z.array(import_zod.z.string()).optional()
|
|
110
|
+
// forward-compat for governs edges
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// src/architecture/collectors/circular-deps.ts
|
|
114
|
+
var import_node_path = require("path");
|
|
115
|
+
|
|
116
|
+
// src/architecture/collectors/hash.ts
|
|
117
|
+
var import_node_crypto = require("crypto");
|
|
118
|
+
function violationId(relativePath, category, normalizedDetail) {
|
|
119
|
+
const path = relativePath.replace(/\\/g, "/");
|
|
120
|
+
const input = `${path}:${category}:${normalizedDetail}`;
|
|
121
|
+
return (0, import_node_crypto.createHash)("sha256").update(input).digest("hex");
|
|
122
|
+
}
|
|
123
|
+
function constraintRuleId(category, scope, description) {
|
|
124
|
+
const input = `${category}:${scope}:${description}`;
|
|
125
|
+
return (0, import_node_crypto.createHash)("sha256").update(input).digest("hex");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// src/shared/result.ts
|
|
129
|
+
var import_types = require("@harness-engineering/types");
|
|
130
|
+
|
|
131
|
+
// src/shared/errors.ts
|
|
132
|
+
function createError(code, message, details = {}, suggestions = []) {
|
|
133
|
+
return { code, message, details, suggestions };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// src/constraints/layers.ts
|
|
137
|
+
var import_minimatch = require("minimatch");
|
|
138
|
+
function resolveFileToLayer(file, layers) {
|
|
139
|
+
for (const layer of layers) {
|
|
140
|
+
for (const pattern of layer.patterns) {
|
|
141
|
+
if ((0, import_minimatch.minimatch)(file, pattern)) {
|
|
142
|
+
return layer;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return void 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// src/shared/fs-utils.ts
|
|
150
|
+
var import_fs = require("fs");
|
|
151
|
+
var import_util = require("util");
|
|
152
|
+
var import_glob = require("glob");
|
|
153
|
+
var accessAsync = (0, import_util.promisify)(import_fs.access);
|
|
154
|
+
var readFileAsync = (0, import_util.promisify)(import_fs.readFile);
|
|
155
|
+
async function findFiles(pattern, cwd = process.cwd()) {
|
|
156
|
+
return (0, import_glob.glob)(pattern, { cwd, absolute: true });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// src/constraints/dependencies.ts
|
|
160
|
+
var import_path = require("path");
|
|
161
|
+
function resolveImportPath(importSource, fromFile, _rootDir) {
|
|
162
|
+
if (!importSource.startsWith(".") && !importSource.startsWith("/")) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
const fromDir = (0, import_path.dirname)(fromFile);
|
|
166
|
+
let resolved = (0, import_path.resolve)(fromDir, importSource);
|
|
167
|
+
if (!resolved.endsWith(".ts") && !resolved.endsWith(".tsx")) {
|
|
168
|
+
resolved = resolved + ".ts";
|
|
169
|
+
}
|
|
170
|
+
return resolved.replace(/\\/g, "/");
|
|
171
|
+
}
|
|
172
|
+
function getImportType(imp) {
|
|
173
|
+
if (imp.kind === "type") return "type-only";
|
|
174
|
+
return "static";
|
|
175
|
+
}
|
|
176
|
+
async function buildDependencyGraph(files, parser, graphDependencyData) {
|
|
177
|
+
if (graphDependencyData) {
|
|
178
|
+
return (0, import_types.Ok)({
|
|
179
|
+
nodes: graphDependencyData.nodes,
|
|
180
|
+
edges: graphDependencyData.edges
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
const nodes = files.map((f) => f.replace(/\\/g, "/"));
|
|
184
|
+
const edges = [];
|
|
185
|
+
for (const file of files) {
|
|
186
|
+
const normalizedFile = file.replace(/\\/g, "/");
|
|
187
|
+
const parseResult = await parser.parseFile(file);
|
|
188
|
+
if (!parseResult.ok) {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const importsResult = parser.extractImports(parseResult.value);
|
|
192
|
+
if (!importsResult.ok) {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
for (const imp of importsResult.value) {
|
|
196
|
+
const resolvedPath = resolveImportPath(imp.source, file, "");
|
|
197
|
+
if (resolvedPath) {
|
|
198
|
+
edges.push({
|
|
199
|
+
from: normalizedFile,
|
|
200
|
+
to: resolvedPath,
|
|
201
|
+
importType: getImportType(imp),
|
|
202
|
+
line: imp.location.line
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return (0, import_types.Ok)({ nodes, edges });
|
|
208
|
+
}
|
|
209
|
+
function checkLayerViolations(graph, layers, rootDir) {
|
|
210
|
+
const violations = [];
|
|
211
|
+
for (const edge of graph.edges) {
|
|
212
|
+
const fromRelative = (0, import_path.relative)(rootDir, edge.from);
|
|
213
|
+
const toRelative = (0, import_path.relative)(rootDir, edge.to);
|
|
214
|
+
const fromLayer = resolveFileToLayer(fromRelative, layers);
|
|
215
|
+
const toLayer = resolveFileToLayer(toRelative, layers);
|
|
216
|
+
if (!fromLayer || !toLayer) continue;
|
|
217
|
+
if (fromLayer.name === toLayer.name) continue;
|
|
218
|
+
if (!fromLayer.allowedDependencies.includes(toLayer.name)) {
|
|
219
|
+
violations.push({
|
|
220
|
+
file: edge.from,
|
|
221
|
+
imports: edge.to,
|
|
222
|
+
fromLayer: fromLayer.name,
|
|
223
|
+
toLayer: toLayer.name,
|
|
224
|
+
reason: "WRONG_LAYER",
|
|
225
|
+
line: edge.line,
|
|
226
|
+
suggestion: `Move the dependency to an allowed layer (${fromLayer.allowedDependencies.join(", ") || "none"}) or update layer rules`
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return violations;
|
|
231
|
+
}
|
|
232
|
+
async function validateDependencies(config) {
|
|
233
|
+
const { layers, rootDir, parser, fallbackBehavior = "error", graphDependencyData } = config;
|
|
234
|
+
if (graphDependencyData) {
|
|
235
|
+
const graphResult2 = await buildDependencyGraph([], parser, graphDependencyData);
|
|
236
|
+
if (!graphResult2.ok) {
|
|
237
|
+
return (0, import_types.Err)(graphResult2.error);
|
|
238
|
+
}
|
|
239
|
+
const violations2 = checkLayerViolations(graphResult2.value, layers, rootDir);
|
|
240
|
+
return (0, import_types.Ok)({
|
|
241
|
+
valid: violations2.length === 0,
|
|
242
|
+
violations: violations2,
|
|
243
|
+
graph: graphResult2.value
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
const healthResult = await parser.health();
|
|
247
|
+
if (!healthResult.ok || !healthResult.value.available) {
|
|
248
|
+
if (fallbackBehavior === "skip") {
|
|
249
|
+
return (0, import_types.Ok)({
|
|
250
|
+
valid: true,
|
|
251
|
+
violations: [],
|
|
252
|
+
graph: { nodes: [], edges: [] },
|
|
253
|
+
skipped: true,
|
|
254
|
+
reason: "Parser unavailable"
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
if (fallbackBehavior === "warn") {
|
|
258
|
+
console.warn(`Parser ${parser.name} unavailable, skipping validation`);
|
|
259
|
+
return (0, import_types.Ok)({
|
|
260
|
+
valid: true,
|
|
261
|
+
violations: [],
|
|
262
|
+
graph: { nodes: [], edges: [] },
|
|
263
|
+
skipped: true,
|
|
264
|
+
reason: "Parser unavailable"
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
return (0, import_types.Err)(
|
|
268
|
+
createError(
|
|
269
|
+
"PARSER_UNAVAILABLE",
|
|
270
|
+
`Parser ${parser.name} is not available`,
|
|
271
|
+
{ parser: parser.name },
|
|
272
|
+
["Install required runtime", "Use different parser", 'Set fallbackBehavior: "skip"']
|
|
273
|
+
)
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
const allFiles = [];
|
|
277
|
+
for (const layer of layers) {
|
|
278
|
+
for (const pattern of layer.patterns) {
|
|
279
|
+
const files = await findFiles(pattern, rootDir);
|
|
280
|
+
allFiles.push(...files);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
const uniqueFiles = [...new Set(allFiles)];
|
|
284
|
+
const graphResult = await buildDependencyGraph(uniqueFiles, parser);
|
|
285
|
+
if (!graphResult.ok) {
|
|
286
|
+
return (0, import_types.Err)(graphResult.error);
|
|
287
|
+
}
|
|
288
|
+
const violations = checkLayerViolations(graphResult.value, layers, rootDir);
|
|
289
|
+
return (0, import_types.Ok)({
|
|
290
|
+
valid: violations.length === 0,
|
|
291
|
+
violations,
|
|
292
|
+
graph: graphResult.value
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// src/constraints/circular-deps.ts
|
|
297
|
+
function tarjanSCC(graph) {
|
|
298
|
+
const nodeMap = /* @__PURE__ */ new Map();
|
|
299
|
+
const stack = [];
|
|
300
|
+
const sccs = [];
|
|
301
|
+
let index = 0;
|
|
302
|
+
const adjacency = /* @__PURE__ */ new Map();
|
|
303
|
+
for (const node of graph.nodes) {
|
|
304
|
+
adjacency.set(node, []);
|
|
305
|
+
}
|
|
306
|
+
for (const edge of graph.edges) {
|
|
307
|
+
const neighbors = adjacency.get(edge.from);
|
|
308
|
+
if (neighbors && graph.nodes.includes(edge.to)) {
|
|
309
|
+
neighbors.push(edge.to);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
function strongConnect(node) {
|
|
313
|
+
nodeMap.set(node, {
|
|
314
|
+
index,
|
|
315
|
+
lowlink: index,
|
|
316
|
+
onStack: true
|
|
317
|
+
});
|
|
318
|
+
index++;
|
|
319
|
+
stack.push(node);
|
|
320
|
+
const neighbors = adjacency.get(node) ?? [];
|
|
321
|
+
for (const neighbor of neighbors) {
|
|
322
|
+
const neighborData = nodeMap.get(neighbor);
|
|
323
|
+
if (!neighborData) {
|
|
324
|
+
strongConnect(neighbor);
|
|
325
|
+
const nodeData2 = nodeMap.get(node);
|
|
326
|
+
const updatedNeighborData = nodeMap.get(neighbor);
|
|
327
|
+
nodeData2.lowlink = Math.min(nodeData2.lowlink, updatedNeighborData.lowlink);
|
|
328
|
+
} else if (neighborData.onStack) {
|
|
329
|
+
const nodeData2 = nodeMap.get(node);
|
|
330
|
+
nodeData2.lowlink = Math.min(nodeData2.lowlink, neighborData.index);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const nodeData = nodeMap.get(node);
|
|
334
|
+
if (nodeData.lowlink === nodeData.index) {
|
|
335
|
+
const scc = [];
|
|
336
|
+
let w;
|
|
337
|
+
do {
|
|
338
|
+
w = stack.pop();
|
|
339
|
+
nodeMap.get(w).onStack = false;
|
|
340
|
+
scc.push(w);
|
|
341
|
+
} while (w !== node);
|
|
342
|
+
if (scc.length > 1) {
|
|
343
|
+
sccs.push(scc);
|
|
344
|
+
} else if (scc.length === 1) {
|
|
345
|
+
const selfNode = scc[0];
|
|
346
|
+
const selfNeighbors = adjacency.get(selfNode) ?? [];
|
|
347
|
+
if (selfNeighbors.includes(selfNode)) {
|
|
348
|
+
sccs.push(scc);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
for (const node of graph.nodes) {
|
|
354
|
+
if (!nodeMap.has(node)) {
|
|
355
|
+
strongConnect(node);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return sccs;
|
|
359
|
+
}
|
|
360
|
+
function detectCircularDeps(graph) {
|
|
361
|
+
const sccs = tarjanSCC(graph);
|
|
362
|
+
const cycles = sccs.map((scc) => {
|
|
363
|
+
const reversed = scc.reverse();
|
|
364
|
+
const firstNode = reversed[reversed.length - 1];
|
|
365
|
+
const cycle = [...reversed, firstNode];
|
|
366
|
+
return {
|
|
367
|
+
cycle,
|
|
368
|
+
severity: "error",
|
|
369
|
+
size: scc.length
|
|
370
|
+
};
|
|
371
|
+
});
|
|
372
|
+
const largestCycle = cycles.reduce((max, c) => Math.max(max, c.size), 0);
|
|
373
|
+
return (0, import_types.Ok)({
|
|
374
|
+
hasCycles: cycles.length > 0,
|
|
375
|
+
cycles,
|
|
376
|
+
largestCycle
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// src/architecture/collectors/circular-deps.ts
|
|
381
|
+
var CircularDepsCollector = class {
|
|
382
|
+
category = "circular-deps";
|
|
383
|
+
getRules(_config, _rootDir) {
|
|
384
|
+
const description = "No circular dependencies allowed";
|
|
385
|
+
return [
|
|
386
|
+
{
|
|
387
|
+
id: constraintRuleId(this.category, "project", description),
|
|
388
|
+
category: this.category,
|
|
389
|
+
description,
|
|
390
|
+
scope: "project"
|
|
391
|
+
}
|
|
392
|
+
];
|
|
393
|
+
}
|
|
394
|
+
async collect(_config, rootDir) {
|
|
395
|
+
const files = await findFiles("**/*.ts", rootDir);
|
|
396
|
+
const stubParser = {
|
|
397
|
+
name: "typescript",
|
|
398
|
+
extensions: [".ts", ".tsx"],
|
|
399
|
+
parseFile: async () => ({ ok: false, error: { code: "PARSE_ERROR", message: "not needed" } }),
|
|
400
|
+
extractImports: () => ({
|
|
401
|
+
ok: false,
|
|
402
|
+
error: { code: "EXTRACT_ERROR", message: "not needed" }
|
|
403
|
+
}),
|
|
404
|
+
extractExports: () => ({
|
|
405
|
+
ok: false,
|
|
406
|
+
error: { code: "EXTRACT_ERROR", message: "not needed" }
|
|
407
|
+
}),
|
|
408
|
+
health: async () => ({ ok: true, value: { available: true } })
|
|
409
|
+
};
|
|
410
|
+
const graphResult = await buildDependencyGraph(files, stubParser);
|
|
411
|
+
if (!graphResult.ok) {
|
|
412
|
+
return [
|
|
413
|
+
{
|
|
414
|
+
category: this.category,
|
|
415
|
+
scope: "project",
|
|
416
|
+
value: 0,
|
|
417
|
+
violations: [],
|
|
418
|
+
metadata: { error: "Failed to build dependency graph" }
|
|
419
|
+
}
|
|
420
|
+
];
|
|
421
|
+
}
|
|
422
|
+
const result = detectCircularDeps(graphResult.value);
|
|
423
|
+
if (!result.ok) {
|
|
424
|
+
return [
|
|
425
|
+
{
|
|
426
|
+
category: this.category,
|
|
427
|
+
scope: "project",
|
|
428
|
+
value: 0,
|
|
429
|
+
violations: [],
|
|
430
|
+
metadata: { error: "Failed to detect circular deps" }
|
|
431
|
+
}
|
|
432
|
+
];
|
|
433
|
+
}
|
|
434
|
+
const { cycles, largestCycle } = result.value;
|
|
435
|
+
const violations = cycles.map((cycle) => {
|
|
436
|
+
const cyclePath = cycle.cycle.map((f) => (0, import_node_path.relative)(rootDir, f)).join(" -> ");
|
|
437
|
+
const firstFile = (0, import_node_path.relative)(rootDir, cycle.cycle[0]);
|
|
438
|
+
return {
|
|
439
|
+
id: violationId(firstFile, this.category, cyclePath),
|
|
440
|
+
file: firstFile,
|
|
441
|
+
detail: `Circular dependency: ${cyclePath}`,
|
|
442
|
+
severity: cycle.severity
|
|
443
|
+
};
|
|
444
|
+
});
|
|
445
|
+
return [
|
|
446
|
+
{
|
|
447
|
+
category: this.category,
|
|
448
|
+
scope: "project",
|
|
449
|
+
value: cycles.length,
|
|
450
|
+
violations,
|
|
451
|
+
metadata: { largestCycle, cycleCount: cycles.length }
|
|
452
|
+
}
|
|
453
|
+
];
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
// src/architecture/collectors/layer-violations.ts
|
|
458
|
+
var import_node_path2 = require("path");
|
|
459
|
+
var LayerViolationCollector = class {
|
|
460
|
+
category = "layer-violations";
|
|
461
|
+
getRules(_config, _rootDir) {
|
|
462
|
+
const description = "No layer boundary violations allowed";
|
|
463
|
+
return [
|
|
464
|
+
{
|
|
465
|
+
id: constraintRuleId(this.category, "project", description),
|
|
466
|
+
category: this.category,
|
|
467
|
+
description,
|
|
468
|
+
scope: "project"
|
|
469
|
+
}
|
|
470
|
+
];
|
|
471
|
+
}
|
|
472
|
+
async collect(_config, rootDir) {
|
|
473
|
+
const stubParser = {
|
|
474
|
+
name: "typescript",
|
|
475
|
+
extensions: [".ts", ".tsx"],
|
|
476
|
+
parseFile: async () => ({ ok: false, error: { code: "PARSE_ERROR", message: "" } }),
|
|
477
|
+
extractImports: () => ({ ok: false, error: { code: "EXTRACT_ERROR", message: "" } }),
|
|
478
|
+
extractExports: () => ({ ok: false, error: { code: "EXTRACT_ERROR", message: "" } }),
|
|
479
|
+
health: async () => ({ ok: true, value: { available: true } })
|
|
480
|
+
};
|
|
481
|
+
const result = await validateDependencies({
|
|
482
|
+
layers: [],
|
|
483
|
+
rootDir,
|
|
484
|
+
parser: stubParser,
|
|
485
|
+
fallbackBehavior: "skip"
|
|
486
|
+
});
|
|
487
|
+
if (!result.ok) {
|
|
488
|
+
return [
|
|
489
|
+
{
|
|
490
|
+
category: this.category,
|
|
491
|
+
scope: "project",
|
|
492
|
+
value: 0,
|
|
493
|
+
violations: [],
|
|
494
|
+
metadata: { error: "Failed to validate dependencies" }
|
|
495
|
+
}
|
|
496
|
+
];
|
|
497
|
+
}
|
|
498
|
+
const layerViolations = result.value.violations.filter(
|
|
499
|
+
(v) => v.reason === "WRONG_LAYER"
|
|
500
|
+
);
|
|
501
|
+
const violations = layerViolations.map((v) => {
|
|
502
|
+
const relFile = (0, import_node_path2.relative)(rootDir, v.file);
|
|
503
|
+
const relImport = (0, import_node_path2.relative)(rootDir, v.imports);
|
|
504
|
+
const detail = `${v.fromLayer} -> ${v.toLayer}: ${relFile} imports ${relImport}`;
|
|
505
|
+
return {
|
|
506
|
+
id: violationId(relFile, this.category, detail),
|
|
507
|
+
file: relFile,
|
|
508
|
+
category: this.category,
|
|
509
|
+
detail,
|
|
510
|
+
severity: "error"
|
|
511
|
+
};
|
|
512
|
+
});
|
|
513
|
+
return [
|
|
514
|
+
{
|
|
515
|
+
category: this.category,
|
|
516
|
+
scope: "project",
|
|
517
|
+
value: violations.length,
|
|
518
|
+
violations
|
|
519
|
+
}
|
|
520
|
+
];
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
// src/architecture/baseline-manager.ts
|
|
525
|
+
var import_node_fs = require("fs");
|
|
526
|
+
var import_node_crypto2 = require("crypto");
|
|
527
|
+
var import_node_path3 = require("path");
|
|
528
|
+
var ArchBaselineManager = class {
|
|
529
|
+
baselinesPath;
|
|
530
|
+
constructor(projectRoot, baselinePath) {
|
|
531
|
+
this.baselinesPath = baselinePath ? (0, import_node_path3.join)(projectRoot, baselinePath) : (0, import_node_path3.join)(projectRoot, ".harness", "arch", "baselines.json");
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Snapshot the current metric results into an ArchBaseline.
|
|
535
|
+
* Aggregates multiple MetricResults for the same category by summing values
|
|
536
|
+
* and concatenating violation IDs.
|
|
537
|
+
*/
|
|
538
|
+
capture(results, commitHash) {
|
|
539
|
+
const metrics = {};
|
|
540
|
+
for (const result of results) {
|
|
541
|
+
const existing = metrics[result.category];
|
|
542
|
+
if (existing) {
|
|
543
|
+
existing.value += result.value;
|
|
544
|
+
existing.violationIds.push(...result.violations.map((v) => v.id));
|
|
545
|
+
} else {
|
|
546
|
+
metrics[result.category] = {
|
|
547
|
+
value: result.value,
|
|
548
|
+
violationIds: result.violations.map((v) => v.id)
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return {
|
|
553
|
+
version: 1,
|
|
554
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
555
|
+
updatedFrom: commitHash,
|
|
556
|
+
metrics
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Load the baselines file from disk.
|
|
561
|
+
* Returns null if the file does not exist, contains invalid JSON,
|
|
562
|
+
* or fails ArchBaselineSchema validation.
|
|
563
|
+
*/
|
|
564
|
+
load() {
|
|
565
|
+
if (!(0, import_node_fs.existsSync)(this.baselinesPath)) {
|
|
566
|
+
console.error(`Baseline file not found at: ${this.baselinesPath}`);
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
try {
|
|
570
|
+
const raw = (0, import_node_fs.readFileSync)(this.baselinesPath, "utf-8");
|
|
571
|
+
const data = JSON.parse(raw);
|
|
572
|
+
const parsed = ArchBaselineSchema.safeParse(data);
|
|
573
|
+
if (!parsed.success) {
|
|
574
|
+
console.error(
|
|
575
|
+
`Baseline validation failed for ${this.baselinesPath}:`,
|
|
576
|
+
parsed.error.format()
|
|
577
|
+
);
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
580
|
+
return parsed.data;
|
|
581
|
+
} catch (error) {
|
|
582
|
+
console.error(`Error loading baseline from ${this.baselinesPath}:`, error);
|
|
583
|
+
return null;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Save an ArchBaseline to disk.
|
|
588
|
+
* Creates parent directories if they do not exist.
|
|
589
|
+
* Uses atomic write (write to temp file, then rename) to prevent corruption.
|
|
590
|
+
*/
|
|
591
|
+
save(baseline) {
|
|
592
|
+
const dir = (0, import_node_path3.dirname)(this.baselinesPath);
|
|
593
|
+
if (!(0, import_node_fs.existsSync)(dir)) {
|
|
594
|
+
(0, import_node_fs.mkdirSync)(dir, { recursive: true });
|
|
595
|
+
}
|
|
596
|
+
const tmp = this.baselinesPath + "." + (0, import_node_crypto2.randomBytes)(4).toString("hex") + ".tmp";
|
|
597
|
+
(0, import_node_fs.writeFileSync)(tmp, JSON.stringify(baseline, null, 2));
|
|
598
|
+
(0, import_node_fs.renameSync)(tmp, this.baselinesPath);
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
// src/architecture/diff.ts
|
|
603
|
+
function aggregateByCategory(results) {
|
|
604
|
+
const map = /* @__PURE__ */ new Map();
|
|
605
|
+
for (const result of results) {
|
|
606
|
+
const existing = map.get(result.category);
|
|
607
|
+
if (existing) {
|
|
608
|
+
existing.value += result.value;
|
|
609
|
+
existing.violations.push(...result.violations);
|
|
610
|
+
} else {
|
|
611
|
+
map.set(result.category, {
|
|
612
|
+
value: result.value,
|
|
613
|
+
violations: [...result.violations]
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return map;
|
|
618
|
+
}
|
|
619
|
+
function diff(current, baseline) {
|
|
620
|
+
const aggregated = aggregateByCategory(current);
|
|
621
|
+
const newViolations = [];
|
|
622
|
+
const resolvedViolations = [];
|
|
623
|
+
const preExisting = [];
|
|
624
|
+
const regressions = [];
|
|
625
|
+
const visitedCategories = /* @__PURE__ */ new Set();
|
|
626
|
+
for (const [category, agg] of aggregated) {
|
|
627
|
+
visitedCategories.add(category);
|
|
628
|
+
const baselineCategory = baseline.metrics[category];
|
|
629
|
+
const baselineViolationIds = new Set(baselineCategory?.violationIds ?? []);
|
|
630
|
+
const baselineValue = baselineCategory?.value ?? 0;
|
|
631
|
+
for (const violation of agg.violations) {
|
|
632
|
+
if (baselineViolationIds.has(violation.id)) {
|
|
633
|
+
preExisting.push(violation.id);
|
|
634
|
+
} else {
|
|
635
|
+
newViolations.push(violation);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
const currentViolationIds = new Set(agg.violations.map((v) => v.id));
|
|
639
|
+
if (baselineCategory) {
|
|
640
|
+
for (const id of baselineCategory.violationIds) {
|
|
641
|
+
if (!currentViolationIds.has(id)) {
|
|
642
|
+
resolvedViolations.push(id);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
if (baselineCategory && agg.value > baselineValue) {
|
|
647
|
+
regressions.push({
|
|
648
|
+
category,
|
|
649
|
+
baselineValue,
|
|
650
|
+
currentValue: agg.value,
|
|
651
|
+
delta: agg.value - baselineValue
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
for (const [category, baselineCategory] of Object.entries(baseline.metrics)) {
|
|
656
|
+
if (!visitedCategories.has(category) && baselineCategory) {
|
|
657
|
+
for (const id of baselineCategory.violationIds) {
|
|
658
|
+
resolvedViolations.push(id);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
const passed = newViolations.length === 0 && regressions.length === 0;
|
|
663
|
+
return {
|
|
664
|
+
passed,
|
|
665
|
+
newViolations,
|
|
666
|
+
resolvedViolations,
|
|
667
|
+
preExisting,
|
|
668
|
+
regressions
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// src/architecture/collectors/complexity.ts
|
|
673
|
+
var import_node_path4 = require("path");
|
|
674
|
+
|
|
675
|
+
// src/entropy/detectors/complexity.ts
|
|
676
|
+
var import_promises = require("fs/promises");
|
|
677
|
+
var DEFAULT_THRESHOLDS = {
|
|
678
|
+
cyclomaticComplexity: { error: 15, warn: 10 },
|
|
679
|
+
nestingDepth: { warn: 4 },
|
|
680
|
+
functionLength: { warn: 50 },
|
|
681
|
+
parameterCount: { warn: 5 },
|
|
682
|
+
fileLength: { info: 300 },
|
|
683
|
+
hotspotPercentile: { error: 95 }
|
|
684
|
+
};
|
|
685
|
+
function extractFunctions(content) {
|
|
686
|
+
const functions = [];
|
|
687
|
+
const lines = content.split("\n");
|
|
688
|
+
const patterns = [
|
|
689
|
+
// function declarations: function name(params) {
|
|
690
|
+
/^\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)/,
|
|
691
|
+
// method declarations: name(params) {
|
|
692
|
+
/^\s*(?:async\s+)?(\w+)\s*\(([^)]*)\)\s*(?::\s*[^{]+)?\s*\{/,
|
|
693
|
+
// arrow functions assigned to const/let/var: const name = (params) =>
|
|
694
|
+
/^\s*(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?\(([^)]*)\)\s*(?::\s*[^=]+)?\s*=>/,
|
|
695
|
+
// arrow functions assigned to const/let/var with single param: const name = param =>
|
|
696
|
+
/^\s*(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?(\w+)\s*=>/
|
|
697
|
+
];
|
|
698
|
+
for (let i = 0; i < lines.length; i++) {
|
|
699
|
+
const line = lines[i];
|
|
700
|
+
for (const pattern of patterns) {
|
|
701
|
+
const match = line.match(pattern);
|
|
702
|
+
if (match) {
|
|
703
|
+
const name = match[1] ?? "anonymous";
|
|
704
|
+
const paramsStr = match[2] || "";
|
|
705
|
+
const params = paramsStr.trim() === "" ? 0 : paramsStr.split(",").length;
|
|
706
|
+
const endLine = findFunctionEnd(lines, i);
|
|
707
|
+
const body = lines.slice(i, endLine + 1).join("\n");
|
|
708
|
+
functions.push({
|
|
709
|
+
name,
|
|
710
|
+
line: i + 1,
|
|
711
|
+
params,
|
|
712
|
+
startLine: i + 1,
|
|
713
|
+
endLine: endLine + 1,
|
|
714
|
+
body
|
|
715
|
+
});
|
|
716
|
+
break;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
return functions;
|
|
721
|
+
}
|
|
722
|
+
function findFunctionEnd(lines, startIdx) {
|
|
723
|
+
let depth = 0;
|
|
724
|
+
let foundOpen = false;
|
|
725
|
+
for (let i = startIdx; i < lines.length; i++) {
|
|
726
|
+
const line = lines[i];
|
|
727
|
+
for (const ch of line) {
|
|
728
|
+
if (ch === "{") {
|
|
729
|
+
depth++;
|
|
730
|
+
foundOpen = true;
|
|
731
|
+
} else if (ch === "}") {
|
|
732
|
+
depth--;
|
|
733
|
+
if (foundOpen && depth === 0) {
|
|
734
|
+
return i;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
return lines.length - 1;
|
|
740
|
+
}
|
|
741
|
+
function computeCyclomaticComplexity(body) {
|
|
742
|
+
let complexity = 1;
|
|
743
|
+
const decisionPatterns = [
|
|
744
|
+
/\bif\s*\(/g,
|
|
745
|
+
/\belse\s+if\s*\(/g,
|
|
746
|
+
/\bwhile\s*\(/g,
|
|
747
|
+
/\bfor\s*\(/g,
|
|
748
|
+
/\bcase\s+/g,
|
|
749
|
+
/&&/g,
|
|
750
|
+
/\|\|/g,
|
|
751
|
+
/\?(?!=)/g,
|
|
752
|
+
// Ternary ? but not ?. or ??
|
|
753
|
+
/\bcatch\s*\(/g
|
|
754
|
+
];
|
|
755
|
+
for (const pattern of decisionPatterns) {
|
|
756
|
+
const matches = body.match(pattern);
|
|
757
|
+
if (matches) {
|
|
758
|
+
complexity += matches.length;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
const elseIfMatches = body.match(/\belse\s+if\s*\(/g);
|
|
762
|
+
if (elseIfMatches) {
|
|
763
|
+
complexity -= elseIfMatches.length;
|
|
764
|
+
}
|
|
765
|
+
return complexity;
|
|
766
|
+
}
|
|
767
|
+
function computeNestingDepth(body) {
|
|
768
|
+
let maxDepth = 0;
|
|
769
|
+
let currentDepth = 0;
|
|
770
|
+
let functionBodyStarted = false;
|
|
771
|
+
for (const ch of body) {
|
|
772
|
+
if (ch === "{") {
|
|
773
|
+
if (!functionBodyStarted) {
|
|
774
|
+
functionBodyStarted = true;
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
currentDepth++;
|
|
778
|
+
if (currentDepth > maxDepth) {
|
|
779
|
+
maxDepth = currentDepth;
|
|
780
|
+
}
|
|
781
|
+
} else if (ch === "}") {
|
|
782
|
+
if (currentDepth > 0) {
|
|
783
|
+
currentDepth--;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
return maxDepth;
|
|
788
|
+
}
|
|
789
|
+
async function detectComplexityViolations(snapshot, config, graphData) {
|
|
790
|
+
const violations = [];
|
|
791
|
+
const thresholds = {
|
|
792
|
+
cyclomaticComplexity: {
|
|
793
|
+
error: config?.thresholds?.cyclomaticComplexity?.error ?? DEFAULT_THRESHOLDS.cyclomaticComplexity.error,
|
|
794
|
+
warn: config?.thresholds?.cyclomaticComplexity?.warn ?? DEFAULT_THRESHOLDS.cyclomaticComplexity.warn
|
|
795
|
+
},
|
|
796
|
+
nestingDepth: {
|
|
797
|
+
warn: config?.thresholds?.nestingDepth?.warn ?? DEFAULT_THRESHOLDS.nestingDepth.warn
|
|
798
|
+
},
|
|
799
|
+
functionLength: {
|
|
800
|
+
warn: config?.thresholds?.functionLength?.warn ?? DEFAULT_THRESHOLDS.functionLength.warn
|
|
801
|
+
},
|
|
802
|
+
parameterCount: {
|
|
803
|
+
warn: config?.thresholds?.parameterCount?.warn ?? DEFAULT_THRESHOLDS.parameterCount.warn
|
|
804
|
+
},
|
|
805
|
+
fileLength: {
|
|
806
|
+
info: config?.thresholds?.fileLength?.info ?? DEFAULT_THRESHOLDS.fileLength.info
|
|
807
|
+
}
|
|
808
|
+
};
|
|
809
|
+
let totalFunctions = 0;
|
|
810
|
+
for (const file of snapshot.files) {
|
|
811
|
+
let content;
|
|
812
|
+
try {
|
|
813
|
+
content = await (0, import_promises.readFile)(file.path, "utf-8");
|
|
814
|
+
} catch {
|
|
815
|
+
continue;
|
|
816
|
+
}
|
|
817
|
+
const lines = content.split("\n");
|
|
818
|
+
if (lines.length > thresholds.fileLength.info) {
|
|
819
|
+
violations.push({
|
|
820
|
+
file: file.path,
|
|
821
|
+
function: "<file>",
|
|
822
|
+
line: 1,
|
|
823
|
+
metric: "fileLength",
|
|
824
|
+
value: lines.length,
|
|
825
|
+
threshold: thresholds.fileLength.info,
|
|
826
|
+
tier: 3,
|
|
827
|
+
severity: "info",
|
|
828
|
+
message: `File has ${lines.length} lines (threshold: ${thresholds.fileLength.info})`
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
const functions = extractFunctions(content);
|
|
832
|
+
totalFunctions += functions.length;
|
|
833
|
+
for (const fn of functions) {
|
|
834
|
+
const complexity = computeCyclomaticComplexity(fn.body);
|
|
835
|
+
if (complexity > thresholds.cyclomaticComplexity.error) {
|
|
836
|
+
violations.push({
|
|
837
|
+
file: file.path,
|
|
838
|
+
function: fn.name,
|
|
839
|
+
line: fn.line,
|
|
840
|
+
metric: "cyclomaticComplexity",
|
|
841
|
+
value: complexity,
|
|
842
|
+
threshold: thresholds.cyclomaticComplexity.error,
|
|
843
|
+
tier: 1,
|
|
844
|
+
severity: "error",
|
|
845
|
+
message: `Function "${fn.name}" has cyclomatic complexity of ${complexity} (error threshold: ${thresholds.cyclomaticComplexity.error})`
|
|
846
|
+
});
|
|
847
|
+
} else if (complexity > thresholds.cyclomaticComplexity.warn) {
|
|
848
|
+
violations.push({
|
|
849
|
+
file: file.path,
|
|
850
|
+
function: fn.name,
|
|
851
|
+
line: fn.line,
|
|
852
|
+
metric: "cyclomaticComplexity",
|
|
853
|
+
value: complexity,
|
|
854
|
+
threshold: thresholds.cyclomaticComplexity.warn,
|
|
855
|
+
tier: 2,
|
|
856
|
+
severity: "warning",
|
|
857
|
+
message: `Function "${fn.name}" has cyclomatic complexity of ${complexity} (warning threshold: ${thresholds.cyclomaticComplexity.warn})`
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
const nestingDepth = computeNestingDepth(fn.body);
|
|
861
|
+
if (nestingDepth > thresholds.nestingDepth.warn) {
|
|
862
|
+
violations.push({
|
|
863
|
+
file: file.path,
|
|
864
|
+
function: fn.name,
|
|
865
|
+
line: fn.line,
|
|
866
|
+
metric: "nestingDepth",
|
|
867
|
+
value: nestingDepth,
|
|
868
|
+
threshold: thresholds.nestingDepth.warn,
|
|
869
|
+
tier: 2,
|
|
870
|
+
severity: "warning",
|
|
871
|
+
message: `Function "${fn.name}" has nesting depth of ${nestingDepth} (threshold: ${thresholds.nestingDepth.warn})`
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
const fnLength = fn.endLine - fn.startLine + 1;
|
|
875
|
+
if (fnLength > thresholds.functionLength.warn) {
|
|
876
|
+
violations.push({
|
|
877
|
+
file: file.path,
|
|
878
|
+
function: fn.name,
|
|
879
|
+
line: fn.line,
|
|
880
|
+
metric: "functionLength",
|
|
881
|
+
value: fnLength,
|
|
882
|
+
threshold: thresholds.functionLength.warn,
|
|
883
|
+
tier: 2,
|
|
884
|
+
severity: "warning",
|
|
885
|
+
message: `Function "${fn.name}" is ${fnLength} lines long (threshold: ${thresholds.functionLength.warn})`
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
if (fn.params > thresholds.parameterCount.warn) {
|
|
889
|
+
violations.push({
|
|
890
|
+
file: file.path,
|
|
891
|
+
function: fn.name,
|
|
892
|
+
line: fn.line,
|
|
893
|
+
metric: "parameterCount",
|
|
894
|
+
value: fn.params,
|
|
895
|
+
threshold: thresholds.parameterCount.warn,
|
|
896
|
+
tier: 2,
|
|
897
|
+
severity: "warning",
|
|
898
|
+
message: `Function "${fn.name}" has ${fn.params} parameters (threshold: ${thresholds.parameterCount.warn})`
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
if (graphData) {
|
|
902
|
+
const hotspot = graphData.hotspots.find(
|
|
903
|
+
(h) => h.file === file.path && h.function === fn.name
|
|
904
|
+
);
|
|
905
|
+
if (hotspot && hotspot.hotspotScore > graphData.percentile95Score) {
|
|
906
|
+
violations.push({
|
|
907
|
+
file: file.path,
|
|
908
|
+
function: fn.name,
|
|
909
|
+
line: fn.line,
|
|
910
|
+
metric: "hotspotScore",
|
|
911
|
+
value: hotspot.hotspotScore,
|
|
912
|
+
threshold: graphData.percentile95Score,
|
|
913
|
+
tier: 1,
|
|
914
|
+
severity: "error",
|
|
915
|
+
message: `Function "${fn.name}" is a complexity hotspot (score: ${hotspot.hotspotScore}, p95: ${graphData.percentile95Score})`
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
const errorCount = violations.filter((v) => v.severity === "error").length;
|
|
922
|
+
const warningCount = violations.filter((v) => v.severity === "warning").length;
|
|
923
|
+
const infoCount = violations.filter((v) => v.severity === "info").length;
|
|
924
|
+
return (0, import_types.Ok)({
|
|
925
|
+
violations,
|
|
926
|
+
stats: {
|
|
927
|
+
filesAnalyzed: snapshot.files.length,
|
|
928
|
+
functionsAnalyzed: totalFunctions,
|
|
929
|
+
violationCount: violations.length,
|
|
930
|
+
errorCount,
|
|
931
|
+
warningCount,
|
|
932
|
+
infoCount
|
|
933
|
+
}
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// src/architecture/collectors/complexity.ts
|
|
938
|
+
var ComplexityCollector = class {
|
|
939
|
+
category = "complexity";
|
|
940
|
+
getRules(_config, _rootDir) {
|
|
941
|
+
const description = "Cyclomatic complexity must stay within thresholds";
|
|
942
|
+
return [
|
|
943
|
+
{
|
|
944
|
+
id: constraintRuleId(this.category, "project", description),
|
|
945
|
+
category: this.category,
|
|
946
|
+
description,
|
|
947
|
+
scope: "project"
|
|
948
|
+
}
|
|
949
|
+
];
|
|
950
|
+
}
|
|
951
|
+
async collect(_config, rootDir) {
|
|
952
|
+
const files = await findFiles("**/*.ts", rootDir);
|
|
953
|
+
const snapshot = {
|
|
954
|
+
files: files.map((f) => ({
|
|
955
|
+
path: f,
|
|
956
|
+
ast: { type: "Program", body: null, language: "typescript" },
|
|
957
|
+
imports: [],
|
|
958
|
+
exports: [],
|
|
959
|
+
internalSymbols: [],
|
|
960
|
+
jsDocComments: []
|
|
961
|
+
})),
|
|
962
|
+
dependencyGraph: { nodes: [], edges: [] },
|
|
963
|
+
exportMap: { byFile: /* @__PURE__ */ new Map(), byName: /* @__PURE__ */ new Map() },
|
|
964
|
+
docs: [],
|
|
965
|
+
codeReferences: [],
|
|
966
|
+
entryPoints: [],
|
|
967
|
+
rootDir,
|
|
968
|
+
config: { rootDir, analyze: {} },
|
|
969
|
+
buildTime: 0
|
|
970
|
+
};
|
|
971
|
+
const complexityThreshold = _config.thresholds.complexity;
|
|
972
|
+
const maxComplexity = typeof complexityThreshold === "number" ? complexityThreshold : complexityThreshold?.max ?? 15;
|
|
973
|
+
const complexityConfig = {
|
|
974
|
+
thresholds: {
|
|
975
|
+
cyclomaticComplexity: {
|
|
976
|
+
error: maxComplexity,
|
|
977
|
+
warn: Math.floor(maxComplexity * 0.7)
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
};
|
|
981
|
+
const result = await detectComplexityViolations(snapshot, complexityConfig);
|
|
982
|
+
if (!result.ok) {
|
|
983
|
+
return [
|
|
984
|
+
{
|
|
985
|
+
category: this.category,
|
|
986
|
+
scope: "project",
|
|
987
|
+
value: 0,
|
|
988
|
+
violations: [],
|
|
989
|
+
metadata: { error: "Failed to detect complexity violations" }
|
|
990
|
+
}
|
|
991
|
+
];
|
|
992
|
+
}
|
|
993
|
+
const { violations: complexityViolations, stats } = result.value;
|
|
994
|
+
const filtered = complexityViolations.filter(
|
|
995
|
+
(v) => v.severity === "error" || v.severity === "warning"
|
|
996
|
+
);
|
|
997
|
+
const violations = filtered.map((v) => {
|
|
998
|
+
const relFile = (0, import_node_path4.relative)(rootDir, v.file);
|
|
999
|
+
const idDetail = `${v.metric}:${v.function}`;
|
|
1000
|
+
return {
|
|
1001
|
+
id: violationId(relFile, this.category, idDetail),
|
|
1002
|
+
file: relFile,
|
|
1003
|
+
category: this.category,
|
|
1004
|
+
detail: `${v.metric}=${v.value} in ${v.function} (threshold: ${v.threshold})`,
|
|
1005
|
+
severity: v.severity
|
|
1006
|
+
};
|
|
1007
|
+
});
|
|
1008
|
+
return [
|
|
1009
|
+
{
|
|
1010
|
+
category: this.category,
|
|
1011
|
+
scope: "project",
|
|
1012
|
+
value: violations.length,
|
|
1013
|
+
violations,
|
|
1014
|
+
metadata: {
|
|
1015
|
+
filesAnalyzed: stats.filesAnalyzed,
|
|
1016
|
+
functionsAnalyzed: stats.functionsAnalyzed
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
];
|
|
1020
|
+
}
|
|
1021
|
+
};
|
|
1022
|
+
|
|
1023
|
+
// src/architecture/collectors/coupling.ts
|
|
1024
|
+
var import_node_path5 = require("path");
|
|
1025
|
+
|
|
1026
|
+
// src/entropy/detectors/coupling.ts
|
|
1027
|
+
var DEFAULT_THRESHOLDS2 = {
|
|
1028
|
+
fanOut: { warn: 15 },
|
|
1029
|
+
fanIn: { info: 20 },
|
|
1030
|
+
couplingRatio: { warn: 0.7 },
|
|
1031
|
+
transitiveDependencyDepth: { info: 30 }
|
|
1032
|
+
};
|
|
1033
|
+
function computeMetricsFromSnapshot(snapshot) {
|
|
1034
|
+
const fanInMap = /* @__PURE__ */ new Map();
|
|
1035
|
+
for (const file of snapshot.files) {
|
|
1036
|
+
for (const imp of file.imports) {
|
|
1037
|
+
const resolved = resolveImportSource(imp.source, file.path, snapshot);
|
|
1038
|
+
if (resolved) {
|
|
1039
|
+
fanInMap.set(resolved, (fanInMap.get(resolved) || 0) + 1);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
return snapshot.files.map((file) => {
|
|
1044
|
+
const fanOut = file.imports.length;
|
|
1045
|
+
const fanIn = fanInMap.get(file.path) || 0;
|
|
1046
|
+
const total = fanIn + fanOut;
|
|
1047
|
+
const couplingRatio = total > 0 ? fanOut / total : 0;
|
|
1048
|
+
return {
|
|
1049
|
+
file: file.path,
|
|
1050
|
+
fanIn,
|
|
1051
|
+
fanOut,
|
|
1052
|
+
couplingRatio,
|
|
1053
|
+
transitiveDepth: 0
|
|
1054
|
+
};
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
function resolveRelativePath(from, source) {
|
|
1058
|
+
const dir = from.includes("/") ? from.substring(0, from.lastIndexOf("/")) : ".";
|
|
1059
|
+
const parts = dir.split("/");
|
|
1060
|
+
for (const segment of source.split("/")) {
|
|
1061
|
+
if (segment === ".") continue;
|
|
1062
|
+
if (segment === "..") {
|
|
1063
|
+
parts.pop();
|
|
1064
|
+
} else {
|
|
1065
|
+
parts.push(segment);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
return parts.join("/");
|
|
1069
|
+
}
|
|
1070
|
+
function resolveImportSource(source, fromFile, snapshot) {
|
|
1071
|
+
if (!source.startsWith(".") && !source.startsWith("/")) {
|
|
1072
|
+
return void 0;
|
|
1073
|
+
}
|
|
1074
|
+
const resolved = resolveRelativePath(fromFile, source);
|
|
1075
|
+
const filePaths = snapshot.files.map((f) => f.path);
|
|
1076
|
+
const candidates = [
|
|
1077
|
+
resolved,
|
|
1078
|
+
`${resolved}.ts`,
|
|
1079
|
+
`${resolved}.tsx`,
|
|
1080
|
+
`${resolved}/index.ts`,
|
|
1081
|
+
`${resolved}/index.tsx`
|
|
1082
|
+
];
|
|
1083
|
+
for (const candidate of candidates) {
|
|
1084
|
+
const match = filePaths.find((fp) => fp === candidate);
|
|
1085
|
+
if (match) return match;
|
|
1086
|
+
}
|
|
1087
|
+
return void 0;
|
|
1088
|
+
}
|
|
1089
|
+
function checkViolations(metrics, config) {
|
|
1090
|
+
const thresholds = {
|
|
1091
|
+
fanOut: { ...DEFAULT_THRESHOLDS2.fanOut, ...config?.thresholds?.fanOut },
|
|
1092
|
+
fanIn: { ...DEFAULT_THRESHOLDS2.fanIn, ...config?.thresholds?.fanIn },
|
|
1093
|
+
couplingRatio: { ...DEFAULT_THRESHOLDS2.couplingRatio, ...config?.thresholds?.couplingRatio },
|
|
1094
|
+
transitiveDependencyDepth: {
|
|
1095
|
+
...DEFAULT_THRESHOLDS2.transitiveDependencyDepth,
|
|
1096
|
+
...config?.thresholds?.transitiveDependencyDepth
|
|
1097
|
+
}
|
|
1098
|
+
};
|
|
1099
|
+
const violations = [];
|
|
1100
|
+
for (const m of metrics) {
|
|
1101
|
+
if (thresholds.fanOut.warn !== void 0 && m.fanOut > thresholds.fanOut.warn) {
|
|
1102
|
+
violations.push({
|
|
1103
|
+
file: m.file,
|
|
1104
|
+
metric: "fanOut",
|
|
1105
|
+
value: m.fanOut,
|
|
1106
|
+
threshold: thresholds.fanOut.warn,
|
|
1107
|
+
tier: 2,
|
|
1108
|
+
severity: "warning",
|
|
1109
|
+
message: `File has ${m.fanOut} imports (threshold: ${thresholds.fanOut.warn})`
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
if (thresholds.fanIn.info !== void 0 && m.fanIn > thresholds.fanIn.info) {
|
|
1113
|
+
violations.push({
|
|
1114
|
+
file: m.file,
|
|
1115
|
+
metric: "fanIn",
|
|
1116
|
+
value: m.fanIn,
|
|
1117
|
+
threshold: thresholds.fanIn.info,
|
|
1118
|
+
tier: 3,
|
|
1119
|
+
severity: "info",
|
|
1120
|
+
message: `File is imported by ${m.fanIn} files (threshold: ${thresholds.fanIn.info})`
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
const totalConnections = m.fanIn + m.fanOut;
|
|
1124
|
+
if (totalConnections > 5 && thresholds.couplingRatio.warn !== void 0 && m.couplingRatio > thresholds.couplingRatio.warn) {
|
|
1125
|
+
violations.push({
|
|
1126
|
+
file: m.file,
|
|
1127
|
+
metric: "couplingRatio",
|
|
1128
|
+
value: m.couplingRatio,
|
|
1129
|
+
threshold: thresholds.couplingRatio.warn,
|
|
1130
|
+
tier: 2,
|
|
1131
|
+
severity: "warning",
|
|
1132
|
+
message: `Coupling ratio is ${m.couplingRatio.toFixed(2)} (threshold: ${thresholds.couplingRatio.warn})`
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
if (thresholds.transitiveDependencyDepth.info !== void 0 && m.transitiveDepth > thresholds.transitiveDependencyDepth.info) {
|
|
1136
|
+
violations.push({
|
|
1137
|
+
file: m.file,
|
|
1138
|
+
metric: "transitiveDependencyDepth",
|
|
1139
|
+
value: m.transitiveDepth,
|
|
1140
|
+
threshold: thresholds.transitiveDependencyDepth.info,
|
|
1141
|
+
tier: 3,
|
|
1142
|
+
severity: "info",
|
|
1143
|
+
message: `Transitive dependency depth is ${m.transitiveDepth} (threshold: ${thresholds.transitiveDependencyDepth.info})`
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
return violations;
|
|
1148
|
+
}
|
|
1149
|
+
async function detectCouplingViolations(snapshot, config, graphData) {
|
|
1150
|
+
let metrics;
|
|
1151
|
+
if (graphData) {
|
|
1152
|
+
metrics = graphData.files.map((f) => ({
|
|
1153
|
+
file: f.file,
|
|
1154
|
+
fanIn: f.fanIn,
|
|
1155
|
+
fanOut: f.fanOut,
|
|
1156
|
+
couplingRatio: f.couplingRatio,
|
|
1157
|
+
transitiveDepth: f.transitiveDepth
|
|
1158
|
+
}));
|
|
1159
|
+
} else {
|
|
1160
|
+
metrics = computeMetricsFromSnapshot(snapshot);
|
|
1161
|
+
}
|
|
1162
|
+
const violations = checkViolations(metrics, config);
|
|
1163
|
+
const warningCount = violations.filter((v) => v.severity === "warning").length;
|
|
1164
|
+
const infoCount = violations.filter((v) => v.severity === "info").length;
|
|
1165
|
+
return (0, import_types.Ok)({
|
|
1166
|
+
violations,
|
|
1167
|
+
stats: {
|
|
1168
|
+
filesAnalyzed: metrics.length,
|
|
1169
|
+
violationCount: violations.length,
|
|
1170
|
+
warningCount,
|
|
1171
|
+
infoCount
|
|
1172
|
+
}
|
|
1173
|
+
});
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// src/architecture/collectors/coupling.ts
|
|
1177
|
+
var CouplingCollector = class {
|
|
1178
|
+
category = "coupling";
|
|
1179
|
+
getRules(_config, _rootDir) {
|
|
1180
|
+
const description = "Coupling metrics must stay within thresholds";
|
|
1181
|
+
return [
|
|
1182
|
+
{
|
|
1183
|
+
id: constraintRuleId(this.category, "project", description),
|
|
1184
|
+
category: this.category,
|
|
1185
|
+
description,
|
|
1186
|
+
scope: "project"
|
|
1187
|
+
}
|
|
1188
|
+
];
|
|
1189
|
+
}
|
|
1190
|
+
async collect(_config, rootDir) {
|
|
1191
|
+
const files = await findFiles("**/*.ts", rootDir);
|
|
1192
|
+
const snapshot = {
|
|
1193
|
+
files: files.map((f) => ({
|
|
1194
|
+
path: f,
|
|
1195
|
+
ast: { type: "Program", body: null, language: "typescript" },
|
|
1196
|
+
imports: [],
|
|
1197
|
+
exports: [],
|
|
1198
|
+
internalSymbols: [],
|
|
1199
|
+
jsDocComments: []
|
|
1200
|
+
})),
|
|
1201
|
+
dependencyGraph: { nodes: [], edges: [] },
|
|
1202
|
+
exportMap: { byFile: /* @__PURE__ */ new Map(), byName: /* @__PURE__ */ new Map() },
|
|
1203
|
+
docs: [],
|
|
1204
|
+
codeReferences: [],
|
|
1205
|
+
entryPoints: [],
|
|
1206
|
+
rootDir,
|
|
1207
|
+
config: { rootDir, analyze: {} },
|
|
1208
|
+
buildTime: 0
|
|
1209
|
+
};
|
|
1210
|
+
const result = await detectCouplingViolations(snapshot);
|
|
1211
|
+
if (!result.ok) {
|
|
1212
|
+
return [
|
|
1213
|
+
{
|
|
1214
|
+
category: this.category,
|
|
1215
|
+
scope: "project",
|
|
1216
|
+
value: 0,
|
|
1217
|
+
violations: [],
|
|
1218
|
+
metadata: { error: "Failed to detect coupling violations" }
|
|
1219
|
+
}
|
|
1220
|
+
];
|
|
1221
|
+
}
|
|
1222
|
+
const { violations: couplingViolations, stats } = result.value;
|
|
1223
|
+
const filtered = couplingViolations.filter(
|
|
1224
|
+
(v) => v.severity === "error" || v.severity === "warning"
|
|
1225
|
+
);
|
|
1226
|
+
const violations = filtered.map((v) => {
|
|
1227
|
+
const relFile = (0, import_node_path5.relative)(rootDir, v.file);
|
|
1228
|
+
const idDetail = `${v.metric}`;
|
|
1229
|
+
return {
|
|
1230
|
+
id: violationId(relFile, this.category, idDetail),
|
|
1231
|
+
file: relFile,
|
|
1232
|
+
category: this.category,
|
|
1233
|
+
detail: `${v.metric}=${v.value} (threshold: ${v.threshold})`,
|
|
1234
|
+
severity: v.severity
|
|
1235
|
+
};
|
|
1236
|
+
});
|
|
1237
|
+
return [
|
|
1238
|
+
{
|
|
1239
|
+
category: this.category,
|
|
1240
|
+
scope: "project",
|
|
1241
|
+
value: violations.length,
|
|
1242
|
+
violations,
|
|
1243
|
+
metadata: { filesAnalyzed: stats.filesAnalyzed }
|
|
1244
|
+
}
|
|
1245
|
+
];
|
|
1246
|
+
}
|
|
1247
|
+
};
|
|
1248
|
+
|
|
1249
|
+
// src/architecture/collectors/forbidden-imports.ts
|
|
1250
|
+
var import_node_path6 = require("path");
|
|
1251
|
+
var ForbiddenImportCollector = class {
|
|
1252
|
+
category = "forbidden-imports";
|
|
1253
|
+
getRules(_config, _rootDir) {
|
|
1254
|
+
const description = "No forbidden imports allowed";
|
|
1255
|
+
return [
|
|
1256
|
+
{
|
|
1257
|
+
id: constraintRuleId(this.category, "project", description),
|
|
1258
|
+
category: this.category,
|
|
1259
|
+
description,
|
|
1260
|
+
scope: "project"
|
|
1261
|
+
}
|
|
1262
|
+
];
|
|
1263
|
+
}
|
|
1264
|
+
async collect(_config, rootDir) {
|
|
1265
|
+
const stubParser = {
|
|
1266
|
+
name: "typescript",
|
|
1267
|
+
extensions: [".ts", ".tsx"],
|
|
1268
|
+
parseFile: async () => ({ ok: false, error: { code: "PARSE_ERROR", message: "" } }),
|
|
1269
|
+
extractImports: () => ({ ok: false, error: { code: "EXTRACT_ERROR", message: "" } }),
|
|
1270
|
+
extractExports: () => ({ ok: false, error: { code: "EXTRACT_ERROR", message: "" } }),
|
|
1271
|
+
health: async () => ({ ok: true, value: { available: true } })
|
|
1272
|
+
};
|
|
1273
|
+
const result = await validateDependencies({
|
|
1274
|
+
layers: [],
|
|
1275
|
+
rootDir,
|
|
1276
|
+
parser: stubParser,
|
|
1277
|
+
fallbackBehavior: "skip"
|
|
1278
|
+
});
|
|
1279
|
+
if (!result.ok) {
|
|
1280
|
+
return [
|
|
1281
|
+
{
|
|
1282
|
+
category: this.category,
|
|
1283
|
+
scope: "project",
|
|
1284
|
+
value: 0,
|
|
1285
|
+
violations: [],
|
|
1286
|
+
metadata: { error: "Failed to validate dependencies" }
|
|
1287
|
+
}
|
|
1288
|
+
];
|
|
1289
|
+
}
|
|
1290
|
+
const forbidden = result.value.violations.filter(
|
|
1291
|
+
(v) => v.reason === "FORBIDDEN_IMPORT"
|
|
1292
|
+
);
|
|
1293
|
+
const violations = forbidden.map((v) => {
|
|
1294
|
+
const relFile = (0, import_node_path6.relative)(rootDir, v.file);
|
|
1295
|
+
const relImport = (0, import_node_path6.relative)(rootDir, v.imports);
|
|
1296
|
+
const detail = `forbidden import: ${relFile} -> ${relImport}`;
|
|
1297
|
+
return {
|
|
1298
|
+
id: violationId(relFile, this.category, detail),
|
|
1299
|
+
file: relFile,
|
|
1300
|
+
category: this.category,
|
|
1301
|
+
detail,
|
|
1302
|
+
severity: "error"
|
|
1303
|
+
};
|
|
1304
|
+
});
|
|
1305
|
+
return [
|
|
1306
|
+
{
|
|
1307
|
+
category: this.category,
|
|
1308
|
+
scope: "project",
|
|
1309
|
+
value: violations.length,
|
|
1310
|
+
violations
|
|
1311
|
+
}
|
|
1312
|
+
];
|
|
1313
|
+
}
|
|
1314
|
+
};
|
|
1315
|
+
|
|
1316
|
+
// src/architecture/collectors/module-size.ts
|
|
1317
|
+
var import_promises2 = require("fs/promises");
|
|
1318
|
+
var import_node_path7 = require("path");
|
|
1319
|
+
async function discoverModules(rootDir) {
|
|
1320
|
+
const modules = [];
|
|
1321
|
+
async function scanDir(dir) {
|
|
1322
|
+
let entries;
|
|
1323
|
+
try {
|
|
1324
|
+
entries = await (0, import_promises2.readdir)(dir, { withFileTypes: true });
|
|
1325
|
+
} catch {
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
const tsFiles = [];
|
|
1329
|
+
const subdirs = [];
|
|
1330
|
+
for (const entry of entries) {
|
|
1331
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === "dist") {
|
|
1332
|
+
continue;
|
|
1333
|
+
}
|
|
1334
|
+
const fullPath = (0, import_node_path7.join)(dir, entry.name);
|
|
1335
|
+
if (entry.isDirectory()) {
|
|
1336
|
+
subdirs.push(fullPath);
|
|
1337
|
+
} else if (entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) && !entry.name.endsWith(".test.ts") && !entry.name.endsWith(".test.tsx") && !entry.name.endsWith(".spec.ts")) {
|
|
1338
|
+
tsFiles.push(fullPath);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
if (tsFiles.length > 0) {
|
|
1342
|
+
let totalLoc = 0;
|
|
1343
|
+
for (const f of tsFiles) {
|
|
1344
|
+
try {
|
|
1345
|
+
const content = await (0, import_promises2.readFile)(f, "utf-8");
|
|
1346
|
+
totalLoc += content.split("\n").filter((line) => line.trim().length > 0).length;
|
|
1347
|
+
} catch {
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
modules.push({
|
|
1351
|
+
modulePath: (0, import_node_path7.relative)(rootDir, dir),
|
|
1352
|
+
fileCount: tsFiles.length,
|
|
1353
|
+
totalLoc,
|
|
1354
|
+
files: tsFiles.map((f) => (0, import_node_path7.relative)(rootDir, f))
|
|
1355
|
+
});
|
|
1356
|
+
}
|
|
1357
|
+
for (const sub of subdirs) {
|
|
1358
|
+
await scanDir(sub);
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
await scanDir(rootDir);
|
|
1362
|
+
return modules;
|
|
1363
|
+
}
|
|
1364
|
+
var ModuleSizeCollector = class {
|
|
1365
|
+
category = "module-size";
|
|
1366
|
+
getRules(config, _rootDir) {
|
|
1367
|
+
const thresholds = config.thresholds["module-size"];
|
|
1368
|
+
let maxLoc = Infinity;
|
|
1369
|
+
let maxFiles = Infinity;
|
|
1370
|
+
if (typeof thresholds === "object" && thresholds !== null) {
|
|
1371
|
+
const t = thresholds;
|
|
1372
|
+
if (t.maxLoc !== void 0) maxLoc = t.maxLoc;
|
|
1373
|
+
if (t.maxFiles !== void 0) maxFiles = t.maxFiles;
|
|
1374
|
+
}
|
|
1375
|
+
const rules = [];
|
|
1376
|
+
if (maxLoc < Infinity) {
|
|
1377
|
+
const desc = `Module LOC must not exceed ${maxLoc}`;
|
|
1378
|
+
rules.push({
|
|
1379
|
+
id: constraintRuleId(this.category, "project", desc),
|
|
1380
|
+
category: this.category,
|
|
1381
|
+
description: desc,
|
|
1382
|
+
scope: "project"
|
|
1383
|
+
});
|
|
1384
|
+
}
|
|
1385
|
+
if (maxFiles < Infinity) {
|
|
1386
|
+
const desc = `Module file count must not exceed ${maxFiles}`;
|
|
1387
|
+
rules.push({
|
|
1388
|
+
id: constraintRuleId(this.category, "project", desc),
|
|
1389
|
+
category: this.category,
|
|
1390
|
+
description: desc,
|
|
1391
|
+
scope: "project"
|
|
1392
|
+
});
|
|
1393
|
+
}
|
|
1394
|
+
if (rules.length === 0) {
|
|
1395
|
+
const desc = "Module size must stay within thresholds";
|
|
1396
|
+
rules.push({
|
|
1397
|
+
id: constraintRuleId(this.category, "project", desc),
|
|
1398
|
+
category: this.category,
|
|
1399
|
+
description: desc,
|
|
1400
|
+
scope: "project"
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1403
|
+
return rules;
|
|
1404
|
+
}
|
|
1405
|
+
async collect(config, rootDir) {
|
|
1406
|
+
const modules = await discoverModules(rootDir);
|
|
1407
|
+
const thresholds = config.thresholds["module-size"];
|
|
1408
|
+
let maxLoc = Infinity;
|
|
1409
|
+
let maxFiles = Infinity;
|
|
1410
|
+
if (typeof thresholds === "object" && thresholds !== null) {
|
|
1411
|
+
const t = thresholds;
|
|
1412
|
+
if (t.maxLoc !== void 0) maxLoc = t.maxLoc;
|
|
1413
|
+
if (t.maxFiles !== void 0) maxFiles = t.maxFiles;
|
|
1414
|
+
}
|
|
1415
|
+
return modules.map((mod) => {
|
|
1416
|
+
const violations = [];
|
|
1417
|
+
if (mod.totalLoc > maxLoc) {
|
|
1418
|
+
violations.push({
|
|
1419
|
+
id: violationId(mod.modulePath, this.category, "totalLoc-exceeded"),
|
|
1420
|
+
file: mod.modulePath,
|
|
1421
|
+
detail: `Module has ${mod.totalLoc} lines of code (threshold: ${maxLoc})`,
|
|
1422
|
+
severity: "warning"
|
|
1423
|
+
});
|
|
1424
|
+
}
|
|
1425
|
+
if (mod.fileCount > maxFiles) {
|
|
1426
|
+
violations.push({
|
|
1427
|
+
id: violationId(mod.modulePath, this.category, "fileCount-exceeded"),
|
|
1428
|
+
file: mod.modulePath,
|
|
1429
|
+
detail: `Module has ${mod.fileCount} files (threshold: ${maxFiles})`,
|
|
1430
|
+
severity: "warning"
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
return {
|
|
1434
|
+
category: this.category,
|
|
1435
|
+
scope: mod.modulePath,
|
|
1436
|
+
value: mod.totalLoc,
|
|
1437
|
+
violations,
|
|
1438
|
+
metadata: { fileCount: mod.fileCount, totalLoc: mod.totalLoc }
|
|
1439
|
+
};
|
|
1440
|
+
});
|
|
1441
|
+
}
|
|
1442
|
+
};
|
|
1443
|
+
|
|
1444
|
+
// src/architecture/collectors/dep-depth.ts
|
|
1445
|
+
var import_promises3 = require("fs/promises");
|
|
1446
|
+
var import_node_path8 = require("path");
|
|
1447
|
+
function extractImportSources(content, filePath) {
|
|
1448
|
+
const importRegex = /(?:import|export)\s+.*?from\s+['"](\.[^'"]+)['"]/g;
|
|
1449
|
+
const dynamicRegex = /import\s*\(\s*['"](\.[^'"]+)['"]\s*\)/g;
|
|
1450
|
+
const sources = [];
|
|
1451
|
+
const dir = (0, import_node_path8.dirname)(filePath);
|
|
1452
|
+
for (const regex of [importRegex, dynamicRegex]) {
|
|
1453
|
+
let match;
|
|
1454
|
+
while ((match = regex.exec(content)) !== null) {
|
|
1455
|
+
let resolved = (0, import_node_path8.resolve)(dir, match[1]);
|
|
1456
|
+
if (!resolved.endsWith(".ts") && !resolved.endsWith(".tsx")) {
|
|
1457
|
+
resolved += ".ts";
|
|
1458
|
+
}
|
|
1459
|
+
sources.push(resolved);
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
return sources;
|
|
1463
|
+
}
|
|
1464
|
+
async function collectTsFiles(dir) {
|
|
1465
|
+
const results = [];
|
|
1466
|
+
async function scan(d) {
|
|
1467
|
+
let entries;
|
|
1468
|
+
try {
|
|
1469
|
+
entries = await (0, import_promises3.readdir)(d, { withFileTypes: true });
|
|
1470
|
+
} catch {
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
for (const entry of entries) {
|
|
1474
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === "dist")
|
|
1475
|
+
continue;
|
|
1476
|
+
const fullPath = (0, import_node_path8.join)(d, entry.name);
|
|
1477
|
+
if (entry.isDirectory()) {
|
|
1478
|
+
await scan(fullPath);
|
|
1479
|
+
} else if (entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) && !entry.name.endsWith(".test.ts") && !entry.name.endsWith(".test.tsx") && !entry.name.endsWith(".spec.ts")) {
|
|
1480
|
+
results.push(fullPath);
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
await scan(dir);
|
|
1485
|
+
return results;
|
|
1486
|
+
}
|
|
1487
|
+
function computeLongestChain(file, graph, visited, memo) {
|
|
1488
|
+
if (memo.has(file)) return memo.get(file);
|
|
1489
|
+
if (visited.has(file)) return 0;
|
|
1490
|
+
visited.add(file);
|
|
1491
|
+
const deps = graph.get(file) || [];
|
|
1492
|
+
let maxDepth = 0;
|
|
1493
|
+
for (const dep of deps) {
|
|
1494
|
+
const depth = 1 + computeLongestChain(dep, graph, visited, memo);
|
|
1495
|
+
if (depth > maxDepth) maxDepth = depth;
|
|
1496
|
+
}
|
|
1497
|
+
visited.delete(file);
|
|
1498
|
+
memo.set(file, maxDepth);
|
|
1499
|
+
return maxDepth;
|
|
1500
|
+
}
|
|
1501
|
+
var DepDepthCollector = class {
|
|
1502
|
+
category = "dependency-depth";
|
|
1503
|
+
getRules(config, _rootDir) {
|
|
1504
|
+
const threshold = typeof config.thresholds["dependency-depth"] === "number" ? config.thresholds["dependency-depth"] : null;
|
|
1505
|
+
const desc = threshold !== null ? `Dependency chain depth must not exceed ${threshold}` : "Dependency chain depth must stay within thresholds";
|
|
1506
|
+
return [
|
|
1507
|
+
{
|
|
1508
|
+
id: constraintRuleId(this.category, "project", desc),
|
|
1509
|
+
category: this.category,
|
|
1510
|
+
description: desc,
|
|
1511
|
+
scope: "project"
|
|
1512
|
+
}
|
|
1513
|
+
];
|
|
1514
|
+
}
|
|
1515
|
+
async collect(config, rootDir) {
|
|
1516
|
+
const allFiles = await collectTsFiles(rootDir);
|
|
1517
|
+
const graph = /* @__PURE__ */ new Map();
|
|
1518
|
+
const fileSet = new Set(allFiles);
|
|
1519
|
+
for (const file of allFiles) {
|
|
1520
|
+
try {
|
|
1521
|
+
const content = await (0, import_promises3.readFile)(file, "utf-8");
|
|
1522
|
+
const imports = extractImportSources(content, file).filter((imp) => fileSet.has(imp));
|
|
1523
|
+
graph.set(file, imports);
|
|
1524
|
+
} catch {
|
|
1525
|
+
graph.set(file, []);
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
const moduleMap = /* @__PURE__ */ new Map();
|
|
1529
|
+
for (const file of allFiles) {
|
|
1530
|
+
const relDir = (0, import_node_path8.relative)(rootDir, (0, import_node_path8.dirname)(file));
|
|
1531
|
+
if (!moduleMap.has(relDir)) moduleMap.set(relDir, []);
|
|
1532
|
+
moduleMap.get(relDir).push(file);
|
|
1533
|
+
}
|
|
1534
|
+
const memo = /* @__PURE__ */ new Map();
|
|
1535
|
+
const threshold = typeof config.thresholds["dependency-depth"] === "number" ? config.thresholds["dependency-depth"] : Infinity;
|
|
1536
|
+
const results = [];
|
|
1537
|
+
for (const [modulePath, files] of moduleMap) {
|
|
1538
|
+
let longestChain = 0;
|
|
1539
|
+
for (const file of files) {
|
|
1540
|
+
const depth = computeLongestChain(file, graph, /* @__PURE__ */ new Set(), memo);
|
|
1541
|
+
if (depth > longestChain) longestChain = depth;
|
|
1542
|
+
}
|
|
1543
|
+
const violations = [];
|
|
1544
|
+
if (longestChain > threshold) {
|
|
1545
|
+
violations.push({
|
|
1546
|
+
id: violationId(modulePath, this.category, "depth-exceeded"),
|
|
1547
|
+
file: modulePath,
|
|
1548
|
+
detail: `Import chain depth is ${longestChain} (threshold: ${threshold})`,
|
|
1549
|
+
severity: "warning"
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1552
|
+
results.push({
|
|
1553
|
+
category: this.category,
|
|
1554
|
+
scope: modulePath,
|
|
1555
|
+
value: longestChain,
|
|
1556
|
+
violations,
|
|
1557
|
+
metadata: { longestChain }
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1560
|
+
return results;
|
|
1561
|
+
}
|
|
1562
|
+
};
|
|
1563
|
+
|
|
1564
|
+
// src/architecture/collectors/index.ts
|
|
1565
|
+
var defaultCollectors = [
|
|
1566
|
+
new CircularDepsCollector(),
|
|
1567
|
+
new LayerViolationCollector(),
|
|
1568
|
+
new ComplexityCollector(),
|
|
1569
|
+
new CouplingCollector(),
|
|
1570
|
+
new ForbiddenImportCollector(),
|
|
1571
|
+
new ModuleSizeCollector(),
|
|
1572
|
+
new DepDepthCollector()
|
|
1573
|
+
];
|
|
1574
|
+
async function runAll(config, rootDir, collectors = defaultCollectors) {
|
|
1575
|
+
const results = await Promise.allSettled(collectors.map((c) => c.collect(config, rootDir)));
|
|
1576
|
+
const allResults = [];
|
|
1577
|
+
for (let i = 0; i < results.length; i++) {
|
|
1578
|
+
const result = results[i];
|
|
1579
|
+
if (result.status === "fulfilled") {
|
|
1580
|
+
allResults.push(...result.value);
|
|
1581
|
+
} else {
|
|
1582
|
+
allResults.push({
|
|
1583
|
+
category: collectors[i].category,
|
|
1584
|
+
scope: "project",
|
|
1585
|
+
value: 0,
|
|
1586
|
+
violations: [],
|
|
1587
|
+
metadata: { error: String(result.reason) }
|
|
1588
|
+
});
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
return allResults;
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
// src/architecture/matchers.ts
|
|
1595
|
+
function architecture(options) {
|
|
1596
|
+
return {
|
|
1597
|
+
kind: "arch-handle",
|
|
1598
|
+
scope: "project",
|
|
1599
|
+
rootDir: options?.rootDir ?? process.cwd(),
|
|
1600
|
+
config: options?.config
|
|
1601
|
+
};
|
|
1602
|
+
}
|
|
1603
|
+
function archModule(modulePath, options) {
|
|
1604
|
+
return {
|
|
1605
|
+
kind: "arch-handle",
|
|
1606
|
+
scope: modulePath,
|
|
1607
|
+
rootDir: options?.rootDir ?? process.cwd(),
|
|
1608
|
+
config: options?.config
|
|
1609
|
+
};
|
|
1610
|
+
}
|
|
1611
|
+
function resolveConfig(handle) {
|
|
1612
|
+
return ArchConfigSchema.parse(handle.config ?? {});
|
|
1613
|
+
}
|
|
1614
|
+
async function collectCategory(handle, collector) {
|
|
1615
|
+
if ("_mockResults" in handle && handle._mockResults) {
|
|
1616
|
+
return handle._mockResults;
|
|
1617
|
+
}
|
|
1618
|
+
const config = resolveConfig(handle);
|
|
1619
|
+
return collector.collect(config, handle.rootDir);
|
|
1620
|
+
}
|
|
1621
|
+
function formatViolationList(violations, limit = 10) {
|
|
1622
|
+
const lines = violations.slice(0, limit).map((v) => ` - ${v.file}: ${v.detail}`);
|
|
1623
|
+
if (violations.length > limit) {
|
|
1624
|
+
lines.push(` ... and ${violations.length - limit} more`);
|
|
1625
|
+
}
|
|
1626
|
+
return lines.join("\n");
|
|
1627
|
+
}
|
|
1628
|
+
async function toHaveNoCircularDeps(received) {
|
|
1629
|
+
const results = await collectCategory(received, new CircularDepsCollector());
|
|
1630
|
+
const violations = results.flatMap((r) => r.violations);
|
|
1631
|
+
const pass = violations.length === 0;
|
|
1632
|
+
return {
|
|
1633
|
+
pass,
|
|
1634
|
+
message: () => pass ? "Expected circular dependencies but found none" : `Found ${violations.length} circular dependenc${violations.length === 1 ? "y" : "ies"}:
|
|
1635
|
+
${formatViolationList(violations)}`
|
|
1636
|
+
};
|
|
1637
|
+
}
|
|
1638
|
+
async function toHaveNoLayerViolations(received) {
|
|
1639
|
+
const results = await collectCategory(received, new LayerViolationCollector());
|
|
1640
|
+
const violations = results.flatMap((r) => r.violations);
|
|
1641
|
+
const pass = violations.length === 0;
|
|
1642
|
+
return {
|
|
1643
|
+
pass,
|
|
1644
|
+
message: () => pass ? "Expected layer violations but found none" : `Found ${violations.length} layer violation${violations.length === 1 ? "" : "s"}:
|
|
1645
|
+
${formatViolationList(violations)}`
|
|
1646
|
+
};
|
|
1647
|
+
}
|
|
1648
|
+
async function toMatchBaseline(received, options) {
|
|
1649
|
+
let diffResult;
|
|
1650
|
+
if ("_mockDiff" in received && received._mockDiff) {
|
|
1651
|
+
diffResult = received._mockDiff;
|
|
1652
|
+
} else {
|
|
1653
|
+
const config = resolveConfig(received);
|
|
1654
|
+
const results = await runAll(config, received.rootDir);
|
|
1655
|
+
const manager = new ArchBaselineManager(received.rootDir, config.baselinePath);
|
|
1656
|
+
const baseline = manager.load();
|
|
1657
|
+
if (!baseline) {
|
|
1658
|
+
return {
|
|
1659
|
+
pass: false,
|
|
1660
|
+
message: () => "No baseline found. Run `harness check-arch --update-baseline` to create one."
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
1663
|
+
diffResult = diff(results, baseline);
|
|
1664
|
+
}
|
|
1665
|
+
const tolerance = options?.tolerance ?? 0;
|
|
1666
|
+
const effectiveNewCount = Math.max(0, diffResult.newViolations.length - tolerance);
|
|
1667
|
+
const pass = effectiveNewCount === 0 && diffResult.regressions.length === 0;
|
|
1668
|
+
return {
|
|
1669
|
+
pass,
|
|
1670
|
+
message: () => {
|
|
1671
|
+
if (pass) {
|
|
1672
|
+
return "Expected baseline regression but architecture matches baseline";
|
|
1673
|
+
}
|
|
1674
|
+
const parts = [];
|
|
1675
|
+
if (diffResult.newViolations.length > 0) {
|
|
1676
|
+
parts.push(
|
|
1677
|
+
`${diffResult.newViolations.length} new violation${diffResult.newViolations.length === 1 ? "" : "s"}${tolerance > 0 ? ` (tolerance: ${tolerance})` : ""}:
|
|
1678
|
+
${formatViolationList(diffResult.newViolations)}`
|
|
1679
|
+
);
|
|
1680
|
+
}
|
|
1681
|
+
if (diffResult.regressions.length > 0) {
|
|
1682
|
+
const regLines = diffResult.regressions.map(
|
|
1683
|
+
(r) => ` - ${r.category}: ${r.baselineValue} -> ${r.currentValue} (+${r.delta})`
|
|
1684
|
+
);
|
|
1685
|
+
parts.push(`Regressions:
|
|
1686
|
+
${regLines.join("\n")}`);
|
|
1687
|
+
}
|
|
1688
|
+
return `Baseline check failed:
|
|
1689
|
+
${parts.join("\n\n")}`;
|
|
1690
|
+
}
|
|
1691
|
+
};
|
|
1692
|
+
}
|
|
1693
|
+
function filterByScope(results, scope) {
|
|
1694
|
+
return results.filter(
|
|
1695
|
+
(r) => r.scope === scope || r.scope.startsWith(scope + "/") || r.scope === "project"
|
|
1696
|
+
);
|
|
1697
|
+
}
|
|
1698
|
+
async function toHaveMaxComplexity(received, maxComplexity) {
|
|
1699
|
+
const results = await collectCategory(received, new ComplexityCollector());
|
|
1700
|
+
const scoped = filterByScope(results, received.scope);
|
|
1701
|
+
const violations = scoped.flatMap((r) => r.violations);
|
|
1702
|
+
const totalValue = scoped.reduce((sum, r) => sum + r.value, 0);
|
|
1703
|
+
const pass = totalValue <= maxComplexity && violations.length === 0;
|
|
1704
|
+
return {
|
|
1705
|
+
pass,
|
|
1706
|
+
message: () => pass ? `Expected complexity to exceed ${maxComplexity} but it was within limits` : `Module '${received.scope}' has complexity violations (${violations.length} violation${violations.length === 1 ? "" : "s"}):
|
|
1707
|
+
${formatViolationList(violations)}`
|
|
1708
|
+
};
|
|
1709
|
+
}
|
|
1710
|
+
async function toHaveMaxCoupling(received, limits) {
|
|
1711
|
+
const config = resolveConfig(received);
|
|
1712
|
+
if (limits.fanIn !== void 0 || limits.fanOut !== void 0) {
|
|
1713
|
+
config.thresholds.coupling = {
|
|
1714
|
+
...typeof config.thresholds.coupling === "object" ? config.thresholds.coupling : {},
|
|
1715
|
+
...limits.fanIn !== void 0 ? { maxFanIn: limits.fanIn } : {},
|
|
1716
|
+
...limits.fanOut !== void 0 ? { maxFanOut: limits.fanOut } : {}
|
|
1717
|
+
};
|
|
1718
|
+
}
|
|
1719
|
+
const collector = new CouplingCollector();
|
|
1720
|
+
const results = "_mockResults" in received && received._mockResults ? received._mockResults : await collector.collect(config, received.rootDir);
|
|
1721
|
+
const scoped = filterByScope(results, received.scope);
|
|
1722
|
+
const violations = scoped.flatMap((r) => r.violations);
|
|
1723
|
+
const pass = violations.length === 0;
|
|
1724
|
+
return {
|
|
1725
|
+
pass,
|
|
1726
|
+
message: () => pass ? `Expected coupling violations in '${received.scope}' but found none` : `Module '${received.scope}' has ${violations.length} coupling violation${violations.length === 1 ? "" : "s"} (fanIn limit: ${limits.fanIn ?? "none"}, fanOut limit: ${limits.fanOut ?? "none"}):
|
|
1727
|
+
${formatViolationList(violations)}`
|
|
1728
|
+
};
|
|
1729
|
+
}
|
|
1730
|
+
async function toHaveMaxFileCount(received, maxFiles) {
|
|
1731
|
+
const results = await collectCategory(received, new ModuleSizeCollector());
|
|
1732
|
+
const scoped = filterByScope(results, received.scope);
|
|
1733
|
+
const fileCount = scoped.reduce((max, r) => {
|
|
1734
|
+
const meta = r.metadata;
|
|
1735
|
+
const fc = typeof meta?.fileCount === "number" ? meta.fileCount : 0;
|
|
1736
|
+
return fc > max ? fc : max;
|
|
1737
|
+
}, 0);
|
|
1738
|
+
const pass = fileCount <= maxFiles;
|
|
1739
|
+
return {
|
|
1740
|
+
pass,
|
|
1741
|
+
message: () => pass ? `Expected file count in '${received.scope}' to exceed ${maxFiles} but it was ${fileCount}` : `Module '${received.scope}' has ${fileCount} files (limit: ${maxFiles})`
|
|
1742
|
+
};
|
|
1743
|
+
}
|
|
1744
|
+
async function toNotDependOn(received, forbiddenModule) {
|
|
1745
|
+
const results = await collectCategory(received, new ForbiddenImportCollector());
|
|
1746
|
+
const allViolations = results.flatMap((r) => r.violations);
|
|
1747
|
+
const scopePrefix = received.scope.replace(/\/+$/, "");
|
|
1748
|
+
const forbiddenPrefix = forbiddenModule.replace(/\/+$/, "");
|
|
1749
|
+
const relevantViolations = allViolations.filter(
|
|
1750
|
+
(v) => (v.file === scopePrefix || v.file.startsWith(scopePrefix + "/")) && (v.detail.includes(forbiddenPrefix + "/") || v.detail.endsWith(forbiddenPrefix))
|
|
1751
|
+
);
|
|
1752
|
+
const pass = relevantViolations.length === 0;
|
|
1753
|
+
return {
|
|
1754
|
+
pass,
|
|
1755
|
+
message: () => pass ? `Expected '${received.scope}' to depend on '${forbiddenModule}' but no such imports found` : `Module '${received.scope}' depends on '${forbiddenModule}' (${relevantViolations.length} import${relevantViolations.length === 1 ? "" : "s"}):
|
|
1756
|
+
${formatViolationList(relevantViolations)}`
|
|
1757
|
+
};
|
|
1758
|
+
}
|
|
1759
|
+
async function toHaveMaxDepDepth(received, maxDepth) {
|
|
1760
|
+
const results = await collectCategory(received, new DepDepthCollector());
|
|
1761
|
+
const scoped = filterByScope(results, received.scope);
|
|
1762
|
+
const maxActual = scoped.reduce((max, r) => r.value > max ? r.value : max, 0);
|
|
1763
|
+
const pass = maxActual <= maxDepth;
|
|
1764
|
+
return {
|
|
1765
|
+
pass,
|
|
1766
|
+
message: () => pass ? `Expected dependency depth in '${received.scope}' to exceed ${maxDepth} but it was ${maxActual}` : `Module '${received.scope}' has dependency depth ${maxActual} (limit: ${maxDepth})`
|
|
1767
|
+
};
|
|
1768
|
+
}
|
|
1769
|
+
var archMatchers = {
|
|
1770
|
+
toHaveNoCircularDeps,
|
|
1771
|
+
toHaveNoLayerViolations,
|
|
1772
|
+
toMatchBaseline,
|
|
1773
|
+
toHaveMaxComplexity,
|
|
1774
|
+
toHaveMaxCoupling,
|
|
1775
|
+
toHaveMaxFileCount,
|
|
1776
|
+
toNotDependOn,
|
|
1777
|
+
toHaveMaxDepDepth
|
|
1778
|
+
};
|
|
1779
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1780
|
+
0 && (module.exports = {
|
|
1781
|
+
archMatchers,
|
|
1782
|
+
archModule,
|
|
1783
|
+
architecture
|
|
1784
|
+
});
|