@codesentinel/codesentinel 1.0.1 → 1.2.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/README.md +44 -0
- package/dist/index.js +1177 -14
- package/dist/index.js.map +1 -1
- package/package.json +8 -6
package/dist/index.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { Command } from "commander";
|
|
5
|
-
import { readFileSync } from "fs";
|
|
4
|
+
import { Command, Option } from "commander";
|
|
5
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
6
6
|
import { dirname, resolve as resolve3 } from "path";
|
|
7
7
|
import { fileURLToPath } from "url";
|
|
8
8
|
|
|
9
|
+
// src/application/run-analyze-command.ts
|
|
10
|
+
import { resolve as resolve2 } from "path";
|
|
11
|
+
|
|
9
12
|
// ../code-graph/dist/index.js
|
|
10
13
|
import { extname, isAbsolute, relative, resolve } from "path";
|
|
11
14
|
import * as ts from "typescript";
|
|
@@ -445,28 +448,1188 @@ var buildProjectGraphSummary = (input) => {
|
|
|
445
448
|
return createGraphAnalysisSummary(input.projectPath, graphData);
|
|
446
449
|
};
|
|
447
450
|
|
|
448
|
-
// ../
|
|
449
|
-
import {
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
451
|
+
// ../dependency-firewall/dist/index.js
|
|
452
|
+
import { existsSync, readFileSync } from "fs";
|
|
453
|
+
import { join } from "path";
|
|
454
|
+
var round4 = (value) => Number(value.toFixed(4));
|
|
455
|
+
var normalizeNodes = (nodes) => {
|
|
456
|
+
const byName = /* @__PURE__ */ new Map();
|
|
457
|
+
for (const node of nodes) {
|
|
458
|
+
const bucket = byName.get(node.name) ?? [];
|
|
459
|
+
bucket.push(node);
|
|
460
|
+
byName.set(node.name, bucket);
|
|
461
|
+
}
|
|
462
|
+
const normalized = [];
|
|
463
|
+
for (const [name, candidates] of byName.entries()) {
|
|
464
|
+
if (candidates.length === 0) {
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
candidates.sort((a, b) => b.version.localeCompare(a.version));
|
|
468
|
+
const selected = candidates[0];
|
|
469
|
+
if (selected === void 0) {
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
const deps = selected.dependencies.map((dep) => {
|
|
473
|
+
const at = dep.lastIndexOf("@");
|
|
474
|
+
return at <= 0 ? dep : dep.slice(0, at);
|
|
475
|
+
}).filter((depName) => depName.length > 0).sort((a, b) => a.localeCompare(b));
|
|
476
|
+
normalized.push({
|
|
477
|
+
key: `${name}@${selected.version}`,
|
|
478
|
+
name,
|
|
479
|
+
version: selected.version,
|
|
480
|
+
dependencies: deps
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
return normalized.sort((a, b) => a.name.localeCompare(b.name));
|
|
484
|
+
};
|
|
485
|
+
var computeDepths = (nodeByName, directNames) => {
|
|
486
|
+
const visiting = /* @__PURE__ */ new Set();
|
|
487
|
+
const depthByName = /* @__PURE__ */ new Map();
|
|
488
|
+
const compute = (name) => {
|
|
489
|
+
const known = depthByName.get(name);
|
|
490
|
+
if (known !== void 0) {
|
|
491
|
+
return known;
|
|
492
|
+
}
|
|
493
|
+
if (visiting.has(name)) {
|
|
494
|
+
return 0;
|
|
495
|
+
}
|
|
496
|
+
visiting.add(name);
|
|
497
|
+
const node = nodeByName.get(name);
|
|
498
|
+
if (node === void 0) {
|
|
499
|
+
visiting.delete(name);
|
|
500
|
+
depthByName.set(name, 0);
|
|
501
|
+
return 0;
|
|
502
|
+
}
|
|
503
|
+
let maxChildDepth = 0;
|
|
504
|
+
for (const dependencyName of node.dependencies) {
|
|
505
|
+
const childDepth = compute(dependencyName);
|
|
506
|
+
if (childDepth > maxChildDepth) {
|
|
507
|
+
maxChildDepth = childDepth;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
visiting.delete(name);
|
|
511
|
+
const ownDepth = directNames.has(name) ? 0 : maxChildDepth + 1;
|
|
512
|
+
depthByName.set(name, ownDepth);
|
|
513
|
+
return ownDepth;
|
|
514
|
+
};
|
|
515
|
+
for (const name of nodeByName.keys()) {
|
|
516
|
+
compute(name);
|
|
517
|
+
}
|
|
518
|
+
let maxDepth = 0;
|
|
519
|
+
for (const depth of depthByName.values()) {
|
|
520
|
+
if (depth > maxDepth) {
|
|
521
|
+
maxDepth = depth;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return { depthByName, maxDepth };
|
|
525
|
+
};
|
|
526
|
+
var rankCentrality = (nodes, dependentsByName, directNames, topN) => [...nodes].map((node) => ({
|
|
527
|
+
name: node.name,
|
|
528
|
+
dependents: dependentsByName.get(node.name) ?? 0,
|
|
529
|
+
fanOut: node.dependencies.length,
|
|
530
|
+
direct: directNames.has(node.name)
|
|
531
|
+
})).sort(
|
|
532
|
+
(a, b) => b.dependents - a.dependents || b.fanOut - a.fanOut || a.name.localeCompare(b.name)
|
|
533
|
+
).slice(0, topN);
|
|
534
|
+
var canPropagateSignal = (signal) => signal === "abandoned" || signal === "high_centrality" || signal === "deep_chain" || signal === "high_fanout";
|
|
535
|
+
var collectTransitiveDependencies = (rootName, nodeByName) => {
|
|
536
|
+
const seen = /* @__PURE__ */ new Set();
|
|
537
|
+
const stack = [...nodeByName.get(rootName)?.dependencies ?? []];
|
|
538
|
+
while (stack.length > 0) {
|
|
539
|
+
const current = stack.pop();
|
|
540
|
+
if (current === void 0 || seen.has(current) || current === rootName) {
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
seen.add(current);
|
|
544
|
+
const currentNode = nodeByName.get(current);
|
|
545
|
+
if (currentNode === void 0) {
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
for (const next of currentNode.dependencies) {
|
|
549
|
+
if (!seen.has(next)) {
|
|
550
|
+
stack.push(next);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return [...seen].sort((a, b) => a.localeCompare(b));
|
|
555
|
+
};
|
|
556
|
+
var buildExternalAnalysisSummary = (targetPath, extraction, metadataByKey, config) => {
|
|
557
|
+
const nodes = normalizeNodes(extraction.nodes);
|
|
558
|
+
const directNames = new Set(extraction.directDependencies.map((dep) => dep.name));
|
|
559
|
+
const directSpecByName = new Map(extraction.directDependencies.map((dep) => [dep.name, dep.requestedRange]));
|
|
560
|
+
const nodeByName = new Map(nodes.map((node) => [node.name, node]));
|
|
561
|
+
const dependentsByName = /* @__PURE__ */ new Map();
|
|
562
|
+
for (const node of nodes) {
|
|
563
|
+
dependentsByName.set(node.name, dependentsByName.get(node.name) ?? 0);
|
|
564
|
+
}
|
|
565
|
+
for (const node of nodes) {
|
|
566
|
+
for (const dependencyName of node.dependencies) {
|
|
567
|
+
if (!nodeByName.has(dependencyName)) {
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
dependentsByName.set(dependencyName, (dependentsByName.get(dependencyName) ?? 0) + 1);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
const { depthByName, maxDepth } = computeDepths(nodeByName, directNames);
|
|
574
|
+
const centralityRanking = rankCentrality(nodes, dependentsByName, directNames, config.centralityTopN);
|
|
575
|
+
const topCentralNames = new Set(
|
|
576
|
+
centralityRanking.slice(0, Math.max(1, Math.ceil(centralityRanking.length * 0.25))).map((entry) => entry.name)
|
|
577
|
+
);
|
|
578
|
+
const allDependencies = [];
|
|
579
|
+
let metadataAvailableCount = 0;
|
|
580
|
+
for (const node of nodes) {
|
|
581
|
+
const metadata = metadataByKey.get(node.key) ?? null;
|
|
582
|
+
if (metadata !== null) {
|
|
583
|
+
metadataAvailableCount += 1;
|
|
584
|
+
}
|
|
585
|
+
const dependencyDepth = depthByName.get(node.name) ?? 0;
|
|
586
|
+
const dependents = dependentsByName.get(node.name) ?? 0;
|
|
587
|
+
const riskSignals = [];
|
|
588
|
+
if ((metadata?.maintainerCount ?? 0) === 1) {
|
|
589
|
+
riskSignals.push("single_maintainer");
|
|
590
|
+
}
|
|
591
|
+
if ((metadata?.daysSinceLastRelease ?? 0) >= config.abandonedDaysThreshold) {
|
|
592
|
+
riskSignals.push("abandoned");
|
|
593
|
+
}
|
|
594
|
+
if (topCentralNames.has(node.name) && dependents > 0) {
|
|
595
|
+
riskSignals.push("high_centrality");
|
|
596
|
+
}
|
|
597
|
+
if (dependencyDepth >= config.deepChainThreshold) {
|
|
598
|
+
riskSignals.push("deep_chain");
|
|
599
|
+
}
|
|
600
|
+
if (node.dependencies.length >= config.fanOutHighThreshold) {
|
|
601
|
+
riskSignals.push("high_fanout");
|
|
602
|
+
}
|
|
603
|
+
if (metadata === null) {
|
|
604
|
+
riskSignals.push("metadata_unavailable");
|
|
605
|
+
}
|
|
606
|
+
allDependencies.push({
|
|
607
|
+
name: node.name,
|
|
608
|
+
direct: directNames.has(node.name),
|
|
609
|
+
requestedRange: directSpecByName.get(node.name) ?? null,
|
|
610
|
+
resolvedVersion: node.version,
|
|
611
|
+
transitiveDependencies: [],
|
|
612
|
+
dependencyDepth,
|
|
613
|
+
fanOut: node.dependencies.length,
|
|
614
|
+
dependents,
|
|
615
|
+
maintainerCount: metadata?.maintainerCount ?? null,
|
|
616
|
+
releaseFrequencyDays: metadata?.releaseFrequencyDays ?? null,
|
|
617
|
+
daysSinceLastRelease: metadata?.daysSinceLastRelease ?? null,
|
|
618
|
+
repositoryActivity30d: metadata?.repositoryActivity30d ?? null,
|
|
619
|
+
busFactor: metadata?.busFactor ?? null,
|
|
620
|
+
ownRiskSignals: [...riskSignals].sort((a, b) => a.localeCompare(b)),
|
|
621
|
+
inheritedRiskSignals: [],
|
|
622
|
+
riskSignals
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
allDependencies.sort((a, b) => a.name.localeCompare(b.name));
|
|
626
|
+
const allByName = new Map(allDependencies.map((dep) => [dep.name, dep]));
|
|
627
|
+
const dependencies = allDependencies.filter((dep) => dep.direct).map((dep) => {
|
|
628
|
+
const transitiveDependencies = collectTransitiveDependencies(dep.name, nodeByName);
|
|
629
|
+
const inheritedSignals = /* @__PURE__ */ new Set();
|
|
630
|
+
const allSignals = new Set(dep.ownRiskSignals);
|
|
631
|
+
for (const transitiveName of transitiveDependencies) {
|
|
632
|
+
const transitive = allByName.get(transitiveName);
|
|
633
|
+
if (transitive === void 0) {
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
for (const signal of transitive.riskSignals) {
|
|
637
|
+
if (canPropagateSignal(signal)) {
|
|
638
|
+
inheritedSignals.add(signal);
|
|
639
|
+
allSignals.add(signal);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
return {
|
|
644
|
+
...dep,
|
|
645
|
+
transitiveDependencies,
|
|
646
|
+
inheritedRiskSignals: [...inheritedSignals].sort((a, b) => a.localeCompare(b)),
|
|
647
|
+
riskSignals: [...allSignals].sort((a, b) => a.localeCompare(b))
|
|
648
|
+
};
|
|
649
|
+
}).sort((a, b) => a.name.localeCompare(b.name));
|
|
650
|
+
const highRiskDependencies = dependencies.filter((dep) => dep.riskSignals.length > 1).sort((a, b) => b.riskSignals.length - a.riskSignals.length || a.name.localeCompare(b.name)).slice(0, config.maxHighRiskDependencies).map((dep) => dep.name);
|
|
651
|
+
const singleMaintainerDependencies = dependencies.filter((dep) => dep.ownRiskSignals.includes("single_maintainer")).map((dep) => dep.name).sort((a, b) => a.localeCompare(b));
|
|
652
|
+
const abandonedDependencies = dependencies.filter((dep) => dep.ownRiskSignals.includes("abandoned")).map((dep) => dep.name).sort((a, b) => a.localeCompare(b));
|
|
653
|
+
return {
|
|
654
|
+
targetPath,
|
|
655
|
+
available: true,
|
|
656
|
+
metrics: {
|
|
657
|
+
totalDependencies: allDependencies.length,
|
|
658
|
+
directDependencies: dependencies.length,
|
|
659
|
+
transitiveDependencies: allDependencies.length - dependencies.length,
|
|
660
|
+
dependencyDepth: maxDepth,
|
|
661
|
+
lockfileKind: extraction.kind,
|
|
662
|
+
metadataCoverage: allDependencies.length === 0 ? 0 : round4(metadataAvailableCount / allDependencies.length)
|
|
663
|
+
},
|
|
664
|
+
dependencies,
|
|
665
|
+
highRiskDependencies,
|
|
666
|
+
singleMaintainerDependencies,
|
|
667
|
+
abandonedDependencies,
|
|
668
|
+
centralityRanking
|
|
669
|
+
};
|
|
670
|
+
};
|
|
671
|
+
var DEFAULT_EXTERNAL_ANALYSIS_CONFIG = {
|
|
672
|
+
abandonedDaysThreshold: 540,
|
|
673
|
+
deepChainThreshold: 6,
|
|
674
|
+
fanOutHighThreshold: 25,
|
|
675
|
+
centralityTopN: 20,
|
|
676
|
+
maxHighRiskDependencies: 100,
|
|
677
|
+
metadataRequestConcurrency: 8
|
|
678
|
+
};
|
|
679
|
+
var LOCKFILE_CANDIDATES = [
|
|
680
|
+
{ fileName: "pnpm-lock.yaml", kind: "pnpm" },
|
|
681
|
+
{ fileName: "package-lock.json", kind: "npm" },
|
|
682
|
+
{ fileName: "npm-shrinkwrap.json", kind: "npm-shrinkwrap" },
|
|
683
|
+
{ fileName: "yarn.lock", kind: "yarn" },
|
|
684
|
+
{ fileName: "bun.lock", kind: "bun" },
|
|
685
|
+
{ fileName: "bun.lockb", kind: "bun" }
|
|
686
|
+
];
|
|
687
|
+
var loadPackageJson = (repositoryPath) => {
|
|
688
|
+
const packageJsonPath2 = join(repositoryPath, "package.json");
|
|
689
|
+
if (!existsSync(packageJsonPath2)) {
|
|
690
|
+
return null;
|
|
691
|
+
}
|
|
692
|
+
return {
|
|
693
|
+
path: packageJsonPath2,
|
|
694
|
+
raw: readFileSync(packageJsonPath2, "utf8")
|
|
695
|
+
};
|
|
696
|
+
};
|
|
697
|
+
var selectLockfile = (repositoryPath) => {
|
|
698
|
+
for (const candidate of LOCKFILE_CANDIDATES) {
|
|
699
|
+
const absolutePath = join(repositoryPath, candidate.fileName);
|
|
700
|
+
if (!existsSync(absolutePath)) {
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
return {
|
|
704
|
+
path: absolutePath,
|
|
705
|
+
kind: candidate.kind,
|
|
706
|
+
raw: readFileSync(absolutePath, "utf8")
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
return null;
|
|
710
|
+
};
|
|
711
|
+
var parsePackageJson = (raw) => {
|
|
712
|
+
const parsed = JSON.parse(raw);
|
|
713
|
+
const merged = /* @__PURE__ */ new Map();
|
|
714
|
+
for (const block of [
|
|
715
|
+
parsed.dependencies,
|
|
716
|
+
parsed.devDependencies,
|
|
717
|
+
parsed.optionalDependencies,
|
|
718
|
+
parsed.peerDependencies
|
|
719
|
+
]) {
|
|
720
|
+
if (block === void 0) {
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
for (const [name, versionRange] of Object.entries(block)) {
|
|
724
|
+
merged.set(name, versionRange);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
return [...merged.entries()].map(([name, requestedRange]) => ({ name, requestedRange })).sort((a, b) => a.name.localeCompare(b.name));
|
|
728
|
+
};
|
|
729
|
+
var parsePackageLock = (raw, directSpecs) => {
|
|
730
|
+
const parsed = JSON.parse(raw);
|
|
731
|
+
const nodes = [];
|
|
732
|
+
if (parsed.packages !== void 0) {
|
|
733
|
+
for (const [packagePath, packageData] of Object.entries(parsed.packages)) {
|
|
734
|
+
if (packagePath.length === 0 || packageData.version === void 0) {
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
const segments = packagePath.split("node_modules/");
|
|
738
|
+
const name = segments[segments.length - 1] ?? "";
|
|
739
|
+
if (name.length === 0) {
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
const dependencies = Object.entries(packageData.dependencies ?? {}).map(([depName, depRange]) => `${depName}@${String(depRange)}`).sort((a, b) => a.localeCompare(b));
|
|
743
|
+
nodes.push({
|
|
744
|
+
name,
|
|
745
|
+
version: packageData.version,
|
|
746
|
+
dependencies
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
} else if (parsed.dependencies !== void 0) {
|
|
750
|
+
for (const [name, dep] of Object.entries(parsed.dependencies)) {
|
|
751
|
+
if (dep.version === void 0) {
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
const dependencies = Object.entries(dep.dependencies ?? {}).map(([depName, depVersion]) => `${depName}@${String(depVersion)}`).sort((a, b) => a.localeCompare(b));
|
|
755
|
+
nodes.push({
|
|
756
|
+
name,
|
|
757
|
+
version: dep.version,
|
|
758
|
+
dependencies
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
nodes.sort((a, b) => a.name.localeCompare(b.name) || a.version.localeCompare(b.version));
|
|
763
|
+
return {
|
|
764
|
+
kind: "npm",
|
|
765
|
+
directDependencies: directSpecs,
|
|
766
|
+
nodes
|
|
767
|
+
};
|
|
768
|
+
};
|
|
769
|
+
var sanitizeValue = (value) => value.replace(/^['"]|['"]$/g, "").trim();
|
|
770
|
+
var parsePackageKey = (rawKey) => {
|
|
771
|
+
const key = sanitizeValue(rawKey.replace(/:$/, ""));
|
|
772
|
+
const withoutSlash = key.startsWith("/") ? key.slice(1) : key;
|
|
773
|
+
const lastAt = withoutSlash.lastIndexOf("@");
|
|
774
|
+
if (lastAt <= 0) {
|
|
775
|
+
return null;
|
|
776
|
+
}
|
|
777
|
+
const name = withoutSlash.slice(0, lastAt);
|
|
778
|
+
const versionWithPeers = withoutSlash.slice(lastAt + 1);
|
|
779
|
+
const version2 = versionWithPeers.split("(")[0] ?? versionWithPeers;
|
|
780
|
+
if (name.length === 0 || version2.length === 0) {
|
|
781
|
+
return null;
|
|
782
|
+
}
|
|
783
|
+
return { name, version: version2 };
|
|
784
|
+
};
|
|
785
|
+
var parsePnpmLockfile = (raw, directSpecs) => {
|
|
786
|
+
const lines = raw.split("\n");
|
|
787
|
+
let state = "root";
|
|
788
|
+
let currentPackage = null;
|
|
789
|
+
let currentDependencyName = null;
|
|
790
|
+
const dependenciesByNode = /* @__PURE__ */ new Map();
|
|
791
|
+
for (const line of lines) {
|
|
792
|
+
if (line.trim().length === 0 || line.trimStart().startsWith("#")) {
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
if (line.startsWith("importers:")) {
|
|
796
|
+
state = "importers";
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
if (line.startsWith("packages:")) {
|
|
800
|
+
state = "packages";
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
if (state === "packages" || state === "packageDeps") {
|
|
804
|
+
const packageMatch = line.match(/^\s{2}([^\s].+):\s*$/);
|
|
805
|
+
if (packageMatch !== null) {
|
|
806
|
+
const parsedKey = parsePackageKey(packageMatch[1] ?? "");
|
|
807
|
+
if (parsedKey !== null) {
|
|
808
|
+
currentPackage = `${parsedKey.name}@${parsedKey.version}`;
|
|
809
|
+
dependenciesByNode.set(currentPackage, /* @__PURE__ */ new Set());
|
|
810
|
+
state = "packageDeps";
|
|
811
|
+
currentDependencyName = null;
|
|
812
|
+
}
|
|
813
|
+
continue;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
if (state === "packageDeps" && currentPackage !== null) {
|
|
817
|
+
const depLine = line.match(/^\s{6}([^:\s]+):\s*(.+)$/);
|
|
818
|
+
if (depLine !== null) {
|
|
819
|
+
const depName = sanitizeValue(depLine[1] ?? "");
|
|
820
|
+
const depRef = sanitizeValue(depLine[2] ?? "");
|
|
821
|
+
const depVersion = depRef.split("(")[0] ?? depRef;
|
|
822
|
+
if (depName.length > 0 && depVersion.length > 0) {
|
|
823
|
+
dependenciesByNode.get(currentPackage)?.add(`${depName}@${depVersion}`);
|
|
824
|
+
}
|
|
825
|
+
currentDependencyName = null;
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
const depBlockLine = line.match(/^\s{6}([^:\s]+):\s*$/);
|
|
829
|
+
if (depBlockLine !== null) {
|
|
830
|
+
currentDependencyName = sanitizeValue(depBlockLine[1] ?? "");
|
|
831
|
+
continue;
|
|
832
|
+
}
|
|
833
|
+
const depVersionLine = line.match(/^\s{8}version:\s*(.+)$/);
|
|
834
|
+
if (depVersionLine !== null && currentDependencyName !== null) {
|
|
835
|
+
const depRef = sanitizeValue(depVersionLine[1] ?? "");
|
|
836
|
+
const depVersion = depRef.split("(")[0] ?? depRef;
|
|
837
|
+
if (depVersion.length > 0) {
|
|
838
|
+
dependenciesByNode.get(currentPackage)?.add(`${currentDependencyName}@${depVersion}`);
|
|
839
|
+
}
|
|
840
|
+
currentDependencyName = null;
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
if (line.match(/^\s{4}(dependencies|optionalDependencies):\s*$/) !== null) {
|
|
844
|
+
continue;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
const nodes = [...dependenciesByNode.entries()].map(([nodeId, deps]) => {
|
|
849
|
+
const at = nodeId.lastIndexOf("@");
|
|
850
|
+
return {
|
|
851
|
+
name: nodeId.slice(0, at),
|
|
852
|
+
version: nodeId.slice(at + 1),
|
|
853
|
+
dependencies: [...deps].sort((a, b) => a.localeCompare(b))
|
|
854
|
+
};
|
|
855
|
+
}).sort(
|
|
856
|
+
(a, b) => a.name.localeCompare(b.name) || a.version.localeCompare(b.version)
|
|
857
|
+
);
|
|
858
|
+
return {
|
|
859
|
+
kind: "pnpm",
|
|
860
|
+
directDependencies: directSpecs,
|
|
861
|
+
nodes
|
|
862
|
+
};
|
|
863
|
+
};
|
|
864
|
+
var stripQuotes = (value) => value.replace(/^['"]|['"]$/g, "");
|
|
865
|
+
var parseVersionSelector = (selector) => {
|
|
866
|
+
const npmIndex = selector.lastIndexOf("@npm:");
|
|
867
|
+
if (npmIndex >= 0) {
|
|
868
|
+
return selector.slice(npmIndex + 5);
|
|
869
|
+
}
|
|
870
|
+
const lastAt = selector.lastIndexOf("@");
|
|
871
|
+
if (lastAt <= 0) {
|
|
872
|
+
return null;
|
|
873
|
+
}
|
|
874
|
+
return selector.slice(lastAt + 1);
|
|
875
|
+
};
|
|
876
|
+
var parseYarnLock = (raw, directSpecs) => {
|
|
877
|
+
const lines = raw.split("\n");
|
|
878
|
+
const nodes = [];
|
|
879
|
+
let selectors = [];
|
|
880
|
+
let version2 = null;
|
|
881
|
+
let readingDependencies = false;
|
|
882
|
+
let dependencies = [];
|
|
883
|
+
const flushEntry = () => {
|
|
884
|
+
if (selectors.length === 0 || version2 === null) {
|
|
885
|
+
selectors = [];
|
|
886
|
+
version2 = null;
|
|
887
|
+
dependencies = [];
|
|
888
|
+
readingDependencies = false;
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
for (const selector of selectors) {
|
|
892
|
+
const parsedVersion = parseVersionSelector(selector);
|
|
893
|
+
const at = selector.lastIndexOf("@");
|
|
894
|
+
const name = at <= 0 ? selector : selector.slice(0, at);
|
|
895
|
+
if (name.length === 0) {
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
nodes.push({
|
|
899
|
+
name,
|
|
900
|
+
version: version2,
|
|
901
|
+
dependencies: [...dependencies].sort((a, b) => a.localeCompare(b))
|
|
902
|
+
});
|
|
903
|
+
if (parsedVersion !== null) {
|
|
904
|
+
nodes.push({
|
|
905
|
+
name,
|
|
906
|
+
version: parsedVersion,
|
|
907
|
+
dependencies: [...dependencies].sort((a, b) => a.localeCompare(b))
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
selectors = [];
|
|
912
|
+
version2 = null;
|
|
913
|
+
dependencies = [];
|
|
914
|
+
readingDependencies = false;
|
|
915
|
+
};
|
|
916
|
+
for (const line of lines) {
|
|
917
|
+
if (line.trim().length === 0) {
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
if (!line.startsWith(" ") && line.endsWith(":")) {
|
|
921
|
+
flushEntry();
|
|
922
|
+
const keyText = line.slice(0, -1);
|
|
923
|
+
selectors = keyText.split(",").map((part) => stripQuotes(part.trim())).filter((part) => part.length > 0);
|
|
924
|
+
continue;
|
|
925
|
+
}
|
|
926
|
+
if (line.match(/^\s{2}version\s+/) !== null) {
|
|
927
|
+
const value = line.replace(/^\s{2}version\s+/, "").trim();
|
|
928
|
+
version2 = stripQuotes(value);
|
|
929
|
+
readingDependencies = false;
|
|
930
|
+
continue;
|
|
931
|
+
}
|
|
932
|
+
if (line.match(/^\s{2}dependencies:\s*$/) !== null) {
|
|
933
|
+
readingDependencies = true;
|
|
934
|
+
continue;
|
|
935
|
+
}
|
|
936
|
+
if (readingDependencies && line.match(/^\s{4}[^\s].+$/) !== null) {
|
|
937
|
+
const depLine = line.trim();
|
|
938
|
+
const firstSpace = depLine.indexOf(" ");
|
|
939
|
+
if (firstSpace <= 0) {
|
|
940
|
+
continue;
|
|
941
|
+
}
|
|
942
|
+
const depName = stripQuotes(depLine.slice(0, firstSpace));
|
|
943
|
+
const depRef = stripQuotes(depLine.slice(firstSpace + 1).trim());
|
|
944
|
+
const depVersion = parseVersionSelector(depRef) ?? depRef;
|
|
945
|
+
dependencies.push(`${depName}@${depVersion}`);
|
|
946
|
+
continue;
|
|
947
|
+
}
|
|
948
|
+
readingDependencies = false;
|
|
949
|
+
}
|
|
950
|
+
flushEntry();
|
|
951
|
+
const deduped = /* @__PURE__ */ new Map();
|
|
952
|
+
for (const node of nodes) {
|
|
953
|
+
const key = `${node.name}@${node.version}`;
|
|
954
|
+
if (!deduped.has(key)) {
|
|
955
|
+
deduped.set(key, node);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
return {
|
|
959
|
+
kind: "yarn",
|
|
960
|
+
directDependencies: directSpecs,
|
|
961
|
+
nodes: [...deduped.values()].sort((a, b) => a.name.localeCompare(b.name) || a.version.localeCompare(b.version))
|
|
962
|
+
};
|
|
963
|
+
};
|
|
964
|
+
var parseBunLock = (_raw, _directSpecs) => {
|
|
965
|
+
throw new Error("unsupported_lockfile_format");
|
|
966
|
+
};
|
|
967
|
+
var withDefaults = (overrides) => ({
|
|
968
|
+
...DEFAULT_EXTERNAL_ANALYSIS_CONFIG,
|
|
969
|
+
...overrides
|
|
970
|
+
});
|
|
971
|
+
var parseExtraction = (lockfileKind, lockfileRaw, directSpecs) => {
|
|
972
|
+
switch (lockfileKind) {
|
|
973
|
+
case "pnpm":
|
|
974
|
+
return parsePnpmLockfile(lockfileRaw, directSpecs);
|
|
975
|
+
case "npm":
|
|
976
|
+
case "npm-shrinkwrap":
|
|
977
|
+
return {
|
|
978
|
+
...parsePackageLock(lockfileRaw, directSpecs),
|
|
979
|
+
kind: lockfileKind
|
|
980
|
+
};
|
|
981
|
+
case "yarn":
|
|
982
|
+
return parseYarnLock(lockfileRaw, directSpecs);
|
|
983
|
+
case "bun":
|
|
984
|
+
return parseBunLock(lockfileRaw, directSpecs);
|
|
985
|
+
default:
|
|
986
|
+
throw new Error("unsupported_lockfile_format");
|
|
987
|
+
}
|
|
988
|
+
};
|
|
989
|
+
var mapWithConcurrency = async (values, limit, handler) => {
|
|
990
|
+
const effectiveLimit = Math.max(1, limit);
|
|
991
|
+
const results = new Array(values.length);
|
|
992
|
+
let index = 0;
|
|
993
|
+
const workers = Array.from({ length: Math.min(effectiveLimit, values.length) }, async () => {
|
|
994
|
+
for (; ; ) {
|
|
995
|
+
const current = index;
|
|
996
|
+
index += 1;
|
|
997
|
+
if (current >= values.length) {
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
const value = values[current];
|
|
1001
|
+
if (value === void 0) {
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
results[current] = await handler(value);
|
|
1005
|
+
}
|
|
1006
|
+
});
|
|
1007
|
+
await Promise.all(workers);
|
|
1008
|
+
return results;
|
|
1009
|
+
};
|
|
1010
|
+
var analyzeDependencyExposure = async (input, metadataProvider) => {
|
|
1011
|
+
const packageJson = loadPackageJson(input.repositoryPath);
|
|
1012
|
+
if (packageJson === null) {
|
|
1013
|
+
return {
|
|
1014
|
+
targetPath: input.repositoryPath,
|
|
1015
|
+
available: false,
|
|
1016
|
+
reason: "package_json_not_found"
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
const lockfile = selectLockfile(input.repositoryPath);
|
|
1020
|
+
if (lockfile === null) {
|
|
1021
|
+
return {
|
|
1022
|
+
targetPath: input.repositoryPath,
|
|
1023
|
+
available: false,
|
|
1024
|
+
reason: "lockfile_not_found"
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
try {
|
|
1028
|
+
const directSpecs = parsePackageJson(packageJson.raw);
|
|
1029
|
+
const extraction = parseExtraction(lockfile.kind, lockfile.raw, directSpecs);
|
|
1030
|
+
const config = withDefaults(input.config);
|
|
1031
|
+
const metadataEntries = await mapWithConcurrency(
|
|
1032
|
+
extraction.nodes,
|
|
1033
|
+
config.metadataRequestConcurrency,
|
|
1034
|
+
async (node) => ({
|
|
1035
|
+
key: `${node.name}@${node.version}`,
|
|
1036
|
+
metadata: await metadataProvider.getMetadata(node.name, node.version)
|
|
1037
|
+
})
|
|
1038
|
+
);
|
|
1039
|
+
const metadataByKey = /* @__PURE__ */ new Map();
|
|
1040
|
+
for (const entry of metadataEntries) {
|
|
1041
|
+
metadataByKey.set(entry.key, entry.metadata);
|
|
1042
|
+
}
|
|
1043
|
+
return buildExternalAnalysisSummary(input.repositoryPath, extraction, metadataByKey, config);
|
|
1044
|
+
} catch (error) {
|
|
1045
|
+
const message = error instanceof Error ? error.message : "unknown";
|
|
1046
|
+
if (message.includes("unsupported_lockfile_format")) {
|
|
1047
|
+
return {
|
|
1048
|
+
targetPath: input.repositoryPath,
|
|
1049
|
+
available: false,
|
|
1050
|
+
reason: "unsupported_lockfile_format"
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
return {
|
|
1054
|
+
targetPath: input.repositoryPath,
|
|
1055
|
+
available: false,
|
|
1056
|
+
reason: "invalid_lockfile"
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
};
|
|
1060
|
+
var ONE_DAY_MS = 24 * 60 * 60 * 1e3;
|
|
1061
|
+
var round42 = (value) => Number(value.toFixed(4));
|
|
1062
|
+
var parseDate = (iso) => {
|
|
1063
|
+
if (iso === void 0) {
|
|
1064
|
+
return null;
|
|
1065
|
+
}
|
|
1066
|
+
const value = Date.parse(iso);
|
|
1067
|
+
return Number.isNaN(value) ? null : value;
|
|
1068
|
+
};
|
|
1069
|
+
var NpmRegistryMetadataProvider = class {
|
|
1070
|
+
cache = /* @__PURE__ */ new Map();
|
|
1071
|
+
async getMetadata(name, version2) {
|
|
1072
|
+
const key = `${name}@${version2}`;
|
|
1073
|
+
if (this.cache.has(key)) {
|
|
1074
|
+
return this.cache.get(key) ?? null;
|
|
1075
|
+
}
|
|
1076
|
+
try {
|
|
1077
|
+
const encodedName = encodeURIComponent(name);
|
|
1078
|
+
const response = await fetch(`https://registry.npmjs.org/${encodedName}`);
|
|
1079
|
+
if (!response.ok) {
|
|
1080
|
+
this.cache.set(key, null);
|
|
1081
|
+
return null;
|
|
1082
|
+
}
|
|
1083
|
+
const payload = await response.json();
|
|
1084
|
+
const timeEntries = payload.time ?? {};
|
|
1085
|
+
const publishDates = Object.entries(timeEntries).filter(([tag]) => tag !== "created" && tag !== "modified").map(([, date]) => parseDate(date)).filter((value) => value !== null).sort((a, b) => a - b);
|
|
1086
|
+
const modifiedAt = parseDate(timeEntries["modified"]);
|
|
1087
|
+
const now = Date.now();
|
|
1088
|
+
const daysSinceLastRelease = modifiedAt === null ? null : Math.max(0, round42((now - modifiedAt) / ONE_DAY_MS));
|
|
1089
|
+
let releaseFrequencyDays = null;
|
|
1090
|
+
if (publishDates.length >= 2) {
|
|
1091
|
+
const totalIntervals = publishDates.length - 1;
|
|
1092
|
+
let sum = 0;
|
|
1093
|
+
for (let i = 1; i < publishDates.length; i += 1) {
|
|
1094
|
+
const current = publishDates[i];
|
|
1095
|
+
const previous = publishDates[i - 1];
|
|
1096
|
+
if (current !== void 0 && previous !== void 0) {
|
|
1097
|
+
sum += current - previous;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
releaseFrequencyDays = round42(sum / totalIntervals / ONE_DAY_MS);
|
|
1101
|
+
}
|
|
1102
|
+
const maintainers = payload.maintainers ?? [];
|
|
1103
|
+
const maintainerCount = maintainers.length > 0 ? maintainers.length : null;
|
|
1104
|
+
const metadata = {
|
|
1105
|
+
name,
|
|
1106
|
+
version: version2,
|
|
1107
|
+
maintainerCount,
|
|
1108
|
+
releaseFrequencyDays,
|
|
1109
|
+
daysSinceLastRelease,
|
|
1110
|
+
repositoryActivity30d: null,
|
|
1111
|
+
busFactor: null
|
|
1112
|
+
};
|
|
1113
|
+
this.cache.set(key, metadata);
|
|
1114
|
+
return metadata;
|
|
1115
|
+
} catch {
|
|
1116
|
+
this.cache.set(key, null);
|
|
1117
|
+
return null;
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
};
|
|
1121
|
+
var NoopMetadataProvider = class {
|
|
1122
|
+
async getMetadata(_name, _version) {
|
|
1123
|
+
return null;
|
|
1124
|
+
}
|
|
1125
|
+
};
|
|
1126
|
+
var analyzeDependencyExposureFromProject = async (input) => {
|
|
1127
|
+
const metadataProvider = process.env["CODESENTINEL_EXTERNAL_METADATA"] === "none" ? new NoopMetadataProvider() : new NpmRegistryMetadataProvider();
|
|
1128
|
+
return analyzeDependencyExposure(input, metadataProvider);
|
|
1129
|
+
};
|
|
1130
|
+
|
|
1131
|
+
// ../git-analyzer/dist/index.js
|
|
1132
|
+
import { execFileSync } from "child_process";
|
|
1133
|
+
var pairKey = (a, b) => `${a}\0${b}`;
|
|
1134
|
+
var round43 = (value) => Number(value.toFixed(4));
|
|
1135
|
+
var normalizeName = (value) => value.toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim();
|
|
1136
|
+
var extractEmailStem = (authorId) => {
|
|
1137
|
+
const normalized = authorId.trim().toLowerCase();
|
|
1138
|
+
const githubNoReplyMatch = normalized.match(/^\d+\+([^@]+)@users\.noreply\.github\.com$/);
|
|
1139
|
+
if (githubNoReplyMatch?.[1] !== void 0) {
|
|
1140
|
+
return githubNoReplyMatch[1].replace(/[._+-]/g, "");
|
|
1141
|
+
}
|
|
1142
|
+
const atIndex = normalized.indexOf("@");
|
|
1143
|
+
if (atIndex <= 0) {
|
|
1144
|
+
return null;
|
|
1145
|
+
}
|
|
1146
|
+
return normalized.slice(0, atIndex).replace(/[._+-]/g, "");
|
|
1147
|
+
};
|
|
1148
|
+
var areNamesCompatible = (left, right) => {
|
|
1149
|
+
if (left.length === 0 || right.length === 0) {
|
|
1150
|
+
return false;
|
|
1151
|
+
}
|
|
1152
|
+
if (left === right) {
|
|
1153
|
+
return true;
|
|
1154
|
+
}
|
|
1155
|
+
if (left.startsWith(`${right} `) || right.startsWith(`${left} `)) {
|
|
1156
|
+
return true;
|
|
1157
|
+
}
|
|
1158
|
+
return false;
|
|
1159
|
+
};
|
|
1160
|
+
var chooseCanonicalAuthorId = (profiles) => {
|
|
1161
|
+
const ordered = [...profiles].sort((a, b) => {
|
|
1162
|
+
const aIsNoReply = a.authorId.includes("@users.noreply.github.com");
|
|
1163
|
+
const bIsNoReply = b.authorId.includes("@users.noreply.github.com");
|
|
1164
|
+
if (aIsNoReply !== bIsNoReply) {
|
|
1165
|
+
return aIsNoReply ? 1 : -1;
|
|
1166
|
+
}
|
|
1167
|
+
if (a.commitCount !== b.commitCount) {
|
|
1168
|
+
return b.commitCount - a.commitCount;
|
|
1169
|
+
}
|
|
1170
|
+
return a.authorId.localeCompare(b.authorId);
|
|
1171
|
+
});
|
|
1172
|
+
return ordered[0]?.authorId ?? "";
|
|
1173
|
+
};
|
|
1174
|
+
var buildAuthorAliasMap = (commits) => {
|
|
1175
|
+
const nameCountsByAuthorId = /* @__PURE__ */ new Map();
|
|
1176
|
+
const commitCountByAuthorId = /* @__PURE__ */ new Map();
|
|
1177
|
+
for (const commit of commits) {
|
|
1178
|
+
commitCountByAuthorId.set(commit.authorId, (commitCountByAuthorId.get(commit.authorId) ?? 0) + 1);
|
|
1179
|
+
const normalizedName = normalizeName(commit.authorName);
|
|
1180
|
+
const names = nameCountsByAuthorId.get(commit.authorId) ?? /* @__PURE__ */ new Map();
|
|
1181
|
+
if (normalizedName.length > 0) {
|
|
1182
|
+
names.set(normalizedName, (names.get(normalizedName) ?? 0) + 1);
|
|
1183
|
+
}
|
|
1184
|
+
nameCountsByAuthorId.set(commit.authorId, names);
|
|
1185
|
+
}
|
|
1186
|
+
const profiles = [...commitCountByAuthorId.entries()].map(([authorId, commitCount]) => {
|
|
1187
|
+
const names = nameCountsByAuthorId.get(authorId);
|
|
1188
|
+
const primaryName = names === void 0 ? "" : [...names.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]?.[0] ?? "";
|
|
1189
|
+
const normalizedAuthorId = authorId.toLowerCase();
|
|
1190
|
+
const isBot = normalizedAuthorId.includes("[bot]");
|
|
1191
|
+
return {
|
|
1192
|
+
authorId,
|
|
1193
|
+
commitCount,
|
|
1194
|
+
primaryName,
|
|
1195
|
+
emailStem: isBot ? null : extractEmailStem(authorId),
|
|
1196
|
+
isBot
|
|
1197
|
+
};
|
|
1198
|
+
});
|
|
1199
|
+
const groupsByStem = /* @__PURE__ */ new Map();
|
|
1200
|
+
for (const profile of profiles) {
|
|
1201
|
+
if (profile.emailStem === null || profile.emailStem.length < 4) {
|
|
1202
|
+
continue;
|
|
1203
|
+
}
|
|
1204
|
+
const current = groupsByStem.get(profile.emailStem) ?? [];
|
|
1205
|
+
current.push(profile);
|
|
1206
|
+
groupsByStem.set(profile.emailStem, current);
|
|
1207
|
+
}
|
|
1208
|
+
const aliasMap = /* @__PURE__ */ new Map();
|
|
1209
|
+
for (const profile of profiles) {
|
|
1210
|
+
aliasMap.set(profile.authorId, profile.authorId);
|
|
1211
|
+
}
|
|
1212
|
+
for (const group of groupsByStem.values()) {
|
|
1213
|
+
if (group.length < 2) {
|
|
1214
|
+
continue;
|
|
1215
|
+
}
|
|
1216
|
+
const compatible = [];
|
|
1217
|
+
for (const profile of group) {
|
|
1218
|
+
if (profile.isBot || profile.primaryName.length === 0) {
|
|
1219
|
+
continue;
|
|
1220
|
+
}
|
|
1221
|
+
compatible.push(profile);
|
|
1222
|
+
}
|
|
1223
|
+
if (compatible.length < 2) {
|
|
1224
|
+
continue;
|
|
1225
|
+
}
|
|
1226
|
+
const canonical = chooseCanonicalAuthorId(compatible);
|
|
1227
|
+
const canonicalProfile = compatible.find((candidate) => candidate.authorId === canonical);
|
|
1228
|
+
if (canonicalProfile === void 0) {
|
|
1229
|
+
continue;
|
|
1230
|
+
}
|
|
1231
|
+
for (const profile of compatible) {
|
|
1232
|
+
if (areNamesCompatible(profile.primaryName, canonicalProfile.primaryName)) {
|
|
1233
|
+
aliasMap.set(profile.authorId, canonical);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
return aliasMap;
|
|
1238
|
+
};
|
|
1239
|
+
var computeBusFactor = (authorDistribution, threshold) => {
|
|
1240
|
+
if (authorDistribution.length === 0) {
|
|
1241
|
+
return 0;
|
|
1242
|
+
}
|
|
1243
|
+
let coveredShare = 0;
|
|
1244
|
+
for (let i = 0; i < authorDistribution.length; i += 1) {
|
|
1245
|
+
const entry = authorDistribution[i];
|
|
1246
|
+
if (entry === void 0) {
|
|
1247
|
+
continue;
|
|
1248
|
+
}
|
|
1249
|
+
coveredShare += entry.share;
|
|
1250
|
+
if (coveredShare >= threshold) {
|
|
1251
|
+
return i + 1;
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
return authorDistribution.length;
|
|
1255
|
+
};
|
|
1256
|
+
var finalizeAuthorDistribution = (authorCommits) => {
|
|
1257
|
+
const totalCommits = [...authorCommits.values()].reduce((sum, value) => sum + value, 0);
|
|
1258
|
+
if (totalCommits === 0) {
|
|
1259
|
+
return [];
|
|
1260
|
+
}
|
|
1261
|
+
return [...authorCommits.entries()].map(([authorId, commits]) => ({
|
|
1262
|
+
authorId,
|
|
1263
|
+
commits,
|
|
1264
|
+
share: round43(commits / totalCommits)
|
|
1265
|
+
})).sort((a, b) => b.commits - a.commits || a.authorId.localeCompare(b.authorId));
|
|
1266
|
+
};
|
|
1267
|
+
var buildCouplingMatrix = (coChangeByPair, fileCommitCount, consideredCommits, skippedLargeCommits, maxCouplingPairs) => {
|
|
1268
|
+
const allPairs = [];
|
|
1269
|
+
for (const [key, coChangeCommits] of coChangeByPair.entries()) {
|
|
1270
|
+
const [fileA, fileB] = key.split("\0");
|
|
1271
|
+
if (fileA === void 0 || fileB === void 0) {
|
|
1272
|
+
continue;
|
|
1273
|
+
}
|
|
1274
|
+
const fileACommits = fileCommitCount.get(fileA) ?? 0;
|
|
1275
|
+
const fileBCommits = fileCommitCount.get(fileB) ?? 0;
|
|
1276
|
+
const denominator = fileACommits + fileBCommits - coChangeCommits;
|
|
1277
|
+
const couplingScore = denominator === 0 ? 0 : round43(coChangeCommits / denominator);
|
|
1278
|
+
allPairs.push({
|
|
1279
|
+
fileA,
|
|
1280
|
+
fileB,
|
|
1281
|
+
coChangeCommits,
|
|
1282
|
+
couplingScore
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1285
|
+
allPairs.sort(
|
|
1286
|
+
(a, b) => b.coChangeCommits - a.coChangeCommits || b.couplingScore - a.couplingScore || a.fileA.localeCompare(b.fileA) || a.fileB.localeCompare(b.fileB)
|
|
1287
|
+
);
|
|
1288
|
+
const truncated = allPairs.length > maxCouplingPairs;
|
|
1289
|
+
return {
|
|
1290
|
+
pairs: truncated ? allPairs.slice(0, maxCouplingPairs) : allPairs,
|
|
1291
|
+
totalPairCount: allPairs.length,
|
|
1292
|
+
consideredCommits,
|
|
1293
|
+
skippedLargeCommits,
|
|
1294
|
+
truncated
|
|
1295
|
+
};
|
|
1296
|
+
};
|
|
1297
|
+
var selectHotspots = (files, config) => {
|
|
1298
|
+
if (files.length === 0) {
|
|
1299
|
+
return { hotspots: [], threshold: 0 };
|
|
1300
|
+
}
|
|
1301
|
+
const sorted = [...files].sort(
|
|
1302
|
+
(a, b) => b.commitCount - a.commitCount || b.churnTotal - a.churnTotal || a.filePath.localeCompare(b.filePath)
|
|
1303
|
+
);
|
|
1304
|
+
const hotspotCount = Math.max(config.hotspotMinFiles, Math.ceil(sorted.length * config.hotspotTopPercent));
|
|
1305
|
+
const selected = sorted.slice(0, hotspotCount);
|
|
1306
|
+
const hotspots = selected.map((file, index) => ({
|
|
1307
|
+
filePath: file.filePath,
|
|
1308
|
+
rank: index + 1,
|
|
1309
|
+
commitCount: file.commitCount,
|
|
1310
|
+
churnTotal: file.churnTotal
|
|
1311
|
+
}));
|
|
1312
|
+
const threshold = selected[selected.length - 1]?.commitCount ?? 0;
|
|
1313
|
+
return { hotspots, threshold };
|
|
1314
|
+
};
|
|
1315
|
+
var computeRepositoryEvolutionSummary = (targetPath, commits, config) => {
|
|
1316
|
+
const authorAliasById = config.authorIdentityMode === "likely_merge" ? buildAuthorAliasMap(commits) : /* @__PURE__ */ new Map();
|
|
1317
|
+
const fileStats = /* @__PURE__ */ new Map();
|
|
1318
|
+
const coChangeByPair = /* @__PURE__ */ new Map();
|
|
1319
|
+
const headCommitTimestamp = commits.length === 0 ? null : commits[commits.length - 1]?.authoredAtUnix ?? null;
|
|
1320
|
+
const recentWindowStart = headCommitTimestamp === null ? Number.NEGATIVE_INFINITY : headCommitTimestamp - config.recentWindowDays * 24 * 60 * 60;
|
|
1321
|
+
let consideredCommits = 0;
|
|
1322
|
+
let skippedLargeCommits = 0;
|
|
1323
|
+
for (const commit of commits) {
|
|
1324
|
+
const uniqueFiles = /* @__PURE__ */ new Set();
|
|
1325
|
+
for (const fileChange of commit.fileChanges) {
|
|
1326
|
+
uniqueFiles.add(fileChange.filePath);
|
|
1327
|
+
const current = fileStats.get(fileChange.filePath) ?? {
|
|
1328
|
+
commitCount: 0,
|
|
1329
|
+
recentCommitCount: 0,
|
|
1330
|
+
churnAdded: 0,
|
|
1331
|
+
churnDeleted: 0,
|
|
1332
|
+
authors: /* @__PURE__ */ new Map()
|
|
1333
|
+
};
|
|
1334
|
+
current.churnAdded += fileChange.additions;
|
|
1335
|
+
current.churnDeleted += fileChange.deletions;
|
|
1336
|
+
fileStats.set(fileChange.filePath, current);
|
|
1337
|
+
}
|
|
1338
|
+
for (const filePath of uniqueFiles) {
|
|
1339
|
+
const current = fileStats.get(filePath);
|
|
1340
|
+
if (current === void 0) {
|
|
1341
|
+
continue;
|
|
1342
|
+
}
|
|
1343
|
+
current.commitCount += 1;
|
|
1344
|
+
if (commit.authoredAtUnix >= recentWindowStart) {
|
|
1345
|
+
current.recentCommitCount += 1;
|
|
1346
|
+
}
|
|
1347
|
+
const effectiveAuthorId = authorAliasById.get(commit.authorId) ?? commit.authorId;
|
|
1348
|
+
current.authors.set(effectiveAuthorId, (current.authors.get(effectiveAuthorId) ?? 0) + 1);
|
|
1349
|
+
}
|
|
1350
|
+
const orderedFiles = [...uniqueFiles].sort((a, b) => a.localeCompare(b));
|
|
1351
|
+
if (orderedFiles.length > 1) {
|
|
1352
|
+
if (orderedFiles.length <= config.maxFilesPerCommitForCoupling) {
|
|
1353
|
+
consideredCommits += 1;
|
|
1354
|
+
for (let i = 0; i < orderedFiles.length - 1; i += 1) {
|
|
1355
|
+
for (let j = i + 1; j < orderedFiles.length; j += 1) {
|
|
1356
|
+
const fileA = orderedFiles[i];
|
|
1357
|
+
const fileB = orderedFiles[j];
|
|
1358
|
+
if (fileA === void 0 || fileB === void 0) {
|
|
1359
|
+
continue;
|
|
1360
|
+
}
|
|
1361
|
+
const key = pairKey(fileA, fileB);
|
|
1362
|
+
coChangeByPair.set(key, (coChangeByPair.get(key) ?? 0) + 1);
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
} else {
|
|
1366
|
+
skippedLargeCommits += 1;
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
const files = [...fileStats.entries()].map(([filePath, stats]) => {
|
|
1371
|
+
const authorDistribution = finalizeAuthorDistribution(stats.authors);
|
|
1372
|
+
const topAuthorShare = authorDistribution[0]?.share ?? 0;
|
|
1373
|
+
return {
|
|
1374
|
+
filePath,
|
|
1375
|
+
commitCount: stats.commitCount,
|
|
1376
|
+
frequencyPer100Commits: commits.length === 0 ? 0 : round43(stats.commitCount / commits.length * 100),
|
|
1377
|
+
churnAdded: stats.churnAdded,
|
|
1378
|
+
churnDeleted: stats.churnDeleted,
|
|
1379
|
+
churnTotal: stats.churnAdded + stats.churnDeleted,
|
|
1380
|
+
recentCommitCount: stats.recentCommitCount,
|
|
1381
|
+
recentVolatility: stats.commitCount === 0 ? 0 : round43(stats.recentCommitCount / stats.commitCount),
|
|
1382
|
+
topAuthorShare,
|
|
1383
|
+
busFactor: computeBusFactor(authorDistribution, config.busFactorCoverageThreshold),
|
|
1384
|
+
authorDistribution
|
|
1385
|
+
};
|
|
1386
|
+
}).sort((a, b) => a.filePath.localeCompare(b.filePath));
|
|
1387
|
+
const fileCommitCount = new Map(files.map((file) => [file.filePath, file.commitCount]));
|
|
1388
|
+
const coupling = buildCouplingMatrix(
|
|
1389
|
+
coChangeByPair,
|
|
1390
|
+
fileCommitCount,
|
|
1391
|
+
consideredCommits,
|
|
1392
|
+
skippedLargeCommits,
|
|
1393
|
+
config.maxCouplingPairs
|
|
1394
|
+
);
|
|
1395
|
+
const { hotspots, threshold } = selectHotspots(files, config);
|
|
1396
|
+
return {
|
|
1397
|
+
targetPath,
|
|
1398
|
+
available: true,
|
|
1399
|
+
files,
|
|
1400
|
+
hotspots,
|
|
1401
|
+
coupling,
|
|
1402
|
+
metrics: {
|
|
1403
|
+
totalCommits: commits.length,
|
|
1404
|
+
totalFiles: files.length,
|
|
1405
|
+
headCommitTimestamp,
|
|
1406
|
+
recentWindowDays: config.recentWindowDays,
|
|
1407
|
+
hotspotTopPercent: config.hotspotTopPercent,
|
|
1408
|
+
hotspotThresholdCommitCount: threshold
|
|
1409
|
+
}
|
|
1410
|
+
};
|
|
1411
|
+
};
|
|
1412
|
+
var DEFAULT_EVOLUTION_CONFIG = {
|
|
1413
|
+
authorIdentityMode: "likely_merge",
|
|
1414
|
+
recentWindowDays: 30,
|
|
1415
|
+
hotspotTopPercent: 0.1,
|
|
1416
|
+
hotspotMinFiles: 1,
|
|
1417
|
+
maxFilesPerCommitForCoupling: 200,
|
|
1418
|
+
maxCouplingPairs: 500,
|
|
1419
|
+
busFactorCoverageThreshold: 0.6
|
|
1420
|
+
};
|
|
1421
|
+
var createEffectiveConfig = (overrides) => ({
|
|
1422
|
+
...DEFAULT_EVOLUTION_CONFIG,
|
|
1423
|
+
...overrides
|
|
1424
|
+
});
|
|
1425
|
+
var analyzeRepositoryEvolution = (input, historyProvider) => {
|
|
1426
|
+
if (!historyProvider.isGitRepository(input.repositoryPath)) {
|
|
1427
|
+
return {
|
|
1428
|
+
targetPath: input.repositoryPath,
|
|
1429
|
+
available: false,
|
|
1430
|
+
reason: "not_git_repository"
|
|
1431
|
+
};
|
|
1432
|
+
}
|
|
1433
|
+
const commits = historyProvider.getCommitHistory(input.repositoryPath);
|
|
1434
|
+
const config = createEffectiveConfig(input.config);
|
|
1435
|
+
return computeRepositoryEvolutionSummary(input.repositoryPath, commits, config);
|
|
1436
|
+
};
|
|
1437
|
+
var GitCommandError = class extends Error {
|
|
1438
|
+
args;
|
|
1439
|
+
constructor(message, args) {
|
|
1440
|
+
super(message);
|
|
1441
|
+
this.name = "GitCommandError";
|
|
1442
|
+
this.args = args;
|
|
1443
|
+
}
|
|
1444
|
+
};
|
|
1445
|
+
var ExecGitCommandClient = class {
|
|
1446
|
+
run(repositoryPath, args) {
|
|
1447
|
+
try {
|
|
1448
|
+
return execFileSync("git", ["-C", repositoryPath, ...args], {
|
|
1449
|
+
encoding: "utf8",
|
|
1450
|
+
maxBuffer: 1024 * 1024 * 64,
|
|
1451
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1452
|
+
});
|
|
1453
|
+
} catch (error) {
|
|
1454
|
+
const message = error instanceof Error ? error.message : "Unknown git execution error";
|
|
1455
|
+
throw new GitCommandError(message, args);
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
};
|
|
1459
|
+
var COMMIT_RECORD_SEPARATOR = "";
|
|
1460
|
+
var COMMIT_FIELD_SEPARATOR = "";
|
|
1461
|
+
var GIT_LOG_FORMAT = `%x1e%H%x1f%at%x1f%an%x1f%ae`;
|
|
1462
|
+
var parseInteger = (value) => {
|
|
1463
|
+
if (value.length === 0) {
|
|
1464
|
+
return null;
|
|
1465
|
+
}
|
|
1466
|
+
const parsed = Number.parseInt(value, 10);
|
|
1467
|
+
if (Number.isNaN(parsed)) {
|
|
1468
|
+
return null;
|
|
1469
|
+
}
|
|
1470
|
+
return parsed;
|
|
1471
|
+
};
|
|
1472
|
+
var normalizeAuthorIdentity = (authorName, authorEmail) => {
|
|
1473
|
+
const normalizedName = authorName.trim().replace(/\s+/g, " ").toLowerCase();
|
|
1474
|
+
const normalizedEmail = authorEmail.trim().toLowerCase();
|
|
1475
|
+
if (/\[bot\]/i.test(normalizedName) || /\[bot\]/i.test(normalizedEmail)) {
|
|
1476
|
+
return normalizedEmail.length > 0 ? normalizedEmail : normalizedName;
|
|
1477
|
+
}
|
|
1478
|
+
const githubNoReplyMatch = normalizedEmail.match(/^\d+\+([^@]+)@users\.noreply\.github\.com$/);
|
|
1479
|
+
const githubHandle = githubNoReplyMatch?.[1]?.trim().toLowerCase();
|
|
1480
|
+
if (githubHandle !== void 0 && githubHandle.length > 0) {
|
|
1481
|
+
return `${githubHandle}@users.noreply.github.com`;
|
|
1482
|
+
}
|
|
1483
|
+
if (normalizedEmail.length > 0) {
|
|
1484
|
+
return normalizedEmail;
|
|
1485
|
+
}
|
|
1486
|
+
return normalizedName;
|
|
1487
|
+
};
|
|
1488
|
+
var parseRenamedPath = (pathSpec) => {
|
|
1489
|
+
if (!pathSpec.includes(" => ")) {
|
|
1490
|
+
return pathSpec;
|
|
1491
|
+
}
|
|
1492
|
+
const braceRenameMatch = pathSpec.match(/^(.*)\{(.+) => (.+)\}(.*)$/);
|
|
1493
|
+
if (braceRenameMatch !== null) {
|
|
1494
|
+
const [, prefix, , renamedTo, suffix] = braceRenameMatch;
|
|
1495
|
+
return `${prefix}${renamedTo}${suffix}`;
|
|
1496
|
+
}
|
|
1497
|
+
const parts = pathSpec.split(" => ");
|
|
1498
|
+
const finalPart = parts[parts.length - 1];
|
|
1499
|
+
return finalPart ?? pathSpec;
|
|
1500
|
+
};
|
|
1501
|
+
var parseNumstatLine = (line) => {
|
|
1502
|
+
const parts = line.split(" ");
|
|
1503
|
+
if (parts.length < 3) {
|
|
1504
|
+
return null;
|
|
1505
|
+
}
|
|
1506
|
+
const additionsRaw = parts[0];
|
|
1507
|
+
const deletionsRaw = parts[1];
|
|
1508
|
+
const pathRaw = parts.slice(2).join(" ");
|
|
1509
|
+
if (additionsRaw === void 0 || deletionsRaw === void 0) {
|
|
1510
|
+
return null;
|
|
1511
|
+
}
|
|
1512
|
+
const additions = additionsRaw === "-" ? 0 : parseInteger(additionsRaw);
|
|
1513
|
+
const deletions = deletionsRaw === "-" ? 0 : parseInteger(deletionsRaw);
|
|
1514
|
+
if (additions === null || deletions === null) {
|
|
1515
|
+
return null;
|
|
1516
|
+
}
|
|
1517
|
+
const filePath = parseRenamedPath(pathRaw);
|
|
1518
|
+
return {
|
|
1519
|
+
filePath,
|
|
1520
|
+
additions,
|
|
1521
|
+
deletions
|
|
1522
|
+
};
|
|
1523
|
+
};
|
|
1524
|
+
var parseGitLog = (rawLog) => {
|
|
1525
|
+
const records = rawLog.split(COMMIT_RECORD_SEPARATOR).map((record) => record.trim()).filter((record) => record.length > 0);
|
|
1526
|
+
const commits = [];
|
|
1527
|
+
for (const record of records) {
|
|
1528
|
+
const lines = record.split("\n").map((line) => line.trimEnd()).filter((line) => line.length > 0);
|
|
1529
|
+
if (lines.length === 0) {
|
|
1530
|
+
continue;
|
|
1531
|
+
}
|
|
1532
|
+
const headerParts = lines[0]?.split(COMMIT_FIELD_SEPARATOR) ?? [];
|
|
1533
|
+
if (headerParts.length !== 4) {
|
|
1534
|
+
continue;
|
|
1535
|
+
}
|
|
1536
|
+
const [hash, authoredAtRaw, authorName, authorEmail] = headerParts;
|
|
1537
|
+
if (hash === void 0 || authoredAtRaw === void 0 || authorName === void 0 || authorEmail === void 0) {
|
|
1538
|
+
continue;
|
|
1539
|
+
}
|
|
1540
|
+
const authoredAtUnix = parseInteger(authoredAtRaw);
|
|
1541
|
+
if (authoredAtUnix === null) {
|
|
1542
|
+
continue;
|
|
1543
|
+
}
|
|
1544
|
+
const fileChanges = [];
|
|
1545
|
+
for (const line of lines.slice(1)) {
|
|
1546
|
+
const parsedLine = parseNumstatLine(line);
|
|
1547
|
+
if (parsedLine !== null) {
|
|
1548
|
+
fileChanges.push(parsedLine);
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
commits.push({
|
|
1552
|
+
hash,
|
|
1553
|
+
authorId: normalizeAuthorIdentity(authorName, authorEmail),
|
|
1554
|
+
authorName,
|
|
1555
|
+
authoredAtUnix,
|
|
1556
|
+
fileChanges
|
|
1557
|
+
});
|
|
1558
|
+
}
|
|
1559
|
+
commits.sort((a, b) => a.authoredAtUnix - b.authoredAtUnix || a.hash.localeCompare(b.hash));
|
|
1560
|
+
return commits;
|
|
1561
|
+
};
|
|
1562
|
+
var NON_GIT_CODES = ["not a git repository", "not in a git directory"];
|
|
1563
|
+
var isNotGitError = (error) => {
|
|
1564
|
+
const lower = error.message.toLowerCase();
|
|
1565
|
+
return NON_GIT_CODES.some((code) => lower.includes(code));
|
|
1566
|
+
};
|
|
1567
|
+
var GitCliHistoryProvider = class {
|
|
1568
|
+
constructor(gitClient) {
|
|
1569
|
+
this.gitClient = gitClient;
|
|
1570
|
+
}
|
|
1571
|
+
isGitRepository(repositoryPath) {
|
|
1572
|
+
try {
|
|
1573
|
+
const output = this.gitClient.run(repositoryPath, ["rev-parse", "--is-inside-work-tree"]);
|
|
1574
|
+
return output.trim() === "true";
|
|
1575
|
+
} catch (error) {
|
|
1576
|
+
if (error instanceof GitCommandError && isNotGitError(error)) {
|
|
1577
|
+
return false;
|
|
1578
|
+
}
|
|
1579
|
+
throw error;
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
getCommitHistory(repositoryPath) {
|
|
1583
|
+
const output = this.gitClient.run(repositoryPath, [
|
|
1584
|
+
"-c",
|
|
1585
|
+
"core.quotepath=false",
|
|
1586
|
+
"log",
|
|
1587
|
+
"--use-mailmap",
|
|
1588
|
+
"--no-merges",
|
|
1589
|
+
"--date=unix",
|
|
1590
|
+
`--pretty=format:${GIT_LOG_FORMAT}`,
|
|
1591
|
+
"--numstat",
|
|
1592
|
+
"--find-renames"
|
|
1593
|
+
]);
|
|
1594
|
+
return parseGitLog(output);
|
|
1595
|
+
}
|
|
1596
|
+
};
|
|
1597
|
+
var analyzeRepositoryEvolutionFromGit = (input) => {
|
|
1598
|
+
const historyProvider = new GitCliHistoryProvider(new ExecGitCommandClient());
|
|
1599
|
+
return analyzeRepositoryEvolution(input, historyProvider);
|
|
453
1600
|
};
|
|
454
1601
|
|
|
455
1602
|
// src/application/run-analyze-command.ts
|
|
456
|
-
var
|
|
1603
|
+
var resolveTargetPath = (inputPath, cwd) => resolve2(cwd, inputPath ?? ".");
|
|
1604
|
+
var runAnalyzeCommand = async (inputPath, authorIdentityMode) => {
|
|
457
1605
|
const invocationCwd = process.env["INIT_CWD"] ?? process.cwd();
|
|
458
|
-
const
|
|
459
|
-
const
|
|
1606
|
+
const targetPath = resolveTargetPath(inputPath, invocationCwd);
|
|
1607
|
+
const structural = buildProjectGraphSummary({ projectPath: targetPath });
|
|
1608
|
+
const evolution = analyzeRepositoryEvolutionFromGit({
|
|
1609
|
+
repositoryPath: targetPath,
|
|
1610
|
+
config: { authorIdentityMode }
|
|
1611
|
+
});
|
|
1612
|
+
const external = await analyzeDependencyExposureFromProject({ repositoryPath: targetPath });
|
|
1613
|
+
const summary = {
|
|
1614
|
+
structural,
|
|
1615
|
+
evolution,
|
|
1616
|
+
external
|
|
1617
|
+
};
|
|
460
1618
|
return JSON.stringify(summary, null, 2);
|
|
461
1619
|
};
|
|
462
1620
|
|
|
463
1621
|
// src/index.ts
|
|
464
1622
|
var program = new Command();
|
|
465
1623
|
var packageJsonPath = resolve3(dirname(fileURLToPath(import.meta.url)), "../package.json");
|
|
466
|
-
var { version } = JSON.parse(
|
|
1624
|
+
var { version } = JSON.parse(readFileSync2(packageJsonPath, "utf8"));
|
|
467
1625
|
program.name("codesentinel").description("Structural and evolutionary risk analysis for TypeScript/JavaScript codebases").version(version);
|
|
468
|
-
program.command("analyze").argument("[path]", "path to the project to analyze").
|
|
469
|
-
|
|
1626
|
+
program.command("analyze").argument("[path]", "path to the project to analyze").addOption(
|
|
1627
|
+
new Option(
|
|
1628
|
+
"--author-identity <mode>",
|
|
1629
|
+
"author identity mode: likely_merge (heuristic) or strict_email (deterministic)"
|
|
1630
|
+
).choices(["likely_merge", "strict_email"]).default("likely_merge")
|
|
1631
|
+
).action(async (path, options) => {
|
|
1632
|
+
const output = await runAnalyzeCommand(path, options.authorIdentity);
|
|
470
1633
|
process.stdout.write(`${output}
|
|
471
1634
|
`);
|
|
472
1635
|
});
|
|
@@ -474,5 +1637,5 @@ if (process.argv.length <= 2) {
|
|
|
474
1637
|
program.outputHelp();
|
|
475
1638
|
process.exit(0);
|
|
476
1639
|
}
|
|
477
|
-
program.
|
|
1640
|
+
await program.parseAsync(process.argv);
|
|
478
1641
|
//# sourceMappingURL=index.js.map
|