@harness-engineering/core 0.26.4 → 0.28.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.
@@ -0,0 +1,1171 @@
1
+ import {
2
+ Ok,
3
+ buildDependencyGraph,
4
+ detectComplexityViolations,
5
+ detectCouplingViolations,
6
+ findFiles,
7
+ getDefaultRegistry,
8
+ relativePosix,
9
+ validateDependencies
10
+ } from "./chunk-MUWJHO2S.mjs";
11
+ import {
12
+ ArchBaselineSchema,
13
+ ArchConfigSchema
14
+ } from "./chunk-IIEDD47I.mjs";
15
+
16
+ // src/architecture/collectors/hash.ts
17
+ import { createHash } from "crypto";
18
+ function violationId(relativePath, category, normalizedDetail) {
19
+ const input = `${relativePath}:${category}:${normalizedDetail}`;
20
+ return createHash("sha256").update(input).digest("hex");
21
+ }
22
+ function constraintRuleId(category, scope, description) {
23
+ const input = `${category}:${scope}:${description}`;
24
+ return createHash("sha256").update(input).digest("hex");
25
+ }
26
+
27
+ // src/constraints/circular-deps.ts
28
+ function buildAdjacencyList(graph) {
29
+ const adjacency = /* @__PURE__ */ new Map();
30
+ const nodeSet = new Set(graph.nodes);
31
+ for (const node of graph.nodes) {
32
+ adjacency.set(node, []);
33
+ }
34
+ for (const edge of graph.edges) {
35
+ const neighbors = adjacency.get(edge.from);
36
+ if (neighbors && nodeSet.has(edge.to)) {
37
+ neighbors.push(edge.to);
38
+ }
39
+ }
40
+ return adjacency;
41
+ }
42
+ function isCyclicSCC(scc, adjacency) {
43
+ if (scc.length > 1) return true;
44
+ if (scc.length === 1) {
45
+ const selfNode = scc[0];
46
+ const selfNeighbors = adjacency.get(selfNode) ?? [];
47
+ return selfNeighbors.includes(selfNode);
48
+ }
49
+ return false;
50
+ }
51
+ function processNeighbors(node, neighbors, nodeMap, stack, adjacency, sccs, indexRef) {
52
+ for (const neighbor of neighbors) {
53
+ const neighborData = nodeMap.get(neighbor);
54
+ if (!neighborData) {
55
+ strongConnectImpl(neighbor, nodeMap, stack, adjacency, sccs, indexRef);
56
+ const nodeData = nodeMap.get(node);
57
+ const updatedNeighborData = nodeMap.get(neighbor);
58
+ nodeData.lowlink = Math.min(nodeData.lowlink, updatedNeighborData.lowlink);
59
+ } else if (neighborData.onStack) {
60
+ const nodeData = nodeMap.get(node);
61
+ nodeData.lowlink = Math.min(nodeData.lowlink, neighborData.index);
62
+ }
63
+ }
64
+ }
65
+ function strongConnectImpl(node, nodeMap, stack, adjacency, sccs, indexRef) {
66
+ nodeMap.set(node, { index: indexRef.value, lowlink: indexRef.value, onStack: true });
67
+ indexRef.value++;
68
+ stack.push(node);
69
+ processNeighbors(node, adjacency.get(node) ?? [], nodeMap, stack, adjacency, sccs, indexRef);
70
+ const nodeData = nodeMap.get(node);
71
+ if (nodeData.lowlink === nodeData.index) {
72
+ const scc = [];
73
+ let w;
74
+ do {
75
+ w = stack.pop();
76
+ nodeMap.get(w).onStack = false;
77
+ scc.push(w);
78
+ } while (w !== node);
79
+ if (isCyclicSCC(scc, adjacency)) {
80
+ sccs.push(scc);
81
+ }
82
+ }
83
+ }
84
+ function tarjanSCC(graph) {
85
+ const nodeMap = /* @__PURE__ */ new Map();
86
+ const stack = [];
87
+ const sccs = [];
88
+ const indexRef = { value: 0 };
89
+ const adjacency = buildAdjacencyList(graph);
90
+ for (const node of graph.nodes) {
91
+ if (!nodeMap.has(node)) {
92
+ strongConnectImpl(node, nodeMap, stack, adjacency, sccs, indexRef);
93
+ }
94
+ }
95
+ return sccs;
96
+ }
97
+ function detectCircularDeps(graph) {
98
+ const sccs = tarjanSCC(graph);
99
+ const cycles = sccs.map((scc) => {
100
+ const reversed = scc.reverse();
101
+ const firstNode = reversed[reversed.length - 1];
102
+ const cycle = [...reversed, firstNode];
103
+ return {
104
+ cycle,
105
+ severity: "error",
106
+ size: scc.length
107
+ };
108
+ });
109
+ const largestCycle = cycles.reduce((max, c) => Math.max(max, c.size), 0);
110
+ return Ok({
111
+ hasCycles: cycles.length > 0,
112
+ cycles,
113
+ largestCycle
114
+ });
115
+ }
116
+ async function detectCircularDepsInFiles(files, parser, graphDependencyData) {
117
+ const graphResult = await buildDependencyGraph(files, parser, graphDependencyData);
118
+ if (!graphResult.ok) {
119
+ return graphResult;
120
+ }
121
+ return detectCircularDeps(graphResult.value);
122
+ }
123
+
124
+ // src/architecture/collectors/circular-deps.ts
125
+ function makeStubParser() {
126
+ return {
127
+ name: "typescript",
128
+ extensions: [".ts", ".tsx"],
129
+ parseFile: async () => ({ ok: false, error: { code: "PARSE_ERROR", message: "not needed" } }),
130
+ extractImports: () => ({ ok: false, error: { code: "EXTRACT_ERROR", message: "not needed" } }),
131
+ extractExports: () => ({ ok: false, error: { code: "EXTRACT_ERROR", message: "not needed" } }),
132
+ health: async () => ({ ok: true, value: { available: true } })
133
+ };
134
+ }
135
+ function mapCycleViolations(cycles, rootDir, category) {
136
+ return cycles.map((cycle) => {
137
+ const cyclePath = cycle.cycle.map((f) => relativePosix(rootDir, f)).join(" -> ");
138
+ const firstFile = relativePosix(rootDir, cycle.cycle[0]);
139
+ return {
140
+ id: violationId(firstFile, category, cyclePath),
141
+ file: firstFile,
142
+ detail: `Circular dependency: ${cyclePath}`,
143
+ severity: cycle.severity
144
+ };
145
+ });
146
+ }
147
+ var CircularDepsCollector = class {
148
+ category = "circular-deps";
149
+ getRules(_config, _rootDir) {
150
+ const description = "No circular dependencies allowed";
151
+ return [
152
+ {
153
+ id: constraintRuleId(this.category, "project", description),
154
+ category: this.category,
155
+ description,
156
+ scope: "project"
157
+ }
158
+ ];
159
+ }
160
+ async collect(_config, rootDir) {
161
+ const files = await findFiles("**/*.ts", rootDir);
162
+ const graphResult = await buildDependencyGraph(files, makeStubParser());
163
+ if (!graphResult.ok) {
164
+ return [
165
+ {
166
+ category: this.category,
167
+ scope: "project",
168
+ value: 0,
169
+ violations: [],
170
+ metadata: { error: "Failed to build dependency graph" }
171
+ }
172
+ ];
173
+ }
174
+ const result = detectCircularDeps(graphResult.value);
175
+ if (!result.ok) {
176
+ return [
177
+ {
178
+ category: this.category,
179
+ scope: "project",
180
+ value: 0,
181
+ violations: [],
182
+ metadata: { error: "Failed to detect circular deps" }
183
+ }
184
+ ];
185
+ }
186
+ const { cycles, largestCycle } = result.value;
187
+ const violations = mapCycleViolations(cycles, rootDir, this.category);
188
+ return [
189
+ {
190
+ category: this.category,
191
+ scope: "project",
192
+ value: cycles.length,
193
+ violations,
194
+ metadata: { largestCycle, cycleCount: cycles.length }
195
+ }
196
+ ];
197
+ }
198
+ };
199
+
200
+ // src/architecture/collectors/layer-violations.ts
201
+ function mapLayerViolations(layerViolations, rootDir, category) {
202
+ return layerViolations.map((v) => {
203
+ const relFile = relativePosix(rootDir, v.file);
204
+ const relImport = relativePosix(rootDir, v.imports);
205
+ const detail = `${v.fromLayer} -> ${v.toLayer}: ${relFile} imports ${relImport}`;
206
+ return {
207
+ id: violationId(relFile, category ?? "", detail),
208
+ file: relFile,
209
+ category,
210
+ detail,
211
+ severity: "error"
212
+ };
213
+ });
214
+ }
215
+ var LayerViolationCollector = class {
216
+ category = "layer-violations";
217
+ getRules(_config, _rootDir) {
218
+ const description = "No layer boundary violations allowed";
219
+ return [
220
+ {
221
+ id: constraintRuleId(this.category, "project", description),
222
+ category: this.category,
223
+ description,
224
+ scope: "project"
225
+ }
226
+ ];
227
+ }
228
+ async collect(_config, rootDir) {
229
+ const registry = getDefaultRegistry();
230
+ const parser = registry.getByLanguage("typescript") ?? registry.getByLanguage("javascript");
231
+ const result = await validateDependencies({
232
+ layers: [],
233
+ rootDir,
234
+ parser,
235
+ fallbackBehavior: "skip"
236
+ });
237
+ if (!result.ok) {
238
+ return [
239
+ {
240
+ category: this.category,
241
+ scope: "project",
242
+ value: 0,
243
+ violations: [],
244
+ metadata: { error: "Failed to validate dependencies" }
245
+ }
246
+ ];
247
+ }
248
+ const violations = mapLayerViolations(
249
+ result.value.violations.filter((v) => v.reason === "WRONG_LAYER"),
250
+ rootDir,
251
+ this.category
252
+ );
253
+ return [{ category: this.category, scope: "project", value: violations.length, violations }];
254
+ }
255
+ };
256
+
257
+ // src/architecture/baseline-manager.ts
258
+ import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from "fs";
259
+ import { randomBytes } from "crypto";
260
+ import { join, dirname } from "path";
261
+ var ArchBaselineManager = class {
262
+ baselinesPath;
263
+ constructor(projectRoot, baselinePath) {
264
+ this.baselinesPath = baselinePath ? join(projectRoot, baselinePath) : join(projectRoot, ".harness", "arch", "baselines.json");
265
+ }
266
+ /**
267
+ * Snapshot the current metric results into an ArchBaseline.
268
+ * Aggregates multiple MetricResults for the same category by summing values
269
+ * and concatenating violation IDs.
270
+ */
271
+ capture(results, commitHash) {
272
+ const metrics = {};
273
+ for (const result of results) {
274
+ const existing = metrics[result.category];
275
+ if (existing) {
276
+ existing.value += result.value;
277
+ existing.violationIds.push(...result.violations.map((v) => v.id));
278
+ } else {
279
+ metrics[result.category] = {
280
+ value: result.value,
281
+ violationIds: result.violations.map((v) => v.id)
282
+ };
283
+ }
284
+ }
285
+ for (const baseline of Object.values(metrics)) {
286
+ baseline.violationIds = [...new Set(baseline.violationIds)];
287
+ }
288
+ return {
289
+ version: 1,
290
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
291
+ updatedFrom: commitHash,
292
+ metrics
293
+ };
294
+ }
295
+ /**
296
+ * Load the baselines file from disk.
297
+ * Returns null if the file does not exist, contains invalid JSON,
298
+ * or fails ArchBaselineSchema validation.
299
+ */
300
+ load() {
301
+ if (!existsSync(this.baselinesPath)) {
302
+ console.error(`Baseline file not found at: ${this.baselinesPath}`);
303
+ return null;
304
+ }
305
+ try {
306
+ const raw = readFileSync(this.baselinesPath, "utf-8");
307
+ const data = JSON.parse(raw);
308
+ const parsed = ArchBaselineSchema.safeParse(data);
309
+ if (!parsed.success) {
310
+ console.error(
311
+ `Baseline validation failed for ${this.baselinesPath}:`,
312
+ parsed.error.format()
313
+ );
314
+ return null;
315
+ }
316
+ return parsed.data;
317
+ } catch (error) {
318
+ console.error(`Error loading baseline from ${this.baselinesPath}:`, error);
319
+ return null;
320
+ }
321
+ }
322
+ /**
323
+ * Refresh the on-disk baseline with new results.
324
+ *
325
+ * Categories present in `results` overwrite their on-disk entry; categories
326
+ * absent from `results` are preserved as-is. This prevents silent data loss
327
+ * when a collector returns no results (e.g. transient failure or a filtered
328
+ * run) and the regenerated file is committed (issue #268).
329
+ *
330
+ * Use this from the `--update-baseline` flow instead of `capture()` + `save()`.
331
+ */
332
+ update(results, commitHash) {
333
+ const fresh = this.capture(results, commitHash);
334
+ const existing = this.load();
335
+ if (existing) {
336
+ fresh.metrics = { ...existing.metrics, ...fresh.metrics };
337
+ }
338
+ this.save(fresh);
339
+ return fresh;
340
+ }
341
+ /**
342
+ * Save an ArchBaseline to disk.
343
+ * Creates parent directories if they do not exist.
344
+ * Uses atomic write (write to temp file, then rename) to prevent corruption.
345
+ */
346
+ save(baseline) {
347
+ const dir = dirname(this.baselinesPath);
348
+ if (!existsSync(dir)) {
349
+ mkdirSync(dir, { recursive: true });
350
+ }
351
+ const tmp = this.baselinesPath + "." + randomBytes(4).toString("hex") + ".tmp";
352
+ writeFileSync(tmp, JSON.stringify(baseline, null, 2));
353
+ renameSync(tmp, this.baselinesPath);
354
+ }
355
+ };
356
+
357
+ // src/architecture/diff.ts
358
+ function aggregateByCategory(results) {
359
+ const map = /* @__PURE__ */ new Map();
360
+ for (const result of results) {
361
+ const existing = map.get(result.category);
362
+ if (existing) {
363
+ existing.value += result.value;
364
+ existing.violations.push(...result.violations);
365
+ } else {
366
+ map.set(result.category, {
367
+ value: result.value,
368
+ violations: [...result.violations]
369
+ });
370
+ }
371
+ }
372
+ return map;
373
+ }
374
+ function classifyViolations(violations, baselineViolationIds) {
375
+ const newViolations = [];
376
+ const preExisting = [];
377
+ for (const violation of violations) {
378
+ if (baselineViolationIds.has(violation.id)) {
379
+ preExisting.push(violation.id);
380
+ } else {
381
+ newViolations.push(violation);
382
+ }
383
+ }
384
+ return { newViolations, preExisting };
385
+ }
386
+ function findResolvedViolations(baselineCategory, currentViolationIds) {
387
+ if (!baselineCategory) return [];
388
+ return baselineCategory.violationIds.filter((id) => !currentViolationIds.has(id));
389
+ }
390
+ function collectOrphanedBaselineViolations(baseline, visitedCategories) {
391
+ const resolved = [];
392
+ for (const [category, baselineCategory] of Object.entries(baseline.metrics)) {
393
+ if (!visitedCategories.has(category) && baselineCategory) {
394
+ resolved.push(...baselineCategory.violationIds);
395
+ }
396
+ }
397
+ return resolved;
398
+ }
399
+ function diffCategory(category, agg, baselineCategory, acc) {
400
+ const baselineViolationIds = new Set(baselineCategory?.violationIds ?? []);
401
+ const baselineValue = baselineCategory?.value ?? 0;
402
+ const classified = classifyViolations(agg.violations, baselineViolationIds);
403
+ acc.newViolations.push(...classified.newViolations);
404
+ acc.preExisting.push(...classified.preExisting);
405
+ const currentViolationIds = new Set(agg.violations.map((v) => v.id));
406
+ acc.resolvedViolations.push(...findResolvedViolations(baselineCategory, currentViolationIds));
407
+ if (baselineCategory && agg.value > baselineValue) {
408
+ acc.regressions.push({
409
+ category,
410
+ baselineValue,
411
+ currentValue: agg.value,
412
+ delta: agg.value - baselineValue
413
+ });
414
+ }
415
+ }
416
+ function diff(current, baseline) {
417
+ const aggregated = aggregateByCategory(current);
418
+ const acc = {
419
+ newViolations: [],
420
+ resolvedViolations: [],
421
+ preExisting: [],
422
+ regressions: []
423
+ };
424
+ const visitedCategories = /* @__PURE__ */ new Set();
425
+ for (const [category, agg] of aggregated) {
426
+ visitedCategories.add(category);
427
+ diffCategory(category, agg, baseline.metrics[category], acc);
428
+ }
429
+ acc.resolvedViolations.push(...collectOrphanedBaselineViolations(baseline, visitedCategories));
430
+ return {
431
+ passed: acc.newViolations.length === 0 && acc.regressions.length === 0,
432
+ newViolations: acc.newViolations,
433
+ resolvedViolations: acc.resolvedViolations,
434
+ preExisting: acc.preExisting,
435
+ regressions: acc.regressions
436
+ };
437
+ }
438
+
439
+ // src/architecture/collectors/complexity.ts
440
+ function buildSnapshot(files, rootDir) {
441
+ return {
442
+ files: files.map((f) => ({
443
+ path: f,
444
+ ast: { type: "Program", body: null, language: "typescript" },
445
+ imports: [],
446
+ exports: [],
447
+ internalSymbols: [],
448
+ jsDocComments: []
449
+ })),
450
+ dependencyGraph: { nodes: [], edges: [] },
451
+ exportMap: { byFile: /* @__PURE__ */ new Map(), byName: /* @__PURE__ */ new Map() },
452
+ docs: [],
453
+ codeReferences: [],
454
+ entryPoints: [],
455
+ rootDir,
456
+ config: { rootDir, analyze: {} },
457
+ buildTime: 0
458
+ };
459
+ }
460
+ function resolveMaxComplexity(config) {
461
+ const threshold = config.thresholds.complexity;
462
+ return typeof threshold === "number" ? threshold : threshold?.max ?? 15;
463
+ }
464
+ function mapComplexityViolations(complexityViolations, rootDir, category) {
465
+ return complexityViolations.filter((v) => v.severity === "error" || v.severity === "warning").map((v) => {
466
+ const relFile = relativePosix(rootDir, v.file);
467
+ return {
468
+ id: violationId(relFile, category ?? "", `${v.metric}:${v.function}`),
469
+ file: relFile,
470
+ category,
471
+ detail: `${v.metric}=${v.value} in ${v.function} (threshold: ${v.threshold})`,
472
+ severity: v.severity
473
+ };
474
+ });
475
+ }
476
+ var ComplexityCollector = class {
477
+ category = "complexity";
478
+ getRules(_config, _rootDir) {
479
+ const description = "Cyclomatic complexity must stay within thresholds";
480
+ return [
481
+ {
482
+ id: constraintRuleId(this.category, "project", description),
483
+ category: this.category,
484
+ description,
485
+ scope: "project"
486
+ }
487
+ ];
488
+ }
489
+ async collect(_config, rootDir) {
490
+ const files = await findFiles("**/*.ts", rootDir);
491
+ const snapshot = buildSnapshot(files, rootDir);
492
+ const maxComplexity = resolveMaxComplexity(_config);
493
+ const complexityConfig = {
494
+ thresholds: {
495
+ cyclomaticComplexity: { error: maxComplexity, warn: Math.floor(maxComplexity * 0.7) }
496
+ }
497
+ };
498
+ const result = await detectComplexityViolations(snapshot, complexityConfig);
499
+ if (!result.ok) {
500
+ return [
501
+ {
502
+ category: this.category,
503
+ scope: "project",
504
+ value: 0,
505
+ violations: [],
506
+ metadata: { error: "Failed to detect complexity violations" }
507
+ }
508
+ ];
509
+ }
510
+ const { violations: complexityViolations, stats } = result.value;
511
+ const violations = mapComplexityViolations(complexityViolations, rootDir, this.category);
512
+ return [
513
+ {
514
+ category: this.category,
515
+ scope: "project",
516
+ value: violations.length,
517
+ violations,
518
+ metadata: {
519
+ filesAnalyzed: stats.filesAnalyzed,
520
+ functionsAnalyzed: stats.functionsAnalyzed
521
+ }
522
+ }
523
+ ];
524
+ }
525
+ };
526
+
527
+ // src/architecture/collectors/coupling.ts
528
+ function buildCouplingSnapshot(files, rootDir) {
529
+ return {
530
+ files: files.map((f) => ({
531
+ path: f,
532
+ ast: { type: "Program", body: null, language: "typescript" },
533
+ imports: [],
534
+ exports: [],
535
+ internalSymbols: [],
536
+ jsDocComments: []
537
+ })),
538
+ dependencyGraph: { nodes: [], edges: [] },
539
+ exportMap: { byFile: /* @__PURE__ */ new Map(), byName: /* @__PURE__ */ new Map() },
540
+ docs: [],
541
+ codeReferences: [],
542
+ entryPoints: [],
543
+ rootDir,
544
+ config: { rootDir, analyze: {} },
545
+ buildTime: 0
546
+ };
547
+ }
548
+ function mapCouplingViolations(couplingViolations, rootDir, category) {
549
+ return couplingViolations.filter((v) => v.severity === "error" || v.severity === "warning").map((v) => {
550
+ const relFile = relativePosix(rootDir, v.file);
551
+ return {
552
+ id: violationId(relFile, category ?? "", v.metric),
553
+ file: relFile,
554
+ category,
555
+ detail: `${v.metric}=${v.value} (threshold: ${v.threshold})`,
556
+ severity: v.severity
557
+ };
558
+ });
559
+ }
560
+ var CouplingCollector = class {
561
+ category = "coupling";
562
+ getRules(_config, _rootDir) {
563
+ const description = "Coupling metrics must stay within thresholds";
564
+ return [
565
+ {
566
+ id: constraintRuleId(this.category, "project", description),
567
+ category: this.category,
568
+ description,
569
+ scope: "project"
570
+ }
571
+ ];
572
+ }
573
+ async collect(_config, rootDir) {
574
+ const files = await findFiles("**/*.ts", rootDir);
575
+ const snapshot = buildCouplingSnapshot(files, rootDir);
576
+ const result = await detectCouplingViolations(snapshot);
577
+ if (!result.ok) {
578
+ return [
579
+ {
580
+ category: this.category,
581
+ scope: "project",
582
+ value: 0,
583
+ violations: [],
584
+ metadata: { error: "Failed to detect coupling violations" }
585
+ }
586
+ ];
587
+ }
588
+ const { violations: couplingViolations, stats } = result.value;
589
+ const violations = mapCouplingViolations(couplingViolations, rootDir, this.category);
590
+ return [
591
+ {
592
+ category: this.category,
593
+ scope: "project",
594
+ value: violations.length,
595
+ violations,
596
+ metadata: { filesAnalyzed: stats.filesAnalyzed }
597
+ }
598
+ ];
599
+ }
600
+ };
601
+
602
+ // src/architecture/collectors/forbidden-imports.ts
603
+ function mapForbiddenImportViolations(forbidden, rootDir, category) {
604
+ return forbidden.map((v) => {
605
+ const relFile = relativePosix(rootDir, v.file);
606
+ const relImport = relativePosix(rootDir, v.imports);
607
+ const detail = `forbidden import: ${relFile} -> ${relImport}`;
608
+ return {
609
+ id: violationId(relFile, category ?? "", detail),
610
+ file: relFile,
611
+ category,
612
+ detail,
613
+ severity: "error"
614
+ };
615
+ });
616
+ }
617
+ var ForbiddenImportCollector = class {
618
+ category = "forbidden-imports";
619
+ getRules(_config, _rootDir) {
620
+ const description = "No forbidden imports allowed";
621
+ return [
622
+ {
623
+ id: constraintRuleId(this.category, "project", description),
624
+ category: this.category,
625
+ description,
626
+ scope: "project"
627
+ }
628
+ ];
629
+ }
630
+ async collect(_config, rootDir) {
631
+ const registry = getDefaultRegistry();
632
+ const parser = registry.getByLanguage("typescript") ?? registry.getByLanguage("javascript");
633
+ const result = await validateDependencies({
634
+ layers: [],
635
+ rootDir,
636
+ parser,
637
+ fallbackBehavior: "skip"
638
+ });
639
+ if (!result.ok) {
640
+ return [
641
+ {
642
+ category: this.category,
643
+ scope: "project",
644
+ value: 0,
645
+ violations: [],
646
+ metadata: { error: "Failed to validate dependencies" }
647
+ }
648
+ ];
649
+ }
650
+ const violations = mapForbiddenImportViolations(
651
+ result.value.violations.filter((v) => v.reason === "FORBIDDEN_IMPORT"),
652
+ rootDir,
653
+ this.category
654
+ );
655
+ return [{ category: this.category, scope: "project", value: violations.length, violations }];
656
+ }
657
+ };
658
+
659
+ // src/architecture/collectors/module-size.ts
660
+ import { readFile, readdir } from "fs/promises";
661
+ import { join as join2 } from "path";
662
+ import { DEFAULT_SKIP_DIRS } from "@harness-engineering/graph";
663
+ function isSkippedEntry(name) {
664
+ return name.startsWith(".") || DEFAULT_SKIP_DIRS.has(name);
665
+ }
666
+ function isTsSourceFile(name) {
667
+ if (!name.endsWith(".ts") && !name.endsWith(".tsx")) return false;
668
+ if (name.endsWith(".test.ts") || name.endsWith(".test.tsx") || name.endsWith(".spec.ts"))
669
+ return false;
670
+ return true;
671
+ }
672
+ async function countLoc(filePath) {
673
+ try {
674
+ const content = await readFile(filePath, "utf-8");
675
+ return content.split("\n").filter((line) => line.trim().length > 0).length;
676
+ } catch {
677
+ return 0;
678
+ }
679
+ }
680
+ async function buildModuleStats(rootDir, dir, tsFiles) {
681
+ let totalLoc = 0;
682
+ for (const f of tsFiles) {
683
+ totalLoc += await countLoc(f);
684
+ }
685
+ return {
686
+ modulePath: relativePosix(rootDir, dir),
687
+ fileCount: tsFiles.length,
688
+ totalLoc,
689
+ files: tsFiles.map((f) => relativePosix(rootDir, f))
690
+ };
691
+ }
692
+ async function scanDir(rootDir, dir, modules) {
693
+ let entries;
694
+ try {
695
+ entries = await readdir(dir, { withFileTypes: true });
696
+ } catch {
697
+ return;
698
+ }
699
+ const tsFiles = [];
700
+ const subdirs = [];
701
+ for (const entry of entries) {
702
+ if (isSkippedEntry(entry.name)) continue;
703
+ const fullPath = join2(dir, entry.name);
704
+ if (entry.isDirectory()) {
705
+ subdirs.push(fullPath);
706
+ continue;
707
+ }
708
+ if (entry.isFile() && isTsSourceFile(entry.name)) {
709
+ tsFiles.push(fullPath);
710
+ }
711
+ }
712
+ if (tsFiles.length > 0) {
713
+ modules.push(await buildModuleStats(rootDir, dir, tsFiles));
714
+ }
715
+ for (const sub of subdirs) {
716
+ await scanDir(rootDir, sub, modules);
717
+ }
718
+ }
719
+ async function discoverModules(rootDir) {
720
+ const modules = [];
721
+ await scanDir(rootDir, rootDir, modules);
722
+ return modules;
723
+ }
724
+ function extractThresholds(config) {
725
+ const thresholds = config.thresholds["module-size"];
726
+ let maxLoc = Infinity;
727
+ let maxFiles = Infinity;
728
+ if (typeof thresholds === "object" && thresholds !== null) {
729
+ const t = thresholds;
730
+ if (t.maxLoc !== void 0) maxLoc = t.maxLoc;
731
+ if (t.maxFiles !== void 0) maxFiles = t.maxFiles;
732
+ }
733
+ return { maxLoc, maxFiles };
734
+ }
735
+ var ModuleSizeCollector = class {
736
+ category = "module-size";
737
+ getRules(config, _rootDir) {
738
+ const { maxLoc, maxFiles } = extractThresholds(config);
739
+ const rules = [];
740
+ if (maxLoc < Infinity) {
741
+ const desc = `Module LOC must not exceed ${maxLoc}`;
742
+ rules.push({
743
+ id: constraintRuleId(this.category, "project", desc),
744
+ category: this.category,
745
+ description: desc,
746
+ scope: "project"
747
+ });
748
+ }
749
+ if (maxFiles < Infinity) {
750
+ const desc = `Module file count must not exceed ${maxFiles}`;
751
+ rules.push({
752
+ id: constraintRuleId(this.category, "project", desc),
753
+ category: this.category,
754
+ description: desc,
755
+ scope: "project"
756
+ });
757
+ }
758
+ if (rules.length === 0) {
759
+ const desc = "Module size must stay within thresholds";
760
+ rules.push({
761
+ id: constraintRuleId(this.category, "project", desc),
762
+ category: this.category,
763
+ description: desc,
764
+ scope: "project"
765
+ });
766
+ }
767
+ return rules;
768
+ }
769
+ async collect(config, rootDir) {
770
+ const modules = await discoverModules(rootDir);
771
+ const { maxLoc, maxFiles } = extractThresholds(config);
772
+ return modules.map((mod) => {
773
+ const violations = [];
774
+ if (mod.totalLoc > maxLoc) {
775
+ violations.push({
776
+ id: violationId(mod.modulePath, this.category, "totalLoc-exceeded"),
777
+ file: mod.modulePath,
778
+ detail: `Module has ${mod.totalLoc} lines of code (threshold: ${maxLoc})`,
779
+ severity: "warning"
780
+ });
781
+ }
782
+ if (mod.fileCount > maxFiles) {
783
+ violations.push({
784
+ id: violationId(mod.modulePath, this.category, "fileCount-exceeded"),
785
+ file: mod.modulePath,
786
+ detail: `Module has ${mod.fileCount} files (threshold: ${maxFiles})`,
787
+ severity: "warning"
788
+ });
789
+ }
790
+ return {
791
+ category: this.category,
792
+ scope: mod.modulePath,
793
+ value: mod.totalLoc,
794
+ violations,
795
+ metadata: { fileCount: mod.fileCount, totalLoc: mod.totalLoc }
796
+ };
797
+ });
798
+ }
799
+ };
800
+
801
+ // src/architecture/collectors/dep-depth.ts
802
+ import { readFile as readFile2, readdir as readdir2 } from "fs/promises";
803
+ import { join as join3, dirname as dirname2, resolve } from "path";
804
+ import { DEFAULT_SKIP_DIRS as DEFAULT_SKIP_DIRS2 } from "@harness-engineering/graph";
805
+ function extractImportSources(content, filePath) {
806
+ const importRegex = /(?:import|export)\s+.*?from\s+['"](\.[^'"]+)['"]/g;
807
+ const dynamicRegex = /import\s*\(\s*['"](\.[^'"]+)['"]\s*\)/g;
808
+ const sources = [];
809
+ const dir = dirname2(filePath);
810
+ for (const regex of [importRegex, dynamicRegex]) {
811
+ let match;
812
+ while ((match = regex.exec(content)) !== null) {
813
+ let resolved = resolve(dir, match[1]);
814
+ if (!resolved.endsWith(".ts") && !resolved.endsWith(".tsx")) {
815
+ resolved += ".ts";
816
+ }
817
+ sources.push(resolved);
818
+ }
819
+ }
820
+ return sources;
821
+ }
822
+ function isSkippedEntry2(name) {
823
+ return name.startsWith(".") || DEFAULT_SKIP_DIRS2.has(name);
824
+ }
825
+ function isTsSourceFile2(name) {
826
+ if (!name.endsWith(".ts") && !name.endsWith(".tsx")) return false;
827
+ return !name.endsWith(".test.ts") && !name.endsWith(".test.tsx") && !name.endsWith(".spec.ts");
828
+ }
829
+ async function scanDir2(d, results) {
830
+ let entries;
831
+ try {
832
+ entries = await readdir2(d, { withFileTypes: true });
833
+ } catch {
834
+ return;
835
+ }
836
+ for (const entry of entries) {
837
+ if (isSkippedEntry2(entry.name)) continue;
838
+ const fullPath = join3(d, entry.name);
839
+ if (entry.isDirectory()) {
840
+ await scanDir2(fullPath, results);
841
+ } else if (entry.isFile() && isTsSourceFile2(entry.name)) {
842
+ results.push(fullPath);
843
+ }
844
+ }
845
+ }
846
+ async function collectTsFiles(dir) {
847
+ const results = [];
848
+ await scanDir2(dir, results);
849
+ return results;
850
+ }
851
+ function computeLongestChain(file, graph, visited, memo) {
852
+ if (memo.has(file)) return memo.get(file);
853
+ if (visited.has(file)) return 0;
854
+ visited.add(file);
855
+ const deps = graph.get(file) || [];
856
+ let maxDepth = 0;
857
+ for (const dep of deps) {
858
+ const depth = 1 + computeLongestChain(dep, graph, visited, memo);
859
+ if (depth > maxDepth) maxDepth = depth;
860
+ }
861
+ visited.delete(file);
862
+ memo.set(file, maxDepth);
863
+ return maxDepth;
864
+ }
865
+ var DepDepthCollector = class {
866
+ category = "dependency-depth";
867
+ getRules(config, _rootDir) {
868
+ const threshold = typeof config.thresholds["dependency-depth"] === "number" ? config.thresholds["dependency-depth"] : null;
869
+ const desc = threshold !== null ? `Dependency chain depth must not exceed ${threshold}` : "Dependency chain depth must stay within thresholds";
870
+ return [
871
+ {
872
+ id: constraintRuleId(this.category, "project", desc),
873
+ category: this.category,
874
+ description: desc,
875
+ scope: "project"
876
+ }
877
+ ];
878
+ }
879
+ async buildImportGraph(allFiles) {
880
+ const graph = /* @__PURE__ */ new Map();
881
+ const fileSet = new Set(allFiles);
882
+ for (const file of allFiles) {
883
+ try {
884
+ const content = await readFile2(file, "utf-8");
885
+ graph.set(
886
+ file,
887
+ extractImportSources(content, file).filter((imp) => fileSet.has(imp))
888
+ );
889
+ } catch {
890
+ graph.set(file, []);
891
+ }
892
+ }
893
+ return graph;
894
+ }
895
+ buildModuleMap(allFiles, rootDir) {
896
+ const moduleMap = /* @__PURE__ */ new Map();
897
+ for (const file of allFiles) {
898
+ const relDir = relativePosix(rootDir, dirname2(file));
899
+ if (!moduleMap.has(relDir)) moduleMap.set(relDir, []);
900
+ moduleMap.get(relDir).push(file);
901
+ }
902
+ return moduleMap;
903
+ }
904
+ async collect(config, rootDir) {
905
+ const allFiles = await collectTsFiles(rootDir);
906
+ const graph = await this.buildImportGraph(allFiles);
907
+ const moduleMap = this.buildModuleMap(allFiles, rootDir);
908
+ const memo = /* @__PURE__ */ new Map();
909
+ const threshold = typeof config.thresholds["dependency-depth"] === "number" ? config.thresholds["dependency-depth"] : Infinity;
910
+ const results = [];
911
+ for (const [modulePath, files] of moduleMap) {
912
+ const longestChain = files.reduce((max, file) => {
913
+ return Math.max(max, computeLongestChain(file, graph, /* @__PURE__ */ new Set(), memo));
914
+ }, 0);
915
+ const violations = [];
916
+ if (longestChain > threshold) {
917
+ violations.push({
918
+ id: violationId(modulePath, this.category, "depth-exceeded"),
919
+ file: modulePath,
920
+ detail: `Import chain depth is ${longestChain} (threshold: ${threshold})`,
921
+ severity: "warning"
922
+ });
923
+ }
924
+ results.push({
925
+ category: this.category,
926
+ scope: modulePath,
927
+ value: longestChain,
928
+ violations,
929
+ metadata: { longestChain }
930
+ });
931
+ }
932
+ return results;
933
+ }
934
+ };
935
+
936
+ // src/architecture/collectors/index.ts
937
+ var defaultCollectors = [
938
+ new CircularDepsCollector(),
939
+ new LayerViolationCollector(),
940
+ new ComplexityCollector(),
941
+ new CouplingCollector(),
942
+ new ForbiddenImportCollector(),
943
+ new ModuleSizeCollector(),
944
+ new DepDepthCollector()
945
+ ];
946
+ async function runAll(config, rootDir, collectors = defaultCollectors) {
947
+ const results = await Promise.allSettled(collectors.map((c) => c.collect(config, rootDir)));
948
+ const allResults = [];
949
+ for (let i = 0; i < results.length; i++) {
950
+ const result = results[i];
951
+ if (result.status === "fulfilled") {
952
+ allResults.push(...result.value);
953
+ } else {
954
+ allResults.push({
955
+ category: collectors[i].category,
956
+ scope: "project",
957
+ value: 0,
958
+ violations: [],
959
+ metadata: { error: String(result.reason) }
960
+ });
961
+ }
962
+ }
963
+ return allResults;
964
+ }
965
+
966
+ // src/architecture/matchers.ts
967
+ function architecture(options) {
968
+ return {
969
+ kind: "arch-handle",
970
+ scope: "project",
971
+ rootDir: options?.rootDir ?? process.cwd(),
972
+ config: options?.config
973
+ };
974
+ }
975
+ function archModule(modulePath, options) {
976
+ return {
977
+ kind: "arch-handle",
978
+ scope: modulePath,
979
+ rootDir: options?.rootDir ?? process.cwd(),
980
+ config: options?.config
981
+ };
982
+ }
983
+ function resolveConfig(handle) {
984
+ return ArchConfigSchema.parse(handle.config ?? {});
985
+ }
986
+ async function collectCategory(handle, collector) {
987
+ if ("_mockResults" in handle && handle._mockResults) {
988
+ return handle._mockResults;
989
+ }
990
+ const config = resolveConfig(handle);
991
+ return collector.collect(config, handle.rootDir);
992
+ }
993
+ function formatViolationList(violations, limit = 10) {
994
+ const lines = violations.slice(0, limit).map((v) => ` - ${v.file}: ${v.detail}`);
995
+ if (violations.length > limit) {
996
+ lines.push(` ... and ${violations.length - limit} more`);
997
+ }
998
+ return lines.join("\n");
999
+ }
1000
+ async function toHaveNoCircularDeps(received) {
1001
+ const results = await collectCategory(received, new CircularDepsCollector());
1002
+ const violations = results.flatMap((r) => r.violations);
1003
+ const pass = violations.length === 0;
1004
+ return {
1005
+ pass,
1006
+ message: () => pass ? "Expected circular dependencies but found none" : `Found ${violations.length} circular dependenc${violations.length === 1 ? "y" : "ies"}:
1007
+ ${formatViolationList(violations)}`
1008
+ };
1009
+ }
1010
+ async function toHaveNoLayerViolations(received) {
1011
+ const results = await collectCategory(received, new LayerViolationCollector());
1012
+ const violations = results.flatMap((r) => r.violations);
1013
+ const pass = violations.length === 0;
1014
+ return {
1015
+ pass,
1016
+ message: () => pass ? "Expected layer violations but found none" : `Found ${violations.length} layer violation${violations.length === 1 ? "" : "s"}:
1017
+ ${formatViolationList(violations)}`
1018
+ };
1019
+ }
1020
+ async function toMatchBaseline(received, options) {
1021
+ let diffResult;
1022
+ if ("_mockDiff" in received && received._mockDiff) {
1023
+ diffResult = received._mockDiff;
1024
+ } else {
1025
+ const config = resolveConfig(received);
1026
+ const results = await runAll(config, received.rootDir);
1027
+ const manager = new ArchBaselineManager(received.rootDir, config.baselinePath);
1028
+ const baseline = manager.load();
1029
+ if (!baseline) {
1030
+ return {
1031
+ pass: false,
1032
+ message: () => "No baseline found. Run `harness check-arch --update-baseline` to create one."
1033
+ };
1034
+ }
1035
+ diffResult = diff(results, baseline);
1036
+ }
1037
+ const tolerance = options?.tolerance ?? 0;
1038
+ const effectiveNewCount = Math.max(0, diffResult.newViolations.length - tolerance);
1039
+ const pass = effectiveNewCount === 0 && diffResult.regressions.length === 0;
1040
+ return {
1041
+ pass,
1042
+ message: () => {
1043
+ if (pass) {
1044
+ return "Expected baseline regression but architecture matches baseline";
1045
+ }
1046
+ const parts = [];
1047
+ if (diffResult.newViolations.length > 0) {
1048
+ parts.push(
1049
+ `${diffResult.newViolations.length} new violation${diffResult.newViolations.length === 1 ? "" : "s"}${tolerance > 0 ? ` (tolerance: ${tolerance})` : ""}:
1050
+ ${formatViolationList(diffResult.newViolations)}`
1051
+ );
1052
+ }
1053
+ if (diffResult.regressions.length > 0) {
1054
+ const regLines = diffResult.regressions.map(
1055
+ (r) => ` - ${r.category}: ${r.baselineValue} -> ${r.currentValue} (+${r.delta})`
1056
+ );
1057
+ parts.push(`Regressions:
1058
+ ${regLines.join("\n")}`);
1059
+ }
1060
+ return `Baseline check failed:
1061
+ ${parts.join("\n\n")}`;
1062
+ }
1063
+ };
1064
+ }
1065
+ function filterByScope(results, scope) {
1066
+ return results.filter(
1067
+ (r) => r.scope === scope || r.scope.startsWith(scope + "/") || r.scope === "project"
1068
+ );
1069
+ }
1070
+ async function toHaveMaxComplexity(received, maxComplexity) {
1071
+ const results = await collectCategory(received, new ComplexityCollector());
1072
+ const scoped = filterByScope(results, received.scope);
1073
+ const violations = scoped.flatMap((r) => r.violations);
1074
+ const totalValue = scoped.reduce((sum, r) => sum + r.value, 0);
1075
+ const pass = totalValue <= maxComplexity && violations.length === 0;
1076
+ return {
1077
+ pass,
1078
+ 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"}):
1079
+ ${formatViolationList(violations)}`
1080
+ };
1081
+ }
1082
+ async function toHaveMaxCoupling(received, limits) {
1083
+ const config = resolveConfig(received);
1084
+ if (limits.fanIn !== void 0 || limits.fanOut !== void 0) {
1085
+ config.thresholds.coupling = {
1086
+ ...typeof config.thresholds.coupling === "object" ? config.thresholds.coupling : {},
1087
+ ...limits.fanIn !== void 0 ? { maxFanIn: limits.fanIn } : {},
1088
+ ...limits.fanOut !== void 0 ? { maxFanOut: limits.fanOut } : {}
1089
+ };
1090
+ }
1091
+ const collector = new CouplingCollector();
1092
+ const results = "_mockResults" in received && received._mockResults ? received._mockResults : await collector.collect(config, received.rootDir);
1093
+ const scoped = filterByScope(results, received.scope);
1094
+ const violations = scoped.flatMap((r) => r.violations);
1095
+ const pass = violations.length === 0;
1096
+ return {
1097
+ pass,
1098
+ 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"}):
1099
+ ${formatViolationList(violations)}`
1100
+ };
1101
+ }
1102
+ async function toHaveMaxFileCount(received, maxFiles) {
1103
+ const results = await collectCategory(received, new ModuleSizeCollector());
1104
+ const scoped = filterByScope(results, received.scope);
1105
+ const fileCount = scoped.reduce((max, r) => {
1106
+ const meta = r.metadata;
1107
+ const fc = typeof meta?.fileCount === "number" ? meta.fileCount : 0;
1108
+ return fc > max ? fc : max;
1109
+ }, 0);
1110
+ const pass = fileCount <= maxFiles;
1111
+ return {
1112
+ pass,
1113
+ message: () => pass ? `Expected file count in '${received.scope}' to exceed ${maxFiles} but it was ${fileCount}` : `Module '${received.scope}' has ${fileCount} files (limit: ${maxFiles})`
1114
+ };
1115
+ }
1116
+ async function toNotDependOn(received, forbiddenModule) {
1117
+ const results = await collectCategory(received, new ForbiddenImportCollector());
1118
+ const allViolations = results.flatMap((r) => r.violations);
1119
+ const scopePrefix = received.scope.replace(/\/+$/, "");
1120
+ const forbiddenPrefix = forbiddenModule.replace(/\/+$/, "");
1121
+ const relevantViolations = allViolations.filter(
1122
+ (v) => (v.file === scopePrefix || v.file.startsWith(scopePrefix + "/")) && (v.detail.includes(forbiddenPrefix + "/") || v.detail.endsWith(forbiddenPrefix))
1123
+ );
1124
+ const pass = relevantViolations.length === 0;
1125
+ return {
1126
+ pass,
1127
+ 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"}):
1128
+ ${formatViolationList(relevantViolations)}`
1129
+ };
1130
+ }
1131
+ async function toHaveMaxDepDepth(received, maxDepth) {
1132
+ const results = await collectCategory(received, new DepDepthCollector());
1133
+ const scoped = filterByScope(results, received.scope);
1134
+ const maxActual = scoped.reduce((max, r) => r.value > max ? r.value : max, 0);
1135
+ const pass = maxActual <= maxDepth;
1136
+ return {
1137
+ pass,
1138
+ message: () => pass ? `Expected dependency depth in '${received.scope}' to exceed ${maxDepth} but it was ${maxActual}` : `Module '${received.scope}' has dependency depth ${maxActual} (limit: ${maxDepth})`
1139
+ };
1140
+ }
1141
+ var archMatchers = {
1142
+ toHaveNoCircularDeps,
1143
+ toHaveNoLayerViolations,
1144
+ toMatchBaseline,
1145
+ toHaveMaxComplexity,
1146
+ toHaveMaxCoupling,
1147
+ toHaveMaxFileCount,
1148
+ toNotDependOn,
1149
+ toHaveMaxDepDepth
1150
+ };
1151
+
1152
+ export {
1153
+ detectCircularDeps,
1154
+ detectCircularDepsInFiles,
1155
+ violationId,
1156
+ constraintRuleId,
1157
+ CircularDepsCollector,
1158
+ LayerViolationCollector,
1159
+ ComplexityCollector,
1160
+ CouplingCollector,
1161
+ ForbiddenImportCollector,
1162
+ ModuleSizeCollector,
1163
+ DepDepthCollector,
1164
+ defaultCollectors,
1165
+ runAll,
1166
+ ArchBaselineManager,
1167
+ diff,
1168
+ architecture,
1169
+ archModule,
1170
+ archMatchers
1171
+ };