@harness-engineering/core 0.13.1 → 0.14.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.
@@ -292,65 +292,71 @@ async function validateDependencies(config) {
292
292
  }
293
293
 
294
294
  // src/constraints/circular-deps.ts
295
- function tarjanSCC(graph) {
296
- const nodeMap = /* @__PURE__ */ new Map();
297
- const stack = [];
298
- const sccs = [];
299
- let index = 0;
295
+ function buildAdjacencyList(graph) {
300
296
  const adjacency = /* @__PURE__ */ new Map();
297
+ const nodeSet = new Set(graph.nodes);
301
298
  for (const node of graph.nodes) {
302
299
  adjacency.set(node, []);
303
300
  }
304
301
  for (const edge of graph.edges) {
305
302
  const neighbors = adjacency.get(edge.from);
306
- if (neighbors && graph.nodes.includes(edge.to)) {
303
+ if (neighbors && nodeSet.has(edge.to)) {
307
304
  neighbors.push(edge.to);
308
305
  }
309
306
  }
310
- function strongConnect(node) {
311
- nodeMap.set(node, {
312
- index,
313
- lowlink: index,
314
- onStack: true
315
- });
316
- index++;
317
- stack.push(node);
318
- const neighbors = adjacency.get(node) ?? [];
319
- for (const neighbor of neighbors) {
320
- const neighborData = nodeMap.get(neighbor);
321
- if (!neighborData) {
322
- strongConnect(neighbor);
323
- const nodeData2 = nodeMap.get(node);
324
- const updatedNeighborData = nodeMap.get(neighbor);
325
- nodeData2.lowlink = Math.min(nodeData2.lowlink, updatedNeighborData.lowlink);
326
- } else if (neighborData.onStack) {
327
- const nodeData2 = nodeMap.get(node);
328
- nodeData2.lowlink = Math.min(nodeData2.lowlink, neighborData.index);
329
- }
307
+ return adjacency;
308
+ }
309
+ function isCyclicSCC(scc, adjacency) {
310
+ if (scc.length > 1) return true;
311
+ if (scc.length === 1) {
312
+ const selfNode = scc[0];
313
+ const selfNeighbors = adjacency.get(selfNode) ?? [];
314
+ return selfNeighbors.includes(selfNode);
315
+ }
316
+ return false;
317
+ }
318
+ function processNeighbors(node, neighbors, nodeMap, stack, adjacency, sccs, indexRef) {
319
+ for (const neighbor of neighbors) {
320
+ const neighborData = nodeMap.get(neighbor);
321
+ if (!neighborData) {
322
+ strongConnectImpl(neighbor, nodeMap, stack, adjacency, sccs, indexRef);
323
+ const nodeData = nodeMap.get(node);
324
+ const updatedNeighborData = nodeMap.get(neighbor);
325
+ nodeData.lowlink = Math.min(nodeData.lowlink, updatedNeighborData.lowlink);
326
+ } else if (neighborData.onStack) {
327
+ const nodeData = nodeMap.get(node);
328
+ nodeData.lowlink = Math.min(nodeData.lowlink, neighborData.index);
330
329
  }
331
- const nodeData = nodeMap.get(node);
332
- if (nodeData.lowlink === nodeData.index) {
333
- const scc = [];
334
- let w;
335
- do {
336
- w = stack.pop();
337
- nodeMap.get(w).onStack = false;
338
- scc.push(w);
339
- } while (w !== node);
340
- if (scc.length > 1) {
341
- sccs.push(scc);
342
- } else if (scc.length === 1) {
343
- const selfNode = scc[0];
344
- const selfNeighbors = adjacency.get(selfNode) ?? [];
345
- if (selfNeighbors.includes(selfNode)) {
346
- sccs.push(scc);
347
- }
348
- }
330
+ }
331
+ }
332
+ function strongConnectImpl(node, nodeMap, stack, adjacency, sccs, indexRef) {
333
+ nodeMap.set(node, { index: indexRef.value, lowlink: indexRef.value, onStack: true });
334
+ indexRef.value++;
335
+ stack.push(node);
336
+ processNeighbors(node, adjacency.get(node) ?? [], nodeMap, stack, adjacency, sccs, indexRef);
337
+ const nodeData = nodeMap.get(node);
338
+ if (nodeData.lowlink === nodeData.index) {
339
+ const scc = [];
340
+ let w;
341
+ do {
342
+ w = stack.pop();
343
+ nodeMap.get(w).onStack = false;
344
+ scc.push(w);
345
+ } while (w !== node);
346
+ if (isCyclicSCC(scc, adjacency)) {
347
+ sccs.push(scc);
349
348
  }
350
349
  }
350
+ }
351
+ function tarjanSCC(graph) {
352
+ const nodeMap = /* @__PURE__ */ new Map();
353
+ const stack = [];
354
+ const sccs = [];
355
+ const indexRef = { value: 0 };
356
+ const adjacency = buildAdjacencyList(graph);
351
357
  for (const node of graph.nodes) {
352
358
  if (!nodeMap.has(node)) {
353
- strongConnect(node);
359
+ strongConnectImpl(node, nodeMap, stack, adjacency, sccs, indexRef);
354
360
  }
355
361
  }
356
362
  return sccs;
@@ -620,6 +626,31 @@ function aggregateByCategory(results) {
620
626
  }
621
627
  return map;
622
628
  }
629
+ function classifyViolations(violations, baselineViolationIds) {
630
+ const newViolations = [];
631
+ const preExisting = [];
632
+ for (const violation of violations) {
633
+ if (baselineViolationIds.has(violation.id)) {
634
+ preExisting.push(violation.id);
635
+ } else {
636
+ newViolations.push(violation);
637
+ }
638
+ }
639
+ return { newViolations, preExisting };
640
+ }
641
+ function findResolvedViolations(baselineCategory, currentViolationIds) {
642
+ if (!baselineCategory) return [];
643
+ return baselineCategory.violationIds.filter((id) => !currentViolationIds.has(id));
644
+ }
645
+ function collectOrphanedBaselineViolations(baseline, visitedCategories) {
646
+ const resolved = [];
647
+ for (const [category, baselineCategory] of Object.entries(baseline.metrics)) {
648
+ if (!visitedCategories.has(category) && baselineCategory) {
649
+ resolved.push(...baselineCategory.violationIds);
650
+ }
651
+ }
652
+ return resolved;
653
+ }
623
654
  function diff(current, baseline) {
624
655
  const aggregated = aggregateByCategory(current);
625
656
  const newViolations = [];
@@ -632,21 +663,11 @@ function diff(current, baseline) {
632
663
  const baselineCategory = baseline.metrics[category];
633
664
  const baselineViolationIds = new Set(baselineCategory?.violationIds ?? []);
634
665
  const baselineValue = baselineCategory?.value ?? 0;
635
- for (const violation of agg.violations) {
636
- if (baselineViolationIds.has(violation.id)) {
637
- preExisting.push(violation.id);
638
- } else {
639
- newViolations.push(violation);
640
- }
641
- }
666
+ const classified = classifyViolations(agg.violations, baselineViolationIds);
667
+ newViolations.push(...classified.newViolations);
668
+ preExisting.push(...classified.preExisting);
642
669
  const currentViolationIds = new Set(agg.violations.map((v) => v.id));
643
- if (baselineCategory) {
644
- for (const id of baselineCategory.violationIds) {
645
- if (!currentViolationIds.has(id)) {
646
- resolvedViolations.push(id);
647
- }
648
- }
649
- }
670
+ resolvedViolations.push(...findResolvedViolations(baselineCategory, currentViolationIds));
650
671
  if (baselineCategory && agg.value > baselineValue) {
651
672
  regressions.push({
652
673
  category,
@@ -656,16 +677,9 @@ function diff(current, baseline) {
656
677
  });
657
678
  }
658
679
  }
659
- for (const [category, baselineCategory] of Object.entries(baseline.metrics)) {
660
- if (!visitedCategories.has(category) && baselineCategory) {
661
- for (const id of baselineCategory.violationIds) {
662
- resolvedViolations.push(id);
663
- }
664
- }
665
- }
666
- const passed = newViolations.length === 0 && regressions.length === 0;
680
+ resolvedViolations.push(...collectOrphanedBaselineViolations(baseline, visitedCategories));
667
681
  return {
668
- passed,
682
+ passed: newViolations.length === 0 && regressions.length === 0,
669
683
  newViolations,
670
684
  resolvedViolations,
671
685
  preExisting,
@@ -683,22 +697,22 @@ var DEFAULT_THRESHOLDS = {
683
697
  fileLength: { info: 300 },
684
698
  hotspotPercentile: { error: 95 }
685
699
  };
700
+ var FUNCTION_PATTERNS = [
701
+ // function declarations: function name(params) {
702
+ /^\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)/,
703
+ // method declarations: name(params) {
704
+ /^\s*(?:async\s+)?(\w+)\s*\(([^)]*)\)\s*(?::\s*[^{]+)?\s*\{/,
705
+ // arrow functions assigned to const/let/var: const name = (params) =>
706
+ /^\s*(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?\(([^)]*)\)\s*(?::\s*[^=]+)?\s*=>/,
707
+ // arrow functions assigned to const/let/var with single param: const name = param =>
708
+ /^\s*(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?(\w+)\s*=>/
709
+ ];
686
710
  function extractFunctions(content) {
687
711
  const functions = [];
688
712
  const lines = content.split("\n");
689
- const patterns = [
690
- // function declarations: function name(params) {
691
- /^\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)/,
692
- // method declarations: name(params) {
693
- /^\s*(?:async\s+)?(\w+)\s*\(([^)]*)\)\s*(?::\s*[^{]+)?\s*\{/,
694
- // arrow functions assigned to const/let/var: const name = (params) =>
695
- /^\s*(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?\(([^)]*)\)\s*(?::\s*[^=]+)?\s*=>/,
696
- // arrow functions assigned to const/let/var with single param: const name = param =>
697
- /^\s*(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?(\w+)\s*=>/
698
- ];
699
713
  for (let i = 0; i < lines.length; i++) {
700
714
  const line = lines[i];
701
- for (const pattern of patterns) {
715
+ for (const pattern of FUNCTION_PATTERNS) {
702
716
  const match = line.match(pattern);
703
717
  if (match) {
704
718
  const name = match[1] ?? "anonymous";
@@ -787,26 +801,155 @@ function computeNestingDepth(body) {
787
801
  }
788
802
  return maxDepth;
789
803
  }
790
- async function detectComplexityViolations(snapshot, config, graphData) {
791
- const violations = [];
792
- const thresholds = {
804
+ function resolveThresholds(config) {
805
+ const userThresholds = config?.thresholds;
806
+ if (!userThresholds) return { ...DEFAULT_THRESHOLDS };
807
+ return {
793
808
  cyclomaticComplexity: {
794
- error: config?.thresholds?.cyclomaticComplexity?.error ?? DEFAULT_THRESHOLDS.cyclomaticComplexity.error,
795
- warn: config?.thresholds?.cyclomaticComplexity?.warn ?? DEFAULT_THRESHOLDS.cyclomaticComplexity.warn
809
+ ...DEFAULT_THRESHOLDS.cyclomaticComplexity,
810
+ ...stripUndefined(userThresholds.cyclomaticComplexity)
796
811
  },
797
812
  nestingDepth: {
798
- warn: config?.thresholds?.nestingDepth?.warn ?? DEFAULT_THRESHOLDS.nestingDepth.warn
813
+ ...DEFAULT_THRESHOLDS.nestingDepth,
814
+ ...stripUndefined(userThresholds.nestingDepth)
799
815
  },
800
816
  functionLength: {
801
- warn: config?.thresholds?.functionLength?.warn ?? DEFAULT_THRESHOLDS.functionLength.warn
817
+ ...DEFAULT_THRESHOLDS.functionLength,
818
+ ...stripUndefined(userThresholds.functionLength)
802
819
  },
803
820
  parameterCount: {
804
- warn: config?.thresholds?.parameterCount?.warn ?? DEFAULT_THRESHOLDS.parameterCount.warn
821
+ ...DEFAULT_THRESHOLDS.parameterCount,
822
+ ...stripUndefined(userThresholds.parameterCount)
805
823
  },
806
- fileLength: {
807
- info: config?.thresholds?.fileLength?.info ?? DEFAULT_THRESHOLDS.fileLength.info
808
- }
824
+ fileLength: { ...DEFAULT_THRESHOLDS.fileLength, ...stripUndefined(userThresholds.fileLength) }
809
825
  };
826
+ }
827
+ function stripUndefined(obj) {
828
+ if (!obj) return {};
829
+ const result = {};
830
+ for (const [key, val] of Object.entries(obj)) {
831
+ if (val !== void 0) result[key] = val;
832
+ }
833
+ return result;
834
+ }
835
+ function checkFileLengthViolation(filePath, lineCount, threshold) {
836
+ if (lineCount <= threshold) return null;
837
+ return {
838
+ file: filePath,
839
+ function: "<file>",
840
+ line: 1,
841
+ metric: "fileLength",
842
+ value: lineCount,
843
+ threshold,
844
+ tier: 3,
845
+ severity: "info",
846
+ message: `File has ${lineCount} lines (threshold: ${threshold})`
847
+ };
848
+ }
849
+ function checkCyclomaticComplexity(filePath, fn, thresholds) {
850
+ const complexity = computeCyclomaticComplexity(fn.body);
851
+ if (complexity > thresholds.error) {
852
+ return {
853
+ file: filePath,
854
+ function: fn.name,
855
+ line: fn.line,
856
+ metric: "cyclomaticComplexity",
857
+ value: complexity,
858
+ threshold: thresholds.error,
859
+ tier: 1,
860
+ severity: "error",
861
+ message: `Function "${fn.name}" has cyclomatic complexity of ${complexity} (error threshold: ${thresholds.error})`
862
+ };
863
+ }
864
+ if (complexity > thresholds.warn) {
865
+ return {
866
+ file: filePath,
867
+ function: fn.name,
868
+ line: fn.line,
869
+ metric: "cyclomaticComplexity",
870
+ value: complexity,
871
+ threshold: thresholds.warn,
872
+ tier: 2,
873
+ severity: "warning",
874
+ message: `Function "${fn.name}" has cyclomatic complexity of ${complexity} (warning threshold: ${thresholds.warn})`
875
+ };
876
+ }
877
+ return null;
878
+ }
879
+ function checkNestingDepth(filePath, fn, threshold) {
880
+ const depth = computeNestingDepth(fn.body);
881
+ if (depth <= threshold) return null;
882
+ return {
883
+ file: filePath,
884
+ function: fn.name,
885
+ line: fn.line,
886
+ metric: "nestingDepth",
887
+ value: depth,
888
+ threshold,
889
+ tier: 2,
890
+ severity: "warning",
891
+ message: `Function "${fn.name}" has nesting depth of ${depth} (threshold: ${threshold})`
892
+ };
893
+ }
894
+ function checkFunctionLength(filePath, fn, threshold) {
895
+ const fnLength = fn.endLine - fn.startLine + 1;
896
+ if (fnLength <= threshold) return null;
897
+ return {
898
+ file: filePath,
899
+ function: fn.name,
900
+ line: fn.line,
901
+ metric: "functionLength",
902
+ value: fnLength,
903
+ threshold,
904
+ tier: 2,
905
+ severity: "warning",
906
+ message: `Function "${fn.name}" is ${fnLength} lines long (threshold: ${threshold})`
907
+ };
908
+ }
909
+ function checkParameterCount(filePath, fn, threshold) {
910
+ if (fn.params <= threshold) return null;
911
+ return {
912
+ file: filePath,
913
+ function: fn.name,
914
+ line: fn.line,
915
+ metric: "parameterCount",
916
+ value: fn.params,
917
+ threshold,
918
+ tier: 2,
919
+ severity: "warning",
920
+ message: `Function "${fn.name}" has ${fn.params} parameters (threshold: ${threshold})`
921
+ };
922
+ }
923
+ function checkHotspot(filePath, fn, graphData) {
924
+ const hotspot = graphData.hotspots.find((h) => h.file === filePath && h.function === fn.name);
925
+ if (!hotspot || hotspot.hotspotScore <= graphData.percentile95Score) return null;
926
+ return {
927
+ file: filePath,
928
+ function: fn.name,
929
+ line: fn.line,
930
+ metric: "hotspotScore",
931
+ value: hotspot.hotspotScore,
932
+ threshold: graphData.percentile95Score,
933
+ tier: 1,
934
+ severity: "error",
935
+ message: `Function "${fn.name}" is a complexity hotspot (score: ${hotspot.hotspotScore}, p95: ${graphData.percentile95Score})`
936
+ };
937
+ }
938
+ function collectFunctionViolations(filePath, fn, thresholds, graphData) {
939
+ const checks = [
940
+ checkCyclomaticComplexity(filePath, fn, thresholds.cyclomaticComplexity),
941
+ checkNestingDepth(filePath, fn, thresholds.nestingDepth.warn),
942
+ checkFunctionLength(filePath, fn, thresholds.functionLength.warn),
943
+ checkParameterCount(filePath, fn, thresholds.parameterCount.warn)
944
+ ];
945
+ if (graphData) {
946
+ checks.push(checkHotspot(filePath, fn, graphData));
947
+ }
948
+ return checks.filter((v) => v !== null);
949
+ }
950
+ async function detectComplexityViolations(snapshot, config, graphData) {
951
+ const violations = [];
952
+ const thresholds = resolveThresholds(config);
810
953
  let totalFunctions = 0;
811
954
  for (const file of snapshot.files) {
812
955
  let content;
@@ -816,107 +959,16 @@ async function detectComplexityViolations(snapshot, config, graphData) {
816
959
  continue;
817
960
  }
818
961
  const lines = content.split("\n");
819
- if (lines.length > thresholds.fileLength.info) {
820
- violations.push({
821
- file: file.path,
822
- function: "<file>",
823
- line: 1,
824
- metric: "fileLength",
825
- value: lines.length,
826
- threshold: thresholds.fileLength.info,
827
- tier: 3,
828
- severity: "info",
829
- message: `File has ${lines.length} lines (threshold: ${thresholds.fileLength.info})`
830
- });
831
- }
962
+ const fileLenViolation = checkFileLengthViolation(
963
+ file.path,
964
+ lines.length,
965
+ thresholds.fileLength.info
966
+ );
967
+ if (fileLenViolation) violations.push(fileLenViolation);
832
968
  const functions = extractFunctions(content);
833
969
  totalFunctions += functions.length;
834
970
  for (const fn of functions) {
835
- const complexity = computeCyclomaticComplexity(fn.body);
836
- if (complexity > thresholds.cyclomaticComplexity.error) {
837
- violations.push({
838
- file: file.path,
839
- function: fn.name,
840
- line: fn.line,
841
- metric: "cyclomaticComplexity",
842
- value: complexity,
843
- threshold: thresholds.cyclomaticComplexity.error,
844
- tier: 1,
845
- severity: "error",
846
- message: `Function "${fn.name}" has cyclomatic complexity of ${complexity} (error threshold: ${thresholds.cyclomaticComplexity.error})`
847
- });
848
- } else if (complexity > thresholds.cyclomaticComplexity.warn) {
849
- violations.push({
850
- file: file.path,
851
- function: fn.name,
852
- line: fn.line,
853
- metric: "cyclomaticComplexity",
854
- value: complexity,
855
- threshold: thresholds.cyclomaticComplexity.warn,
856
- tier: 2,
857
- severity: "warning",
858
- message: `Function "${fn.name}" has cyclomatic complexity of ${complexity} (warning threshold: ${thresholds.cyclomaticComplexity.warn})`
859
- });
860
- }
861
- const nestingDepth = computeNestingDepth(fn.body);
862
- if (nestingDepth > thresholds.nestingDepth.warn) {
863
- violations.push({
864
- file: file.path,
865
- function: fn.name,
866
- line: fn.line,
867
- metric: "nestingDepth",
868
- value: nestingDepth,
869
- threshold: thresholds.nestingDepth.warn,
870
- tier: 2,
871
- severity: "warning",
872
- message: `Function "${fn.name}" has nesting depth of ${nestingDepth} (threshold: ${thresholds.nestingDepth.warn})`
873
- });
874
- }
875
- const fnLength = fn.endLine - fn.startLine + 1;
876
- if (fnLength > thresholds.functionLength.warn) {
877
- violations.push({
878
- file: file.path,
879
- function: fn.name,
880
- line: fn.line,
881
- metric: "functionLength",
882
- value: fnLength,
883
- threshold: thresholds.functionLength.warn,
884
- tier: 2,
885
- severity: "warning",
886
- message: `Function "${fn.name}" is ${fnLength} lines long (threshold: ${thresholds.functionLength.warn})`
887
- });
888
- }
889
- if (fn.params > thresholds.parameterCount.warn) {
890
- violations.push({
891
- file: file.path,
892
- function: fn.name,
893
- line: fn.line,
894
- metric: "parameterCount",
895
- value: fn.params,
896
- threshold: thresholds.parameterCount.warn,
897
- tier: 2,
898
- severity: "warning",
899
- message: `Function "${fn.name}" has ${fn.params} parameters (threshold: ${thresholds.parameterCount.warn})`
900
- });
901
- }
902
- if (graphData) {
903
- const hotspot = graphData.hotspots.find(
904
- (h) => h.file === file.path && h.function === fn.name
905
- );
906
- if (hotspot && hotspot.hotspotScore > graphData.percentile95Score) {
907
- violations.push({
908
- file: file.path,
909
- function: fn.name,
910
- line: fn.line,
911
- metric: "hotspotScore",
912
- value: hotspot.hotspotScore,
913
- threshold: graphData.percentile95Score,
914
- tier: 1,
915
- severity: "error",
916
- message: `Function "${fn.name}" is a complexity hotspot (score: ${hotspot.hotspotScore}, p95: ${graphData.percentile95Score})`
917
- });
918
- }
919
- }
971
+ violations.push(...collectFunctionViolations(file.path, fn, thresholds, graphData));
920
972
  }
921
973
  }
922
974
  const errorCount = violations.filter((v) => v.severity === "error").length;