@getcodesentinel/codesentinel 1.16.0 → 1.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -7,6 +7,12 @@ import { Command, Option } from "commander";
7
7
  import { existsSync, readFileSync } from "fs";
8
8
  import { join } from "path";
9
9
  import { setTimeout as sleep } from "timers/promises";
10
+ import { join as join4 } from "path";
11
+ import { homedir } from "os";
12
+ import { join as join2 } from "path";
13
+ import { createHash } from "crypto";
14
+ import { mkdir, readdir, readFile, rename, stat, unlink, writeFile } from "fs/promises";
15
+ import { join as join3 } from "path";
10
16
  var round4 = (value) => Number(value.toFixed(4));
11
17
  var normalizeNodes = (nodes) => {
12
18
  const byName = /* @__PURE__ */ new Map();
@@ -614,6 +620,23 @@ var parseLockfileExtraction = (lockfileKind, lockfileRaw, directSpecs) => {
614
620
  throw new Error("unsupported_lockfile_format");
615
621
  }
616
622
  };
623
+ var cachedFetch = async (options) => {
624
+ const nowMs = options.nowMs ?? Date.now();
625
+ const cachedEntry = options.cacheStore === null ? null : await options.cacheStore.get(options.key);
626
+ if (cachedEntry !== null && nowMs - cachedEntry.fetchedAtMs <= options.ttlMs) {
627
+ return cachedEntry.value;
628
+ }
629
+ try {
630
+ const fresh = await options.fetchFresh();
631
+ if (fresh !== null) {
632
+ void options.cacheStore?.set(options.key, { value: fresh, fetchedAtMs: nowMs }).catch(() => {
633
+ });
634
+ return fresh;
635
+ }
636
+ } catch {
637
+ }
638
+ return cachedEntry?.value ?? null;
639
+ };
617
640
  var parseRetryAfterMs = (value) => {
618
641
  if (value === null) {
619
642
  return null;
@@ -640,8 +663,202 @@ var fetchJsonWithRetry = async (url, options) => {
640
663
  }
641
664
  return null;
642
665
  };
666
+ var resolveCodesentinelCacheDir = (env = process.env) => {
667
+ const explicit = env["CODESENTINEL_CACHE_DIR"]?.trim();
668
+ if (explicit !== void 0 && explicit.length > 0) {
669
+ return explicit;
670
+ }
671
+ if (process.platform === "win32") {
672
+ const localAppData = env["LOCALAPPDATA"]?.trim();
673
+ if (localAppData !== void 0 && localAppData.length > 0) {
674
+ return join2(localAppData, "codesentinel", "Cache");
675
+ }
676
+ return join2(homedir(), "AppData", "Local", "codesentinel", "Cache");
677
+ }
678
+ const xdgCacheHome = env["XDG_CACHE_HOME"]?.trim();
679
+ if (xdgCacheHome !== void 0 && xdgCacheHome.length > 0) {
680
+ return join2(xdgCacheHome, "codesentinel");
681
+ }
682
+ return join2(homedir(), ".cache", "codesentinel");
683
+ };
684
+ var parseCacheEntryPayload = (value) => {
685
+ if (typeof value !== "object" || value === null) {
686
+ return null;
687
+ }
688
+ const payload = value;
689
+ if (typeof payload.key !== "string" || payload.key.length === 0) {
690
+ return null;
691
+ }
692
+ if (typeof payload.fetchedAtMs !== "number" || !Number.isFinite(payload.fetchedAtMs)) {
693
+ return null;
694
+ }
695
+ return {
696
+ key: payload.key,
697
+ fetchedAtMs: payload.fetchedAtMs,
698
+ value: payload.value
699
+ };
700
+ };
701
+ var DEFAULT_OPTIONS = {
702
+ maxBytes: 100 * 1024 * 1024,
703
+ maxEntryBytes: 4 * 1024 * 1024,
704
+ sweepIntervalWrites: 25
705
+ };
706
+ var FileCacheStore = class {
707
+ constructor(directoryPath, options = DEFAULT_OPTIONS) {
708
+ this.directoryPath = directoryPath;
709
+ this.options = options;
710
+ }
711
+ byKey = /* @__PURE__ */ new Map();
712
+ inFlightWrites = /* @__PURE__ */ new Map();
713
+ writesSinceSweep = 0;
714
+ sweepPromise = Promise.resolve();
715
+ toEntryPath(key) {
716
+ const digest = createHash("sha256").update(key).digest("hex");
717
+ return join3(this.directoryPath, `${digest}.json`);
718
+ }
719
+ async writeEntry(key, entry) {
720
+ const filePath = this.toEntryPath(key);
721
+ await mkdir(this.directoryPath, { recursive: true });
722
+ const tempPath = `${filePath}.tmp`;
723
+ const payload = {
724
+ key,
725
+ fetchedAtMs: entry.fetchedAtMs,
726
+ value: entry.value
727
+ };
728
+ const raw = JSON.stringify(payload);
729
+ if (Buffer.byteLength(raw, "utf8") > this.options.maxEntryBytes) {
730
+ return;
731
+ }
732
+ await writeFile(tempPath, raw, "utf8");
733
+ await rename(tempPath, filePath);
734
+ }
735
+ async sweepIfNeeded() {
736
+ this.writesSinceSweep += 1;
737
+ if (this.writesSinceSweep < this.options.sweepIntervalWrites) {
738
+ return;
739
+ }
740
+ this.writesSinceSweep = 0;
741
+ this.sweepPromise = this.sweepPromise.catch(() => {
742
+ }).then(async () => {
743
+ await this.evictToSizeLimit();
744
+ });
745
+ await this.sweepPromise;
746
+ }
747
+ async evictToSizeLimit() {
748
+ let entries;
749
+ try {
750
+ const dirEntries = await readdir(this.directoryPath, { withFileTypes: true });
751
+ entries = (await Promise.all(
752
+ dirEntries.filter((entry) => entry.isFile() && entry.name.endsWith(".json")).map(async (entry) => {
753
+ const path = join3(this.directoryPath, entry.name);
754
+ const info = await stat(path);
755
+ return { path, size: info.size, mtimeMs: info.mtimeMs };
756
+ })
757
+ )).filter((entry) => Number.isFinite(entry.size) && entry.size > 0);
758
+ } catch {
759
+ return;
760
+ }
761
+ let totalBytes = entries.reduce((sum, entry) => sum + entry.size, 0);
762
+ if (totalBytes <= this.options.maxBytes) {
763
+ return;
764
+ }
765
+ entries.sort((a, b) => a.mtimeMs - b.mtimeMs);
766
+ for (const entry of entries) {
767
+ if (totalBytes <= this.options.maxBytes) {
768
+ break;
769
+ }
770
+ try {
771
+ await unlink(entry.path);
772
+ totalBytes -= entry.size;
773
+ } catch {
774
+ }
775
+ }
776
+ }
777
+ async get(key) {
778
+ if (this.byKey.has(key)) {
779
+ return this.byKey.get(key) ?? null;
780
+ }
781
+ try {
782
+ const raw = await readFile(this.toEntryPath(key), "utf8");
783
+ const parsed = parseCacheEntryPayload(JSON.parse(raw));
784
+ if (parsed === null || parsed.key !== key) {
785
+ this.byKey.set(key, null);
786
+ return null;
787
+ }
788
+ const value = { fetchedAtMs: parsed.fetchedAtMs, value: parsed.value };
789
+ this.byKey.set(key, value);
790
+ return value;
791
+ } catch {
792
+ this.byKey.set(key, null);
793
+ return null;
794
+ }
795
+ }
796
+ async set(key, entry) {
797
+ const normalized = entry;
798
+ this.byKey.set(key, normalized);
799
+ const previous = this.inFlightWrites.get(key) ?? Promise.resolve();
800
+ const next = previous.catch(() => {
801
+ }).then(async () => {
802
+ await this.writeEntry(key, normalized);
803
+ await this.sweepIfNeeded();
804
+ });
805
+ this.inFlightWrites.set(key, next);
806
+ await next;
807
+ }
808
+ };
809
+ var SIX_HOURS_MS = 6 * 60 * 60 * 1e3;
810
+ var ONE_DAY_MS = 24 * 60 * 60 * 1e3;
811
+ var DEFAULT_MAX_BYTES = 100 * 1024 * 1024;
812
+ var DEFAULT_MAX_ENTRY_BYTES = 4 * 1024 * 1024;
813
+ var DEFAULT_SWEEP_INTERVAL_WRITES = 25;
814
+ var cacheStoreSingleton;
815
+ var parsePositiveIntegerFromEnv = (value, fallback) => {
816
+ if (value === void 0) {
817
+ return fallback;
818
+ }
819
+ const parsed = Number.parseInt(value, 10);
820
+ if (!Number.isFinite(parsed) || parsed <= 0) {
821
+ return fallback;
822
+ }
823
+ return parsed;
824
+ };
825
+ var cacheDisabled = (env) => {
826
+ const mode = env["CODESENTINEL_CACHE_MODE"]?.trim().toLowerCase();
827
+ return mode === "none";
828
+ };
829
+ var getNpmMetadataCacheStore = () => {
830
+ if (cacheStoreSingleton !== void 0) {
831
+ return cacheStoreSingleton;
832
+ }
833
+ if (cacheDisabled(process.env)) {
834
+ cacheStoreSingleton = null;
835
+ return cacheStoreSingleton;
836
+ }
837
+ const path = join4(resolveCodesentinelCacheDir(process.env), "npm-metadata-v2");
838
+ cacheStoreSingleton = new FileCacheStore(path, {
839
+ maxBytes: parsePositiveIntegerFromEnv(
840
+ process.env["CODESENTINEL_CACHE_MAX_BYTES"],
841
+ DEFAULT_MAX_BYTES
842
+ ),
843
+ maxEntryBytes: parsePositiveIntegerFromEnv(
844
+ process.env["CODESENTINEL_CACHE_MAX_ENTRY_BYTES"],
845
+ DEFAULT_MAX_ENTRY_BYTES
846
+ ),
847
+ sweepIntervalWrites: parsePositiveIntegerFromEnv(
848
+ process.env["CODESENTINEL_CACHE_SWEEP_INTERVAL_WRITES"],
849
+ DEFAULT_SWEEP_INTERVAL_WRITES
850
+ )
851
+ });
852
+ return cacheStoreSingleton;
853
+ };
854
+ var getPackumentCacheTtlMs = () => parsePositiveIntegerFromEnv(process.env["CODESENTINEL_CACHE_TTL_PACKUMENT_MS"], SIX_HOURS_MS);
855
+ var getWeeklyDownloadsCacheTtlMs = () => parsePositiveIntegerFromEnv(process.env["CODESENTINEL_CACHE_TTL_DOWNLOADS_MS"], ONE_DAY_MS);
856
+ var toMetadataPackumentCacheKey = (name) => `npm:packument:metadata:${name}`;
857
+ var toGraphPackumentCacheKey = (name) => `npm:packument:graph:${name}`;
858
+ var toWeeklyDownloadsCacheKey = (name) => `npm:downloads:last-week:${name}`;
643
859
  var MAX_RETRIES = 3;
644
860
  var RETRY_BASE_DELAY_MS = 500;
861
+ var PACKUMENT_CACHE_STORE = getNpmMetadataCacheStore();
645
862
  var parsePrerelease = (value) => {
646
863
  if (value === void 0 || value.length === 0) {
647
864
  return [];
@@ -892,23 +1109,62 @@ var resolveRangeVersion = (versions, requested) => {
892
1109
  }
893
1110
  return null;
894
1111
  };
1112
+ var slimPackumentForGraph = (payload) => {
1113
+ const versions = payload.versions ?? {};
1114
+ const dependenciesByVersion = {};
1115
+ const versionNames = Object.keys(versions);
1116
+ for (const [version2, manifest] of Object.entries(versions)) {
1117
+ const dependenciesRaw = manifest?.dependencies ?? {};
1118
+ const dependencies = {};
1119
+ for (const [dependencyName, dependencyRange] of Object.entries(dependenciesRaw)) {
1120
+ if (dependencyName.length > 0 && dependencyRange.length > 0) {
1121
+ dependencies[dependencyName] = dependencyRange;
1122
+ }
1123
+ }
1124
+ if (Object.keys(dependencies).length > 0) {
1125
+ dependenciesByVersion[version2] = dependencies;
1126
+ }
1127
+ }
1128
+ const slim = {
1129
+ versionNames,
1130
+ dependenciesByVersion
1131
+ };
1132
+ if (payload["dist-tags"] !== void 0) {
1133
+ slim["dist-tags"] = payload["dist-tags"];
1134
+ }
1135
+ return slim;
1136
+ };
895
1137
  var fetchPackument = async (name) => {
896
1138
  const encodedName = encodeURIComponent(name);
897
1139
  try {
898
- return await fetchJsonWithRetry(`https://registry.npmjs.org/${encodedName}`, {
899
- retries: MAX_RETRIES,
900
- baseDelayMs: RETRY_BASE_DELAY_MS
1140
+ return await cachedFetch({
1141
+ key: toGraphPackumentCacheKey(name),
1142
+ ttlMs: getPackumentCacheTtlMs(),
1143
+ cacheStore: PACKUMENT_CACHE_STORE,
1144
+ fetchFresh: async () => {
1145
+ const payload = await fetchJsonWithRetry(
1146
+ `https://registry.npmjs.org/${encodedName}`,
1147
+ {
1148
+ retries: MAX_RETRIES,
1149
+ baseDelayMs: RETRY_BASE_DELAY_MS
1150
+ }
1151
+ );
1152
+ if (payload === null) {
1153
+ return null;
1154
+ }
1155
+ return slimPackumentForGraph(payload);
1156
+ }
901
1157
  });
902
1158
  } catch {
903
1159
  return null;
904
1160
  }
905
1161
  };
906
1162
  var resolveRequestedVersion = (packument, requested) => {
907
- const versions = packument.versions ?? {};
908
- const versionKeys = Object.keys(versions);
1163
+ const versionKeys = [...packument.versionNames];
1164
+ const versionSet = new Set(versionKeys);
909
1165
  const tags = packument["dist-tags"] ?? {};
910
1166
  const latest = tags["latest"];
911
- if (requested !== null && versions[requested] !== void 0) {
1167
+ if (requested !== null && versionSet.has(requested)) {
912
1168
  return {
913
1169
  version: requested,
914
1170
  resolution: "exact",
@@ -917,7 +1173,7 @@ var resolveRequestedVersion = (packument, requested) => {
917
1173
  }
918
1174
  if (requested !== null) {
919
1175
  const tagged = tags[requested];
920
- if (tagged !== void 0 && versions[tagged] !== void 0) {
1176
+ if (tagged !== void 0 && versionSet.has(tagged)) {
921
1177
  return {
922
1178
  version: tagged,
923
1179
  resolution: "tag",
@@ -927,7 +1183,7 @@ var resolveRequestedVersion = (packument, requested) => {
927
1183
  }
928
1184
  if (requested !== null) {
929
1185
  const matched = resolveRangeVersion(versionKeys, requested);
930
- if (matched !== null && versions[matched] !== void 0) {
1186
+ if (matched !== null && versionSet.has(matched)) {
931
1187
  return {
932
1188
  version: matched,
933
1189
  resolution: "range",
@@ -935,7 +1191,7 @@ var resolveRequestedVersion = (packument, requested) => {
935
1191
  };
936
1192
  }
937
1193
  }
938
- if (latest !== void 0 && versions[latest] !== void 0) {
1194
+ if (latest !== void 0 && versionSet.has(latest)) {
939
1195
  return {
940
1196
  version: latest,
941
1197
  resolution: "latest",
@@ -1016,8 +1272,8 @@ var resolveRegistryGraphFromDirectSpecs = async (directSpecs, options) => {
1016
1272
  if (nodesByKey.has(nodeKey)) {
1017
1273
  continue;
1018
1274
  }
1019
- const manifest = (packument.versions ?? {})[resolved.version] ?? {};
1020
- const dependencies = Object.entries(manifest.dependencies ?? {}).filter(
1275
+ const manifestDependencies = packument.dependenciesByVersion[resolved.version] ?? {};
1276
+ const dependencies = Object.entries(manifestDependencies).filter(
1021
1277
  ([dependencyName, dependencyRange]) => dependencyName.length > 0 && dependencyRange.length > 0
1022
1278
  ).sort((a, b) => a[0].localeCompare(b[0]));
1023
1279
  nodesByKey.set(nodeKey, {
@@ -1301,7 +1557,7 @@ var analyzeDependencyCandidate = async (input, metadataProvider) => {
1301
1557
  external
1302
1558
  };
1303
1559
  };
1304
- var ONE_DAY_MS = 24 * 60 * 60 * 1e3;
1560
+ var ONE_DAY_MS2 = 24 * 60 * 60 * 1e3;
1305
1561
  var MAX_RETRIES2 = 3;
1306
1562
  var RETRY_BASE_DELAY_MS2 = 500;
1307
1563
  var round42 = (value) => Number(value.toFixed(4));
@@ -1314,12 +1570,18 @@ var parseDate = (iso) => {
1314
1570
  };
1315
1571
  var NpmRegistryMetadataProvider = class {
1316
1572
  cache = /* @__PURE__ */ new Map();
1573
+ cacheStore = getNpmMetadataCacheStore();
1317
1574
  async fetchWeeklyDownloads(name) {
1318
1575
  const encodedName = encodeURIComponent(name);
1319
- const payload = await fetchJsonWithRetry(
1320
- `https://api.npmjs.org/downloads/point/last-week/${encodedName}`,
1321
- { retries: MAX_RETRIES2, baseDelayMs: RETRY_BASE_DELAY_MS2 }
1322
- );
1576
+ const payload = await cachedFetch({
1577
+ key: toWeeklyDownloadsCacheKey(name),
1578
+ ttlMs: getWeeklyDownloadsCacheTtlMs(),
1579
+ cacheStore: this.cacheStore,
1580
+ fetchFresh: async () => await fetchJsonWithRetry(
1581
+ `https://api.npmjs.org/downloads/point/last-week/${encodedName}`,
1582
+ { retries: MAX_RETRIES2, baseDelayMs: RETRY_BASE_DELAY_MS2 }
1583
+ )
1584
+ });
1323
1585
  if (payload === null) {
1324
1586
  return null;
1325
1587
  }
@@ -1336,10 +1598,31 @@ var NpmRegistryMetadataProvider = class {
1336
1598
  }
1337
1599
  try {
1338
1600
  const encodedName = encodeURIComponent(name);
1339
- const payload = await fetchJsonWithRetry(
1340
- `https://registry.npmjs.org/${encodedName}`,
1341
- { retries: MAX_RETRIES2, baseDelayMs: RETRY_BASE_DELAY_MS2 }
1342
- );
1601
+ const payload = await cachedFetch({
1602
+ key: toMetadataPackumentCacheKey(name),
1603
+ ttlMs: getPackumentCacheTtlMs(),
1604
+ cacheStore: this.cacheStore,
1605
+ fetchFresh: async () => {
1606
+ const fresh = await fetchJsonWithRetry(
1607
+ `https://registry.npmjs.org/${encodedName}`,
1608
+ {
1609
+ retries: MAX_RETRIES2,
1610
+ baseDelayMs: RETRY_BASE_DELAY_MS2
1611
+ }
1612
+ );
1613
+ if (fresh === null) {
1614
+ return null;
1615
+ }
1616
+ const slim = {};
1617
+ if (fresh.time !== void 0) {
1618
+ slim.time = fresh.time;
1619
+ }
1620
+ if (fresh.maintainers !== void 0) {
1621
+ slim.maintainers = fresh.maintainers;
1622
+ }
1623
+ return slim;
1624
+ }
1625
+ });
1343
1626
  if (payload === null) {
1344
1627
  this.cache.set(key, null);
1345
1628
  return null;
@@ -1348,7 +1631,7 @@ var NpmRegistryMetadataProvider = class {
1348
1631
  const publishDates = Object.entries(timeEntries).filter(([tag]) => tag !== "created" && tag !== "modified").map(([, date]) => parseDate(date)).filter((value) => value !== null).sort((a, b) => a - b);
1349
1632
  const modifiedAt = parseDate(timeEntries["modified"]);
1350
1633
  const now = Date.now();
1351
- const daysSinceLastRelease = modifiedAt === null ? null : Math.max(0, round42((now - modifiedAt) / ONE_DAY_MS));
1634
+ const daysSinceLastRelease = modifiedAt === null ? null : Math.max(0, round42((now - modifiedAt) / ONE_DAY_MS2));
1352
1635
  let releaseFrequencyDays = null;
1353
1636
  if (publishDates.length >= 2) {
1354
1637
  const totalIntervals = publishDates.length - 1;
@@ -1360,7 +1643,7 @@ var NpmRegistryMetadataProvider = class {
1360
1643
  sum += current - previous;
1361
1644
  }
1362
1645
  }
1363
- releaseFrequencyDays = round42(sum / totalIntervals / ONE_DAY_MS);
1646
+ releaseFrequencyDays = round42(sum / totalIntervals / ONE_DAY_MS2);
1364
1647
  }
1365
1648
  const maintainers = payload.maintainers ?? [];
1366
1649
  const maintainerCount = maintainers.length > 0 ? maintainers.length : null;
@@ -1656,7 +1939,7 @@ var createReport = (snapshot, diff) => {
1656
1939
  confidence: repositoryConfidence(snapshot),
1657
1940
  dimensionScores: repositoryDimensionScores(snapshot)
1658
1941
  },
1659
- quality: snapshot.analysis.quality,
1942
+ health: snapshot.analysis.health,
1660
1943
  hotspots: hotspotItems(snapshot),
1661
1944
  structural: {
1662
1945
  cycleCount: snapshot.analysis.structural.metrics.cycleCount,
@@ -1728,23 +2011,21 @@ var renderTextReport = (report) => {
1728
2011
  lines.push(` external: ${report.repository.dimensionScores.external ?? "n/a"}`);
1729
2012
  lines.push(` interactions: ${report.repository.dimensionScores.interactions ?? "n/a"}`);
1730
2013
  lines.push("");
1731
- lines.push("Quality Summary");
1732
- lines.push(` qualityScore: ${report.quality.qualityScore}`);
1733
- lines.push(` normalizedScore: ${report.quality.normalizedScore}`);
1734
- lines.push(` modularity: ${report.quality.dimensions.modularity}`);
1735
- lines.push(` changeHygiene: ${report.quality.dimensions.changeHygiene}`);
1736
- lines.push(` staticAnalysis: ${report.quality.dimensions.staticAnalysis}`);
1737
- lines.push(` complexity: ${report.quality.dimensions.complexity}`);
1738
- lines.push(` duplication: ${report.quality.dimensions.duplication}`);
1739
- lines.push(` testHealth: ${report.quality.dimensions.testHealth}`);
2014
+ lines.push("Health Summary");
2015
+ lines.push(` healthScore: ${report.health.healthScore}`);
2016
+ lines.push(` normalizedScore: ${report.health.normalizedScore}`);
2017
+ lines.push(` modularity: ${report.health.dimensions.modularity}`);
2018
+ lines.push(` changeHygiene: ${report.health.dimensions.changeHygiene}`);
2019
+ lines.push(` testHealth: ${report.health.dimensions.testHealth}`);
2020
+ lines.push(` ownershipDistribution: ${report.health.dimensions.ownershipDistribution}`);
1740
2021
  lines.push(" topIssues:");
1741
- for (const issue of report.quality.topIssues.slice(0, 5)) {
2022
+ for (const issue of report.health.topIssues.slice(0, 5)) {
1742
2023
  const ruleSuffix = issue.ruleId === void 0 ? "" : ` [rule=${issue.ruleId}]`;
1743
2024
  lines.push(
1744
2025
  ` - [${issue.severity}] (${issue.dimension}) ${issue.id}${ruleSuffix} @ ${issue.target}: ${issue.message}`
1745
2026
  );
1746
2027
  }
1747
- if (report.quality.topIssues.length === 0) {
2028
+ if (report.health.topIssues.length === 0) {
1748
2029
  lines.push(" - none");
1749
2030
  }
1750
2031
  lines.push("");
@@ -1823,20 +2104,18 @@ var renderMarkdownReport = (report) => {
1823
2104
  lines.push(`- external: \`${report.repository.dimensionScores.external ?? "n/a"}\``);
1824
2105
  lines.push(`- interactions: \`${report.repository.dimensionScores.interactions ?? "n/a"}\``);
1825
2106
  lines.push("");
1826
- lines.push("## Quality Summary");
1827
- lines.push(`- qualityScore: \`${report.quality.qualityScore}\``);
1828
- lines.push(`- normalizedScore: \`${report.quality.normalizedScore}\``);
1829
- lines.push(`- modularity: \`${report.quality.dimensions.modularity}\``);
1830
- lines.push(`- changeHygiene: \`${report.quality.dimensions.changeHygiene}\``);
1831
- lines.push(`- staticAnalysis: \`${report.quality.dimensions.staticAnalysis}\``);
1832
- lines.push(`- complexity: \`${report.quality.dimensions.complexity}\``);
1833
- lines.push(`- duplication: \`${report.quality.dimensions.duplication}\``);
1834
- lines.push(`- testHealth: \`${report.quality.dimensions.testHealth}\``);
1835
- if (report.quality.topIssues.length === 0) {
2107
+ lines.push("## Health Summary");
2108
+ lines.push(`- healthScore: \`${report.health.healthScore}\``);
2109
+ lines.push(`- normalizedScore: \`${report.health.normalizedScore}\``);
2110
+ lines.push(`- modularity: \`${report.health.dimensions.modularity}\``);
2111
+ lines.push(`- changeHygiene: \`${report.health.dimensions.changeHygiene}\``);
2112
+ lines.push(`- testHealth: \`${report.health.dimensions.testHealth}\``);
2113
+ lines.push(`- ownershipDistribution: \`${report.health.dimensions.ownershipDistribution}\``);
2114
+ if (report.health.topIssues.length === 0) {
1836
2115
  lines.push("- top issues: none");
1837
2116
  } else {
1838
2117
  lines.push("- top issues:");
1839
- for (const issue of report.quality.topIssues.slice(0, 5)) {
2118
+ for (const issue of report.health.topIssues.slice(0, 5)) {
1840
2119
  const ruleSuffix = issue.ruleId === void 0 ? "" : ` [rule=${issue.ruleId}]`;
1841
2120
  lines.push(
1842
2121
  ` - [${issue.severity}] \`${issue.id}\`${ruleSuffix} (\`${issue.dimension}\`) @ \`${issue.target}\`: ${issue.message}`
@@ -1929,7 +2208,7 @@ var formatReport = (report, format) => {
1929
2208
 
1930
2209
  // ../governance/dist/index.js
1931
2210
  import { mkdirSync, rmSync } from "fs";
1932
- import { basename, join as join2, resolve } from "path";
2211
+ import { basename, join as join5, resolve } from "path";
1933
2212
  import { execFile } from "child_process";
1934
2213
  import { promisify } from "util";
1935
2214
  var EXIT_CODES = {
@@ -1992,8 +2271,8 @@ var validateGateConfig = (input) => {
1992
2271
  if (config.maxRiskDelta !== void 0 && (!Number.isFinite(config.maxRiskDelta) || config.maxRiskDelta < 0)) {
1993
2272
  throw new GovernanceConfigurationError("max-risk-delta must be a finite number >= 0");
1994
2273
  }
1995
- if (config.maxQualityDelta !== void 0 && (!Number.isFinite(config.maxQualityDelta) || config.maxQualityDelta < 0)) {
1996
- throw new GovernanceConfigurationError("max-quality-delta must be a finite number >= 0");
2274
+ if (config.maxHealthDelta !== void 0 && (!Number.isFinite(config.maxHealthDelta) || config.maxHealthDelta < 0)) {
2275
+ throw new GovernanceConfigurationError("max-health-delta must be a finite number >= 0");
1997
2276
  }
1998
2277
  if (config.maxNewHotspots !== void 0 && (!Number.isInteger(config.maxNewHotspots) || config.maxNewHotspots < 0)) {
1999
2278
  throw new GovernanceConfigurationError("max-new-hotspots must be an integer >= 0");
@@ -2001,8 +2280,8 @@ var validateGateConfig = (input) => {
2001
2280
  if (config.maxRiskScore !== void 0 && (!Number.isFinite(config.maxRiskScore) || config.maxRiskScore < 0 || config.maxRiskScore > 100)) {
2002
2281
  throw new GovernanceConfigurationError("max-risk-score must be a number in [0, 100]");
2003
2282
  }
2004
- if (config.minQualityScore !== void 0 && (!Number.isFinite(config.minQualityScore) || config.minQualityScore < 0 || config.minQualityScore > 100)) {
2005
- throw new GovernanceConfigurationError("min-quality-score must be a number in [0, 100]");
2283
+ if (config.minHealthScore !== void 0 && (!Number.isFinite(config.minHealthScore) || config.minHealthScore < 0 || config.minHealthScore > 100)) {
2284
+ throw new GovernanceConfigurationError("min-health-score must be a number in [0, 100]");
2006
2285
  }
2007
2286
  if (config.newHotspotScoreThreshold !== void 0 && (!Number.isFinite(config.newHotspotScoreThreshold) || config.newHotspotScoreThreshold < 0 || config.newHotspotScoreThreshold > 100)) {
2008
2287
  throw new GovernanceConfigurationError(
@@ -2030,17 +2309,17 @@ var evaluateGates = (input) => {
2030
2309
  );
2031
2310
  }
2032
2311
  }
2033
- if (config.minQualityScore !== void 0) {
2034
- evaluatedGates.push("min-quality-score");
2035
- const current = input.current.analysis.quality.qualityScore;
2036
- if (current < config.minQualityScore) {
2312
+ if (config.minHealthScore !== void 0) {
2313
+ evaluatedGates.push("min-health-score");
2314
+ const current = input.current.analysis.health.healthScore;
2315
+ if (current < config.minHealthScore) {
2037
2316
  violations.push(
2038
2317
  makeViolation(
2039
- "min-quality-score",
2318
+ "min-health-score",
2040
2319
  "error",
2041
- `Quality score ${current} is below configured minimum ${config.minQualityScore}.`,
2320
+ `Health score ${current} is below configured minimum ${config.minHealthScore}.`,
2042
2321
  [input.current.analysis.structural.targetPath],
2043
- [{ kind: "repository_metric", metric: "qualityScore" }]
2322
+ [{ kind: "repository_metric", metric: "healthScore" }]
2044
2323
  )
2045
2324
  );
2046
2325
  }
@@ -2065,22 +2344,22 @@ var evaluateGates = (input) => {
2065
2344
  );
2066
2345
  }
2067
2346
  }
2068
- if (config.maxQualityDelta !== void 0) {
2069
- evaluatedGates.push("max-quality-delta");
2070
- requireDiff(input, "max-quality-delta");
2347
+ if (config.maxHealthDelta !== void 0) {
2348
+ evaluatedGates.push("max-health-delta");
2349
+ requireDiff(input, "max-health-delta");
2071
2350
  const baseline = input.baseline;
2072
2351
  if (baseline === void 0) {
2073
- throw new GovernanceConfigurationError("max-quality-delta requires baseline snapshot");
2352
+ throw new GovernanceConfigurationError("max-health-delta requires baseline snapshot");
2074
2353
  }
2075
- const delta = input.current.analysis.quality.normalizedScore - baseline.analysis.quality.normalizedScore;
2076
- if (delta < -config.maxQualityDelta) {
2354
+ const delta = input.current.analysis.health.normalizedScore - baseline.analysis.health.normalizedScore;
2355
+ if (delta < -config.maxHealthDelta) {
2077
2356
  violations.push(
2078
2357
  makeViolation(
2079
- "max-quality-delta",
2358
+ "max-health-delta",
2080
2359
  "error",
2081
- `Quality normalized score delta ${delta.toFixed(4)} is below allowed minimum ${(-config.maxQualityDelta).toFixed(4)}.`,
2360
+ `Health normalized score delta ${delta.toFixed(4)} is below allowed minimum ${(-config.maxHealthDelta).toFixed(4)}.`,
2082
2361
  [input.current.analysis.structural.targetPath],
2083
- [{ kind: "repository_metric", metric: "qualityNormalizedScore" }]
2362
+ [{ kind: "repository_metric", metric: "healthNormalizedScore" }]
2084
2363
  )
2085
2364
  );
2086
2365
  }
@@ -2450,7 +2729,7 @@ var tryRunGit = async (repositoryPath, args) => {
2450
2729
  }
2451
2730
  };
2452
2731
  var buildWorktreePath = (repoRoot, sha) => {
2453
- const tmpRoot = join2(repoRoot, SENTINEL_TMP_DIR, WORKTREE_DIR);
2732
+ const tmpRoot = join5(repoRoot, SENTINEL_TMP_DIR, WORKTREE_DIR);
2454
2733
  mkdirSync(tmpRoot, { recursive: true });
2455
2734
  const baseName = `baseline-${sha.slice(0, 12)}-${process.pid}`;
2456
2735
  const candidate = resolve(tmpRoot, baseName);
@@ -2542,8 +2821,8 @@ var resolveAutoBaselineRef = async (input) => {
2542
2821
 
2543
2822
  // src/index.ts
2544
2823
  import { readFileSync as readFileSync2 } from "fs";
2545
- import { readFile as readFile6, writeFile as writeFile5 } from "fs/promises";
2546
- import { dirname as dirname2, resolve as resolve6 } from "path";
2824
+ import { readFile as readFile6, writeFile as writeFile6 } from "fs/promises";
2825
+ import { dirname as dirname2, resolve as resolve5 } from "path";
2547
2826
  import { fileURLToPath } from "url";
2548
2827
 
2549
2828
  // src/application/format-analyze-output.ts
@@ -2584,11 +2863,11 @@ var createSummaryShape = (summary) => ({
2584
2863
  fragileClusterCount: summary.risk.fragileClusters.length,
2585
2864
  dependencyAmplificationZoneCount: summary.risk.dependencyAmplificationZones.length
2586
2865
  },
2587
- quality: {
2588
- qualityScore: summary.quality.qualityScore,
2589
- normalizedScore: summary.quality.normalizedScore,
2590
- dimensions: summary.quality.dimensions,
2591
- topIssues: summary.quality.topIssues.slice(0, 5)
2866
+ health: {
2867
+ healthScore: summary.health.healthScore,
2868
+ normalizedScore: summary.health.normalizedScore,
2869
+ dimensions: summary.health.dimensions,
2870
+ topIssues: summary.health.topIssues.slice(0, 5)
2592
2871
  }
2593
2872
  });
2594
2873
  var formatAnalyzeOutput = (summary, mode) => mode === "json" ? JSON.stringify(summary, null, 2) : JSON.stringify(createSummaryShape(summary), null, 2);
@@ -2935,13 +3214,13 @@ var parseLogLevel = (value) => {
2935
3214
 
2936
3215
  // src/application/check-for-updates.ts
2937
3216
  import { spawn } from "child_process";
2938
- import { mkdir, readFile, writeFile } from "fs/promises";
2939
- import { homedir } from "os";
2940
- import { dirname, join as join3 } from "path";
3217
+ import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
3218
+ import { homedir as homedir2 } from "os";
3219
+ import { dirname, join as join6 } from "path";
2941
3220
  import { stderr, stdin } from "process";
2942
3221
  import { clearScreenDown, cursorTo, emitKeypressEvents } from "readline";
2943
3222
  var UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
2944
- var UPDATE_CACHE_PATH = join3(homedir(), ".cache", "codesentinel", "update-check.json");
3223
+ var UPDATE_CACHE_PATH = join6(homedir2(), ".cache", "codesentinel", "update-check.json");
2945
3224
  var SEMVER_PATTERN = /^(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)(?:-(?<prerelease>[0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
2946
3225
  var ANSI = {
2947
3226
  reset: "\x1B[0m",
@@ -3065,7 +3344,7 @@ var parseNpmViewVersionOutput = (output) => {
3065
3344
  };
3066
3345
  var readCache = async () => {
3067
3346
  try {
3068
- const raw = await readFile(UPDATE_CACHE_PATH, "utf8");
3347
+ const raw = await readFile2(UPDATE_CACHE_PATH, "utf8");
3069
3348
  const parsed = JSON.parse(raw);
3070
3349
  if (typeof parsed === "object" && parsed !== null && typeof parsed.lastCheckedAt === "string") {
3071
3350
  return { lastCheckedAt: parsed.lastCheckedAt };
@@ -3076,8 +3355,8 @@ var readCache = async () => {
3076
3355
  return null;
3077
3356
  };
3078
3357
  var writeCache = async (cache) => {
3079
- await mkdir(dirname(UPDATE_CACHE_PATH), { recursive: true });
3080
- await writeFile(UPDATE_CACHE_PATH, JSON.stringify(cache), "utf8");
3358
+ await mkdir2(dirname(UPDATE_CACHE_PATH), { recursive: true });
3359
+ await writeFile2(UPDATE_CACHE_PATH, JSON.stringify(cache), "utf8");
3081
3360
  };
3082
3361
  var shouldRunUpdateCheck = (input) => {
3083
3362
  if (!input.isInteractive) {
@@ -3161,7 +3440,7 @@ var promptInstall = async (packageName, latestVersion, currentVersion) => {
3161
3440
  );
3162
3441
  return "skip";
3163
3442
  }
3164
- return await new Promise((resolve7) => {
3443
+ return await new Promise((resolve6) => {
3165
3444
  emitKeypressEvents(stdin);
3166
3445
  let selectedIndex = 0;
3167
3446
  const previousRawMode = stdin.isRaw;
@@ -3186,7 +3465,7 @@ var promptInstall = async (packageName, latestVersion, currentVersion) => {
3186
3465
  } else {
3187
3466
  stderr.write("\n");
3188
3467
  }
3189
- resolve7(choice);
3468
+ resolve6(choice);
3190
3469
  };
3191
3470
  const onKeypress = (_str, key) => {
3192
3471
  if (key.ctrl === true && key.name === "c") {
@@ -3265,7 +3544,7 @@ var checkForCliUpdates = async (input) => {
3265
3544
  };
3266
3545
 
3267
3546
  // src/application/run-analyze-command.ts
3268
- import { resolve as resolve4 } from "path";
3547
+ import { resolve as resolve3 } from "path";
3269
3548
 
3270
3549
  // ../code-graph/dist/index.js
3271
3550
  import { extname, isAbsolute, relative, resolve as resolve2 } from "path";
@@ -4313,618 +4592,121 @@ var analyzeRepositoryEvolutionFromGit = (input, onProgress) => {
4313
4592
  return analyzeRepositoryEvolution(input, historyProvider, onProgress);
4314
4593
  };
4315
4594
 
4316
- // ../quality-signals/dist/index.js
4317
- import { readFile as readFile2 } from "fs/promises";
4318
- import { existsSync as existsSync2 } from "fs";
4319
- import { join as join4, relative as relative2, resolve as resolve3 } from "path";
4320
- import { ESLint } from "eslint";
4321
- import * as ts2 from "typescript";
4322
- import * as ts3 from "typescript";
4323
- var markerRegex = /\b(?:TODO|FIXME)\b/gi;
4324
- var countMarkers = (text) => text.match(markerRegex)?.length ?? 0;
4325
- var countTodoFixmeInComments = (content) => {
4326
- const scanner = ts3.createScanner(
4327
- ts3.ScriptTarget.Latest,
4328
- false,
4329
- ts3.LanguageVariant.Standard,
4330
- content
4331
- );
4332
- let total = 0;
4333
- let token = scanner.scan();
4334
- while (token !== ts3.SyntaxKind.EndOfFileToken) {
4335
- if (token === ts3.SyntaxKind.SingleLineCommentTrivia || token === ts3.SyntaxKind.MultiLineCommentTrivia) {
4336
- total += countMarkers(scanner.getTokenText());
4337
- }
4338
- token = scanner.scan();
4339
- }
4340
- return total;
4341
- };
4342
- var SOURCE_EXTENSIONS2 = /* @__PURE__ */ new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"]);
4343
- var normalizePath2 = (value) => value.replaceAll("\\", "/");
4344
- var isTestPath = (path) => {
4345
- const normalized = normalizePath2(path);
4346
- return normalized.includes("/__tests__/") || normalized.includes("\\__tests__\\") || normalized.includes(".test.") || normalized.includes(".spec.");
4347
- };
4348
- var collectTodoFixmeCommentCount = async (targetPath, structural) => {
4349
- const filePaths2 = [...structural.files].map((file) => file.relativePath).sort((a, b) => a.localeCompare(b));
4350
- let total = 0;
4351
- for (const relativePath of filePaths2) {
4352
- try {
4353
- const content = await readFile2(join4(targetPath, relativePath), "utf8");
4354
- total += countTodoFixmeInComments(content);
4355
- } catch {
4356
- }
4357
- }
4358
- return total;
4359
- };
4360
- var collectEslintSignals = async (targetPath, structural, logger) => {
4361
- const absoluteFiles = structural.files.map((file) => join4(targetPath, file.relativePath));
4362
- if (absoluteFiles.length === 0) {
4363
- return {
4364
- errorCount: 0,
4365
- warningCount: 0,
4366
- filesWithIssues: 0,
4367
- ruleCounts: []
4368
- };
4369
- }
4370
- try {
4371
- const eslint = new ESLint({ cwd: targetPath, errorOnUnmatchedPattern: false });
4372
- const results = await eslint.lintFiles(absoluteFiles);
4373
- let errorCount = 0;
4374
- let warningCount = 0;
4375
- let filesWithIssues = 0;
4376
- const ruleCounts = /* @__PURE__ */ new Map();
4377
- for (const result of results) {
4378
- if (result.errorCount + result.warningCount > 0) {
4379
- filesWithIssues += 1;
4380
- }
4381
- errorCount += result.errorCount;
4382
- warningCount += result.warningCount;
4383
- for (const message of result.messages) {
4384
- if (message.ruleId === null) {
4385
- continue;
4386
- }
4387
- const severity = message.severity >= 2 ? "error" : "warn";
4388
- const current = ruleCounts.get(message.ruleId);
4389
- if (current === void 0) {
4390
- ruleCounts.set(message.ruleId, {
4391
- ruleId: message.ruleId,
4392
- severity,
4393
- count: 1
4394
- });
4395
- } else {
4396
- ruleCounts.set(message.ruleId, {
4397
- ruleId: current.ruleId,
4398
- severity: current.severity === "error" || severity === "error" ? "error" : "warn",
4399
- count: current.count + 1
4400
- });
4401
- }
4402
- }
4403
- }
4404
- return {
4405
- errorCount,
4406
- warningCount,
4407
- filesWithIssues,
4408
- ruleCounts: [...ruleCounts.values()].sort(
4409
- (a, b) => b.count - a.count || a.ruleId.localeCompare(b.ruleId)
4410
- )
4411
- };
4412
- } catch (error) {
4413
- logger.warn(
4414
- `quality signals: eslint collection unavailable (${error instanceof Error ? error.message : "unknown error"})`
4415
- );
4416
- return void 0;
4417
- }
4418
- };
4419
- var collectTypeScriptSignals = (targetPath, logger) => {
4420
- const tsconfigPath = ts2.findConfigFile(targetPath, ts2.sys.fileExists, "tsconfig.json");
4421
- if (tsconfigPath === void 0) {
4422
- return void 0;
4423
- }
4424
- try {
4425
- const parsed = ts2.getParsedCommandLineOfConfigFile(
4426
- tsconfigPath,
4427
- {},
4428
- {
4429
- ...ts2.sys,
4430
- onUnRecoverableConfigFileDiagnostic: () => {
4431
- throw new Error(`failed to parse ${tsconfigPath}`);
4432
- }
4433
- }
4434
- );
4435
- if (parsed === void 0) {
4436
- return void 0;
4437
- }
4438
- const program2 = ts2.createProgram({ rootNames: parsed.fileNames, options: parsed.options });
4439
- const diagnostics = [
4440
- ...program2.getOptionsDiagnostics(),
4441
- ...program2.getGlobalDiagnostics(),
4442
- ...program2.getSyntacticDiagnostics(),
4443
- ...program2.getSemanticDiagnostics()
4444
- ];
4445
- let errorCount = 0;
4446
- let warningCount = 0;
4447
- const fileSet = /* @__PURE__ */ new Set();
4448
- for (const diagnostic of diagnostics) {
4449
- if (diagnostic.category === ts2.DiagnosticCategory.Error) {
4450
- errorCount += 1;
4451
- } else if (diagnostic.category === ts2.DiagnosticCategory.Warning) {
4452
- warningCount += 1;
4453
- }
4454
- if (diagnostic.file !== void 0) {
4455
- const path = normalizePath2(relative2(targetPath, diagnostic.file.fileName));
4456
- fileSet.add(path);
4457
- }
4458
- }
4459
- return {
4460
- errorCount,
4461
- warningCount,
4462
- filesWithDiagnostics: fileSet.size
4463
- };
4464
- } catch (error) {
4465
- logger.warn(
4466
- `quality signals: typescript diagnostic collection unavailable (${error instanceof Error ? error.message : "unknown error"})`
4467
- );
4468
- return void 0;
4595
+ // ../health-engine/dist/index.js
4596
+ var clamp01 = (value) => {
4597
+ if (!Number.isFinite(value)) {
4598
+ return 0;
4469
4599
  }
4470
- };
4471
- var cyclomaticIncrement = (node) => {
4472
- if (ts2.isIfStatement(node) || ts2.isForStatement(node) || ts2.isForInStatement(node) || ts2.isForOfStatement(node) || ts2.isWhileStatement(node) || ts2.isDoStatement(node) || ts2.isCatchClause(node) || ts2.isConditionalExpression(node)) {
4473
- return 1;
4600
+ if (value <= 0) {
4601
+ return 0;
4474
4602
  }
4475
- if (ts2.isCaseClause(node)) {
4603
+ if (value >= 1) {
4476
4604
  return 1;
4477
4605
  }
4478
- if (ts2.isBinaryExpression(node)) {
4479
- if (node.operatorToken.kind === ts2.SyntaxKind.AmpersandAmpersandToken || node.operatorToken.kind === ts2.SyntaxKind.BarBarToken || node.operatorToken.kind === ts2.SyntaxKind.QuestionQuestionToken) {
4480
- return 1;
4481
- }
4482
- }
4483
- return 0;
4484
- };
4485
- var computeCyclomaticComplexity = (node) => {
4486
- let complexity = 1;
4487
- const visit = (current) => {
4488
- complexity += cyclomaticIncrement(current);
4489
- if (current !== node && (ts2.isFunctionLike(current) || ts2.isArrowFunction(current) || ts2.isMethodDeclaration(current) || ts2.isConstructorDeclaration(current))) {
4490
- return;
4491
- }
4492
- ts2.forEachChild(current, visit);
4493
- };
4494
- visit(node);
4495
- return complexity;
4606
+ return value;
4496
4607
  };
4497
- var collectFunctionComplexities = (content, fileName) => {
4498
- const sourceFile = ts2.createSourceFile(fileName, content, ts2.ScriptTarget.Latest, true);
4499
- const complexities = [];
4500
- const visit = (node) => {
4501
- if (ts2.isFunctionDeclaration(node) || ts2.isMethodDeclaration(node) || ts2.isFunctionExpression(node) || ts2.isArrowFunction(node) || ts2.isConstructorDeclaration(node) || ts2.isGetAccessorDeclaration(node) || ts2.isSetAccessorDeclaration(node)) {
4502
- complexities.push(computeCyclomaticComplexity(node));
4503
- }
4504
- ts2.forEachChild(node, visit);
4505
- };
4506
- visit(sourceFile);
4507
- if (complexities.length === 0) {
4508
- return [computeCyclomaticComplexity(sourceFile)];
4608
+ var round45 = (value) => Number(value.toFixed(4));
4609
+ var average = (values) => {
4610
+ if (values.length === 0) {
4611
+ return 0;
4509
4612
  }
4510
- return complexities;
4613
+ const total = values.reduce((sum, value) => sum + value, 0);
4614
+ return total / values.length;
4511
4615
  };
4512
- var collectComplexitySignals = async (targetPath, structural) => {
4513
- const complexities = [];
4514
- for (const file of structural.files) {
4515
- const extension = file.relativePath.slice(file.relativePath.lastIndexOf("."));
4516
- if (!SOURCE_EXTENSIONS2.has(extension)) {
4517
- continue;
4518
- }
4519
- try {
4520
- const content = await readFile2(join4(targetPath, file.relativePath), "utf8");
4521
- complexities.push(...collectFunctionComplexities(content, file.relativePath));
4522
- } catch {
4523
- }
4616
+ var concentration = (rawValues) => {
4617
+ const values = rawValues.filter((value) => value > 0);
4618
+ const count = values.length;
4619
+ if (count <= 1) {
4620
+ return 0;
4524
4621
  }
4525
- if (complexities.length === 0) {
4526
- return void 0;
4622
+ const total = values.reduce((sum, value) => sum + value, 0);
4623
+ if (total <= 0) {
4624
+ return 0;
4527
4625
  }
4528
- const averageCyclomatic = complexities.reduce((sum, value) => sum + value, 0) / complexities.length;
4529
- const maxCyclomatic = Math.max(...complexities);
4530
- const highComplexityFileCount = complexities.filter((value) => value >= 15).length;
4531
- return {
4532
- averageCyclomatic,
4533
- maxCyclomatic,
4534
- highComplexityFileCount,
4535
- analyzedFileCount: complexities.length
4536
- };
4626
+ const hhi = values.reduce((sum, value) => {
4627
+ const share = value / total;
4628
+ return sum + share * share;
4629
+ }, 0);
4630
+ const minHhi = 1 / count;
4631
+ const normalized = (hhi - minHhi) / (1 - minHhi);
4632
+ return clamp01(normalized);
4537
4633
  };
4538
- var DUPLICATION_MIN_BLOCK_TOKENS = 40;
4539
- var DUPLICATION_KGRAM_TOKENS = 25;
4540
- var DUPLICATION_WINDOW_SIZE = 4;
4541
- var DUPLICATION_MAX_FILES = 5e3;
4542
- var DUPLICATION_MAX_TOKENS_PER_FILE = 12e3;
4543
- var DUPLICATION_MAX_FINGERPRINTS_PER_FILE = 1200;
4544
- var DUPLICATION_EXACT_MAX_WINDOWS = 25e4;
4545
- var HASH_BASE = 16777619;
4546
- var hashString32 = (value) => {
4547
- let hash = 2166136261;
4548
- for (let index = 0; index < value.length; index += 1) {
4549
- hash ^= value.charCodeAt(index);
4550
- hash = Math.imul(hash, 16777619) >>> 0;
4551
- }
4552
- return hash >>> 0;
4553
- };
4554
- var computeRollingBasePower = (kgramSize) => {
4555
- let value = 1;
4556
- for (let index = 1; index < kgramSize; index += 1) {
4557
- value = Math.imul(value, HASH_BASE) >>> 0;
4634
+ var DIMENSION_WEIGHTS = {
4635
+ modularity: 0.35,
4636
+ changeHygiene: 0.3,
4637
+ testHealth: 0.2,
4638
+ ownershipDistribution: 0.15
4639
+ };
4640
+ var HEALTH_TRACE_VERSION = "1";
4641
+ var toPercentage = (normalizedHealth) => round45(clamp01(normalizedHealth) * 100);
4642
+ var dampenForSmallSamples = (penalty, sampleSize, warmupSize, minimumWeight = 0.35) => {
4643
+ const reliability = clamp01(sampleSize / Math.max(1, warmupSize));
4644
+ const dampeningWeight = minimumWeight + (1 - minimumWeight) * reliability;
4645
+ return clamp01(penalty) * dampeningWeight;
4646
+ };
4647
+ var topPercentShare = (values, fraction) => {
4648
+ const positive = values.filter((value) => value > 0).sort((a, b) => b - a);
4649
+ if (positive.length === 0) {
4650
+ return 0;
4558
4651
  }
4559
- return value;
4560
- };
4561
- var tokenizeForDuplication = (content, filePath) => {
4562
- const languageVariant = filePath.endsWith(".tsx") || filePath.endsWith(".jsx") ? ts2.LanguageVariant.JSX : ts2.LanguageVariant.Standard;
4563
- const scanner = ts2.createScanner(ts2.ScriptTarget.Latest, true, languageVariant, content);
4564
- const tokens = [];
4565
- let token = scanner.scan();
4566
- while (token !== ts2.SyntaxKind.EndOfFileToken) {
4567
- if (token !== ts2.SyntaxKind.WhitespaceTrivia && token !== ts2.SyntaxKind.NewLineTrivia && token !== ts2.SyntaxKind.SingleLineCommentTrivia && token !== ts2.SyntaxKind.MultiLineCommentTrivia) {
4568
- if (token === ts2.SyntaxKind.Identifier || token === ts2.SyntaxKind.PrivateIdentifier) {
4569
- tokens.push("id");
4570
- } else if (token === ts2.SyntaxKind.StringLiteral || token === ts2.SyntaxKind.NoSubstitutionTemplateLiteral || token === ts2.SyntaxKind.TemplateHead || token === ts2.SyntaxKind.TemplateMiddle || token === ts2.SyntaxKind.TemplateTail || token === ts2.SyntaxKind.NumericLiteral || token === ts2.SyntaxKind.BigIntLiteral || token === ts2.SyntaxKind.RegularExpressionLiteral) {
4571
- tokens.push("lit");
4572
- } else {
4573
- const stable = ts2.tokenToString(token) ?? ts2.SyntaxKind[token] ?? `${token}`;
4574
- tokens.push(stable);
4575
- }
4576
- }
4577
- token = scanner.scan();
4652
+ const topCount = Math.max(1, Math.ceil(positive.length * clamp01(fraction)));
4653
+ const total = positive.reduce((sum, value) => sum + value, 0);
4654
+ const topTotal = positive.slice(0, topCount).reduce((sum, value) => sum + value, 0);
4655
+ if (total <= 0) {
4656
+ return 0;
4578
4657
  }
4579
- return tokens;
4658
+ return clamp01(topTotal / total);
4580
4659
  };
4581
- var buildKgramHashes = (tokenValues, kgramSize) => {
4582
- if (tokenValues.length < kgramSize) {
4583
- return [];
4584
- }
4585
- const fingerprints = [];
4586
- const removePower = computeRollingBasePower(kgramSize);
4587
- let hash = 0;
4588
- for (let index = 0; index < kgramSize; index += 1) {
4589
- hash = Math.imul(hash, HASH_BASE) + (tokenValues[index] ?? 0) >>> 0;
4590
- }
4591
- fingerprints.push({ hash, start: 0 });
4592
- for (let start = 1; start <= tokenValues.length - kgramSize; start += 1) {
4593
- const removed = tokenValues[start - 1] ?? 0;
4594
- const added = tokenValues[start + kgramSize - 1] ?? 0;
4595
- const removedContribution = Math.imul(removed, removePower) >>> 0;
4596
- const shifted = Math.imul(hash - removedContribution >>> 0, HASH_BASE) >>> 0;
4597
- hash = shifted + added >>> 0;
4598
- fingerprints.push({ hash, start });
4599
- }
4600
- return fingerprints;
4601
- };
4602
- var winnowFingerprints = (kgrams, windowSize) => {
4603
- if (kgrams.length === 0) {
4604
- return [];
4605
- }
4606
- if (kgrams.length <= windowSize) {
4607
- const minimum = [...kgrams].sort(
4608
- (left, right) => left.hash - right.hash || right.start - left.start
4609
- )[0];
4610
- return minimum === void 0 ? [] : [minimum];
4660
+ var normalizedEntropy = (weights) => {
4661
+ const positive = weights.filter((value) => value > 0);
4662
+ if (positive.length <= 1) {
4663
+ return 0;
4611
4664
  }
4612
- const selected = /* @__PURE__ */ new Map();
4613
- for (let start = 0; start <= kgrams.length - windowSize; start += 1) {
4614
- let best = kgrams[start];
4615
- if (best === void 0) {
4616
- continue;
4617
- }
4618
- for (let offset = 1; offset < windowSize; offset += 1) {
4619
- const candidate = kgrams[start + offset];
4620
- if (candidate === void 0) {
4621
- continue;
4622
- }
4623
- if (candidate.hash < best.hash || candidate.hash === best.hash && candidate.start > best.start) {
4624
- best = candidate;
4625
- }
4626
- }
4627
- selected.set(`${best.hash}:${best.start}`, best);
4665
+ const total = positive.reduce((sum, value) => sum + value, 0);
4666
+ if (total <= 0) {
4667
+ return 0;
4628
4668
  }
4629
- return [...selected.values()].sort((left, right) => left.start - right.start);
4669
+ const entropy = positive.reduce((sum, value) => {
4670
+ const p = value / total;
4671
+ return sum - p * Math.log(p);
4672
+ }, 0);
4673
+ return clamp01(entropy / Math.log(positive.length));
4630
4674
  };
4631
- var capFingerprints = (fingerprints, maxFingerprints) => {
4632
- if (fingerprints.length <= maxFingerprints) {
4633
- return fingerprints;
4634
- }
4635
- const step = fingerprints.length / maxFingerprints;
4636
- const capped = [];
4637
- for (let index = 0; index < maxFingerprints; index += 1) {
4638
- const selected = fingerprints[Math.floor(index * step)];
4639
- if (selected !== void 0) {
4640
- capped.push(selected);
4641
- }
4642
- }
4643
- return capped;
4675
+ var toFactorTrace = (spec) => ({
4676
+ factorId: spec.factorId,
4677
+ contribution: round45(spec.penalty * spec.weight * 100),
4678
+ penalty: round45(spec.penalty),
4679
+ rawMetrics: spec.rawMetrics,
4680
+ normalizedMetrics: spec.normalizedMetrics,
4681
+ weight: round45(spec.weight),
4682
+ evidence: spec.evidence
4683
+ });
4684
+ var createDimensionTrace = (dimension, health, factors) => ({
4685
+ dimension,
4686
+ normalizedScore: round45(clamp01(health)),
4687
+ score: toPercentage(health),
4688
+ factors: factors.map((factor) => toFactorTrace(factor))
4689
+ });
4690
+ var filePaths = (structural) => structural.files.map((file) => file.relativePath);
4691
+ var normalizePath2 = (value) => value.replaceAll("\\", "/").toLowerCase();
4692
+ var isTestPath = (path) => {
4693
+ const normalized = normalizePath2(path);
4694
+ return normalized.includes("/__tests__/") || normalized.includes("/tests/") || normalized.includes("/test/") || normalized.includes(".test.") || normalized.includes(".spec.");
4644
4695
  };
4645
- var tokenBlockSignature = (tokens, start, blockLength) => {
4646
- if (start < 0 || start + blockLength > tokens.length) {
4647
- return void 0;
4696
+ var isSourcePath = (path) => {
4697
+ if (path.endsWith(".d.ts")) {
4698
+ return false;
4648
4699
  }
4649
- return tokens.slice(start, start + blockLength).join(" ");
4700
+ return !isTestPath(path);
4650
4701
  };
4651
- var mergeTokenRanges = (ranges) => {
4652
- if (ranges.length === 0) {
4653
- return [];
4654
- }
4655
- const sorted = [...ranges].sort(
4656
- (left, right) => left.start - right.start || left.end - right.end
4657
- );
4658
- const merged = [];
4659
- for (const range of sorted) {
4660
- const previous = merged[merged.length - 1];
4661
- if (previous === void 0 || range.start > previous.end) {
4662
- merged.push({ ...range });
4663
- continue;
4664
- }
4665
- previous.end = Math.max(previous.end, range.end);
4666
- }
4667
- return merged;
4668
- };
4669
- var aggregateDuplicationFromSignatures = (signatures, fileByPath) => {
4670
- let duplicatedBlockCount = 0;
4671
- const duplicatedRanges = /* @__PURE__ */ new Map();
4672
- for (const entries of signatures.values()) {
4673
- if (entries.length <= 1) {
4674
- continue;
4675
- }
4676
- const uniqueEntries = /* @__PURE__ */ new Map();
4677
- for (const entry of entries) {
4678
- uniqueEntries.set(`${entry.file}:${entry.start}`, entry);
4679
- }
4680
- if (uniqueEntries.size <= 1) {
4681
- continue;
4682
- }
4683
- duplicatedBlockCount += uniqueEntries.size - 1;
4684
- for (const entry of uniqueEntries.values()) {
4685
- const source = fileByPath.get(entry.file);
4686
- if (source === void 0) {
4687
- continue;
4688
- }
4689
- const signature = tokenBlockSignature(
4690
- source.tokens,
4691
- entry.start,
4692
- DUPLICATION_MIN_BLOCK_TOKENS
4693
- );
4694
- if (signature === void 0) {
4695
- continue;
4696
- }
4697
- const ranges = duplicatedRanges.get(entry.file) ?? [];
4698
- ranges.push({
4699
- start: entry.start,
4700
- end: Math.min(source.tokens.length, entry.start + DUPLICATION_MIN_BLOCK_TOKENS)
4701
- });
4702
- duplicatedRanges.set(entry.file, ranges);
4703
- }
4704
- }
4705
- let duplicatedTokenCount = 0;
4706
- for (const ranges of duplicatedRanges.values()) {
4707
- const mergedRanges = mergeTokenRanges(ranges);
4708
- duplicatedTokenCount += mergedRanges.reduce((sum, range) => sum + (range.end - range.start), 0);
4709
- }
4710
- return {
4711
- duplicatedBlockCount,
4712
- duplicatedTokenCount,
4713
- filesWithDuplication: duplicatedRanges.size
4714
- };
4715
- };
4716
- var collectExactTokenDuplication = (analyzedFiles) => {
4717
- const signatures = /* @__PURE__ */ new Map();
4718
- for (const file of analyzedFiles) {
4719
- const tokenValues = file.tokens.map((token) => hashString32(token));
4720
- const windows = buildKgramHashes(tokenValues, DUPLICATION_MIN_BLOCK_TOKENS);
4721
- for (const window of windows) {
4722
- const signature = tokenBlockSignature(
4723
- file.tokens,
4724
- window.start,
4725
- DUPLICATION_MIN_BLOCK_TOKENS
4726
- );
4727
- if (signature === void 0) {
4728
- continue;
4729
- }
4730
- const entries = signatures.get(signature) ?? [];
4731
- entries.push({ file: file.file, start: window.start });
4732
- signatures.set(signature, entries);
4733
- }
4734
- }
4735
- const fileByPath = new Map(analyzedFiles.map((file) => [file.file, file]));
4736
- return aggregateDuplicationFromSignatures(signatures, fileByPath);
4737
- };
4738
- var collectWinnowingDuplication = (analyzedFiles) => {
4739
- const signatures = /* @__PURE__ */ new Map();
4740
- for (const file of analyzedFiles) {
4741
- const tokenValues = file.tokens.map((token) => hashString32(token));
4742
- const kgrams = buildKgramHashes(tokenValues, DUPLICATION_KGRAM_TOKENS);
4743
- const fingerprints = capFingerprints(
4744
- winnowFingerprints(kgrams, DUPLICATION_WINDOW_SIZE),
4745
- DUPLICATION_MAX_FINGERPRINTS_PER_FILE
4746
- );
4747
- for (const fingerprint of fingerprints) {
4748
- const signature = tokenBlockSignature(
4749
- file.tokens,
4750
- fingerprint.start,
4751
- DUPLICATION_MIN_BLOCK_TOKENS
4752
- );
4753
- if (signature === void 0) {
4754
- continue;
4755
- }
4756
- const entries = signatures.get(signature) ?? [];
4757
- entries.push({ file: file.file, start: fingerprint.start });
4758
- signatures.set(signature, entries);
4759
- }
4760
- }
4761
- const fileByPath = new Map(analyzedFiles.map((file) => [file.file, file]));
4762
- return aggregateDuplicationFromSignatures(signatures, fileByPath);
4763
- };
4764
- var collectDuplicationSignals = async (targetPath, structural) => {
4765
- const files = [...structural.files].map((file) => file.relativePath).sort((left, right) => left.localeCompare(right)).filter((filePath) => SOURCE_EXTENSIONS2.has(filePath.slice(filePath.lastIndexOf(".")))).filter((filePath) => isTestPath(filePath) === false).slice(0, DUPLICATION_MAX_FILES);
4766
- const analyzedFiles = [];
4767
- let significantTokenCount = 0;
4768
- let exactWindowCount = 0;
4769
- for (const relativePath of files) {
4770
- try {
4771
- const content = await readFile2(join4(targetPath, relativePath), "utf8");
4772
- const tokens = tokenizeForDuplication(content, relativePath).slice(
4773
- 0,
4774
- DUPLICATION_MAX_TOKENS_PER_FILE
4775
- );
4776
- significantTokenCount += tokens.length;
4777
- if (tokens.length < DUPLICATION_MIN_BLOCK_TOKENS) {
4778
- continue;
4779
- }
4780
- exactWindowCount += tokens.length - DUPLICATION_MIN_BLOCK_TOKENS + 1;
4781
- analyzedFiles.push({
4782
- file: relativePath,
4783
- tokens
4784
- });
4785
- } catch {
4786
- }
4787
- }
4788
- if (analyzedFiles.length === 0) {
4789
- return void 0;
4790
- }
4791
- const mode = exactWindowCount <= DUPLICATION_EXACT_MAX_WINDOWS ? "exact-token" : "winnowing";
4792
- const aggregated = mode === "exact-token" ? collectExactTokenDuplication(analyzedFiles) : collectWinnowingDuplication(analyzedFiles);
4793
- const duplicatedLineRatio = significantTokenCount === 0 ? 0 : Math.min(1, aggregated.duplicatedTokenCount / significantTokenCount);
4794
- return {
4795
- mode,
4796
- duplicatedLineRatio,
4797
- duplicatedBlockCount: aggregated.duplicatedBlockCount,
4798
- filesWithDuplication: aggregated.filesWithDuplication
4799
- };
4800
- };
4801
- var toRatio = (value) => {
4802
- if (typeof value !== "number" || Number.isFinite(value) === false) {
4803
- return null;
4804
- }
4805
- return Math.min(1, Math.max(0, value / 100));
4806
- };
4807
- var collectCoverageSignals = async (targetPath, logger) => {
4808
- const configuredPath = process.env["CODESENTINEL_QUALITY_COVERAGE_SUMMARY"];
4809
- const summaryPath = configuredPath === void 0 || configuredPath.trim().length === 0 ? join4(targetPath, "coverage", "coverage-summary.json") : resolve3(targetPath, configuredPath);
4810
- if (!existsSync2(summaryPath)) {
4811
- return void 0;
4812
- }
4813
- try {
4814
- const raw = await readFile2(summaryPath, "utf8");
4815
- const parsed = JSON.parse(raw);
4816
- return {
4817
- lineCoverage: toRatio(parsed.total?.lines?.pct),
4818
- branchCoverage: toRatio(parsed.total?.branches?.pct),
4819
- functionCoverage: toRatio(parsed.total?.functions?.pct),
4820
- statementCoverage: toRatio(parsed.total?.statements?.pct)
4821
- };
4822
- } catch (error) {
4823
- logger.warn(
4824
- `quality signals: coverage summary parse failed at ${summaryPath} (${error instanceof Error ? error.message : "unknown error"})`
4825
- );
4826
- return void 0;
4827
- }
4828
- };
4829
- var collectQualitySignals = async (targetPath, structural, logger) => {
4830
- const [todoFixmeCommentCount, eslint, complexity, duplication, coverage] = await Promise.all([
4831
- collectTodoFixmeCommentCount(targetPath, structural),
4832
- collectEslintSignals(targetPath, structural, logger),
4833
- collectComplexitySignals(targetPath, structural),
4834
- collectDuplicationSignals(targetPath, structural),
4835
- collectCoverageSignals(targetPath, logger)
4836
- ]);
4837
- const typescript = collectTypeScriptSignals(targetPath, logger);
4838
- return {
4839
- todoFixmeCommentCount,
4840
- ...eslint === void 0 ? {} : { eslint },
4841
- ...typescript === void 0 ? {} : { typescript },
4842
- ...complexity === void 0 ? {} : { complexity },
4843
- ...duplication === void 0 ? {} : { duplication },
4844
- ...coverage === void 0 ? {} : { coverage }
4845
- };
4846
- };
4847
-
4848
- // ../quality-engine/dist/index.js
4849
- var clamp01 = (value) => {
4850
- if (!Number.isFinite(value)) {
4851
- return 0;
4852
- }
4853
- if (value <= 0) {
4854
- return 0;
4855
- }
4856
- if (value >= 1) {
4857
- return 1;
4858
- }
4859
- return value;
4860
- };
4861
- var round45 = (value) => Number(value.toFixed(4));
4862
- var average = (values) => {
4863
- if (values.length === 0) {
4864
- return 0;
4865
- }
4866
- const total = values.reduce((sum, value) => sum + value, 0);
4867
- return total / values.length;
4868
- };
4869
- var concentration = (rawValues) => {
4870
- const values = rawValues.filter((value) => value > 0);
4871
- const count = values.length;
4872
- if (count <= 1) {
4873
- return 0;
4874
- }
4875
- const total = values.reduce((sum, value) => sum + value, 0);
4876
- if (total <= 0) {
4877
- return 0;
4878
- }
4879
- const hhi = values.reduce((sum, value) => {
4880
- const share = value / total;
4881
- return sum + share * share;
4882
- }, 0);
4883
- const minHhi = 1 / count;
4884
- const normalized = (hhi - minHhi) / (1 - minHhi);
4885
- return clamp01(normalized);
4886
- };
4887
- var DIMENSION_WEIGHTS = {
4888
- modularity: 0.2,
4889
- changeHygiene: 0.2,
4890
- staticAnalysis: 0.2,
4891
- complexity: 0.15,
4892
- duplication: 0.1,
4893
- testHealth: 0.15
4894
- };
4895
- var QUALITY_TRACE_VERSION = "1";
4896
- var toPercentage = (normalizedQuality) => round45(clamp01(normalizedQuality) * 100);
4897
- var logScaled = (value, scale) => {
4898
- if (scale <= 0) {
4899
- return 0;
4900
- }
4901
- return clamp01(Math.log1p(Math.max(0, value)) / Math.log1p(scale));
4902
- };
4903
- var toFactorTrace = (spec) => ({
4904
- factorId: spec.factorId,
4905
- contribution: round45(spec.penalty * spec.weight * 100),
4906
- penalty: round45(spec.penalty),
4907
- rawMetrics: spec.rawMetrics,
4908
- normalizedMetrics: spec.normalizedMetrics,
4909
- weight: round45(spec.weight),
4910
- evidence: spec.evidence
4911
- });
4912
- var createDimensionTrace = (dimension, quality, factors) => ({
4913
- dimension,
4914
- normalizedScore: round45(clamp01(quality)),
4915
- score: toPercentage(quality),
4916
- factors: factors.map((factor) => toFactorTrace(factor))
4917
- });
4918
- var filePaths = (structural) => structural.files.map((file) => file.relativePath);
4919
- var isTestPath2 = (path) => {
4920
- const normalized = path.toLowerCase();
4921
- return normalized.includes("/__tests__/") || normalized.includes("\\__tests__\\") || normalized.includes(".test.") || normalized.includes(".spec.");
4922
- };
4923
- var isSourcePath = (path) => {
4924
- if (path.endsWith(".d.ts")) {
4925
- return false;
4926
- }
4927
- return !isTestPath2(path);
4702
+ var hasTestDirectory = (paths) => paths.some((path) => {
4703
+ const normalized = normalizePath2(path);
4704
+ return normalized.includes("/__tests__/") || normalized.includes("/tests/") || normalized.includes("/test/");
4705
+ });
4706
+ var moduleNameFromPath = (path) => {
4707
+ const normalized = path.replaceAll("\\", "/");
4708
+ const firstSegment = normalized.split("/")[0] ?? normalized;
4709
+ return firstSegment.length === 0 ? normalized : firstSegment;
4928
4710
  };
4929
4711
  var pushIssue = (issues, issue) => {
4930
4712
  issues.push({
@@ -4932,484 +4714,704 @@ var pushIssue = (issues, issue) => {
4932
4714
  severity: issue.severity ?? "warn"
4933
4715
  });
4934
4716
  };
4935
- var computeRepositoryQualitySummary = (input) => {
4717
+ var weightedPenalty = (factors) => clamp01(factors.reduce((sum, factor) => sum + factor.penalty * factor.weight, 0));
4718
+ var computeRepositoryHealthSummary = (input) => {
4719
+ const ownershipPenaltyMultiplier = clamp01(input.config?.ownershipPenaltyMultiplier ?? 1);
4936
4720
  const issues = [];
4937
4721
  const sourceFileSet = new Set(input.structural.files.map((file) => file.relativePath));
4938
- const signals = input.signals;
4939
- const cycleCount = input.structural.metrics.cycleCount;
4940
- const cycleSizeAverage = input.structural.cycles.length === 0 ? 0 : average(input.structural.cycles.map((cycle) => cycle.nodes.length));
4941
- const cyclePenalty = clamp01(cycleCount / 6) * 0.7 + clamp01((cycleSizeAverage - 2) / 8) * 0.3;
4722
+ const sourceFileCount = Math.max(1, input.structural.files.length);
4723
+ const structuralEdges = input.structural.edges;
4724
+ const cycleSets = input.structural.cycles.map((cycle) => new Set(cycle.nodes)).filter((set) => set.size >= 2);
4725
+ const cycleNodeSet = /* @__PURE__ */ new Set();
4726
+ for (const cycleSet of cycleSets) {
4727
+ for (const node of cycleSet) {
4728
+ cycleNodeSet.add(node);
4729
+ }
4730
+ }
4731
+ const edgesInsideCycles = structuralEdges.filter(
4732
+ (edge) => cycleSets.some((cycleSet) => cycleSet.has(edge.from) && cycleSet.has(edge.to))
4733
+ ).length;
4734
+ const cycleEdgeRatio = structuralEdges.length === 0 ? 0 : clamp01(edgesInsideCycles / structuralEdges.length);
4735
+ const cycleNodeRatio = clamp01(cycleNodeSet.size / sourceFileCount);
4736
+ const cycleDensityPenalty = clamp01(
4737
+ clamp01(cycleEdgeRatio / 0.2) * 0.75 + clamp01(cycleNodeRatio / 0.35) * 0.25
4738
+ );
4942
4739
  const fanInConcentration = concentration(input.structural.files.map((file) => file.fanIn));
4943
4740
  const fanOutConcentration = concentration(input.structural.files.map((file) => file.fanOut));
4944
- const centralityConcentration = average([fanInConcentration, fanOutConcentration]);
4945
- if (cycleCount > 0) {
4946
- pushIssue(issues, {
4947
- id: "quality.modularity.structural_cycles",
4948
- ruleId: "graph.structural_cycles",
4949
- dimension: "modularity",
4950
- target: input.structural.cycles[0]?.nodes.slice().sort((a, b) => a.localeCompare(b)).join(" -> ") ?? input.structural.targetPath,
4951
- message: `${cycleCount} structural cycle(s) increase coupling and refactor cost.`,
4952
- severity: cycleCount >= 3 ? "error" : "warn",
4953
- impact: round45(cyclePenalty * 0.55)
4954
- });
4955
- }
4956
- if (centralityConcentration >= 0.5) {
4957
- const hottest = [...input.structural.files].map((file) => ({
4958
- path: file.relativePath,
4959
- pressure: file.fanIn + file.fanOut
4960
- })).sort((a, b) => b.pressure - a.pressure || a.path.localeCompare(b.path))[0];
4961
- pushIssue(issues, {
4962
- id: "quality.modularity.centrality_concentration",
4963
- ruleId: "graph.centrality_concentration",
4964
- dimension: "modularity",
4965
- target: hottest?.path ?? input.structural.targetPath,
4966
- message: "Fan-in/fan-out pressure is concentrated in a small set of files.",
4967
- impact: round45(centralityConcentration * 0.45)
4968
- });
4741
+ const fanConcentration = average([fanInConcentration, fanOutConcentration]);
4742
+ const centralityPressure = input.structural.files.map((file) => file.fanIn + file.fanOut);
4743
+ const centralityConcentration = concentration(centralityPressure);
4744
+ let structuralHotspotOverlap = 0;
4745
+ if (input.evolution.available) {
4746
+ const evolutionSourceFiles = input.evolution.files.filter(
4747
+ (file) => sourceFileSet.has(file.filePath)
4748
+ );
4749
+ const topStructuralCount = Math.max(1, Math.ceil(sourceFileCount * 0.1));
4750
+ const topChangeCount = Math.max(1, Math.ceil(Math.max(1, evolutionSourceFiles.length) * 0.1));
4751
+ const topStructural = new Set(
4752
+ [...input.structural.files].map((file) => ({
4753
+ filePath: file.relativePath,
4754
+ pressure: file.fanIn + file.fanOut
4755
+ })).sort((a, b) => b.pressure - a.pressure || a.filePath.localeCompare(b.filePath)).slice(0, topStructuralCount).map((item) => item.filePath)
4756
+ );
4757
+ const topChange = new Set(
4758
+ [...evolutionSourceFiles].sort((a, b) => b.churnTotal - a.churnTotal || a.filePath.localeCompare(b.filePath)).slice(0, topChangeCount).map((item) => item.filePath)
4759
+ );
4760
+ const overlapCount = [...topStructural].filter((filePath) => topChange.has(filePath)).length;
4761
+ structuralHotspotOverlap = topStructural.size === 0 || topChange.size === 0 ? 0 : clamp01(overlapCount / Math.min(topStructural.size, topChange.size));
4969
4762
  }
4970
4763
  const modularityFactors = [
4971
4764
  {
4972
- factorId: "quality.modularity.structural_cycles",
4973
- penalty: cyclePenalty,
4765
+ factorId: "health.modularity.cycle_density",
4766
+ penalty: cycleDensityPenalty,
4974
4767
  rawMetrics: {
4975
- cycleCount,
4976
- averageCycleSize: round45(cycleSizeAverage)
4768
+ cycleCount: input.structural.metrics.cycleCount,
4769
+ cycleEdgeRatio: round45(cycleEdgeRatio),
4770
+ cycleNodeRatio: round45(cycleNodeRatio)
4977
4771
  },
4978
4772
  normalizedMetrics: {
4979
- cyclePenalty: round45(cyclePenalty)
4773
+ cycleDensityPenalty: round45(cycleDensityPenalty)
4980
4774
  },
4981
- weight: 0.55,
4982
- evidence: [{ kind: "repository_metric", metric: "structural.cycles" }]
4775
+ weight: 0.4,
4776
+ evidence: [{ kind: "repository_metric", metric: "structural.cycleEdgeRatio" }]
4983
4777
  },
4984
4778
  {
4985
- factorId: "quality.modularity.centrality_concentration",
4986
- penalty: centralityConcentration,
4779
+ factorId: "health.modularity.fan_concentration",
4780
+ penalty: fanConcentration,
4987
4781
  rawMetrics: {
4988
4782
  fanInConcentration: round45(fanInConcentration),
4989
4783
  fanOutConcentration: round45(fanOutConcentration)
4990
4784
  },
4991
4785
  normalizedMetrics: {
4992
- centralityConcentration: round45(centralityConcentration)
4786
+ fanConcentration: round45(fanConcentration)
4993
4787
  },
4994
- weight: 0.45,
4788
+ weight: 0.25,
4995
4789
  evidence: [{ kind: "repository_metric", metric: "structural.files.fanIn/fanOut" }]
4790
+ },
4791
+ {
4792
+ factorId: "health.modularity.centrality_concentration",
4793
+ penalty: centralityConcentration,
4794
+ rawMetrics: {
4795
+ centralityConcentration: round45(centralityConcentration)
4796
+ },
4797
+ normalizedMetrics: {
4798
+ centralityConcentration: round45(centralityConcentration)
4799
+ },
4800
+ weight: 0.2,
4801
+ evidence: [{ kind: "repository_metric", metric: "structural.centralityPressure" }]
4802
+ },
4803
+ {
4804
+ factorId: "health.modularity.hotspot_overlap",
4805
+ penalty: structuralHotspotOverlap,
4806
+ rawMetrics: {
4807
+ structuralHotspotOverlap: round45(structuralHotspotOverlap)
4808
+ },
4809
+ normalizedMetrics: {
4810
+ structuralHotspotOverlap: round45(structuralHotspotOverlap)
4811
+ },
4812
+ weight: 0.15,
4813
+ evidence: [{ kind: "repository_metric", metric: "structural.evolution.hotspotOverlap" }]
4996
4814
  }
4997
4815
  ];
4998
- const modularityPenalty = clamp01(
4999
- modularityFactors.reduce((sum, factor) => sum + factor.penalty * factor.weight, 0)
4816
+ const modularityPenalty = dampenForSmallSamples(
4817
+ weightedPenalty(modularityFactors),
4818
+ sourceFileCount,
4819
+ 8,
4820
+ 0.45
5000
4821
  );
5001
- let churnConcentration = 0;
5002
- let volatilityConcentration = 0;
5003
- let couplingDensity = 0;
5004
- let couplingIntensity = 0;
4822
+ if (cycleDensityPenalty >= 0.35) {
4823
+ const firstCycle = input.structural.cycles[0];
4824
+ pushIssue(issues, {
4825
+ id: "health.modularity.cycle_density",
4826
+ ruleId: "graph.cycle_density",
4827
+ signal: "structural.cycleEdgeRatio",
4828
+ dimension: "modularity",
4829
+ target: firstCycle?.nodes.slice().sort((a, b) => a.localeCompare(b)).join(" -> ") ?? input.structural.targetPath,
4830
+ message: "Dependencies inside cycles consume a high share of graph edges, reducing refactor flexibility.",
4831
+ severity: cycleDensityPenalty >= 0.7 ? "error" : "warn",
4832
+ evidenceMetrics: {
4833
+ cycleCount: input.structural.metrics.cycleCount,
4834
+ cycleEdgeRatio: round45(cycleEdgeRatio),
4835
+ cycleNodeRatio: round45(cycleNodeRatio)
4836
+ },
4837
+ impact: round45(modularityPenalty * 0.35)
4838
+ });
4839
+ }
4840
+ if (centralityConcentration >= 0.5) {
4841
+ const hottest = [...input.structural.files].map((file) => ({
4842
+ path: file.relativePath,
4843
+ pressure: file.fanIn + file.fanOut
4844
+ })).sort((a, b) => b.pressure - a.pressure || a.path.localeCompare(b.path))[0];
4845
+ pushIssue(issues, {
4846
+ id: "health.modularity.centrality_concentration",
4847
+ ruleId: "graph.centrality_concentration",
4848
+ signal: "structural.centralityPressure",
4849
+ dimension: "modularity",
4850
+ target: hottest?.path ?? input.structural.targetPath,
4851
+ message: "Dependency flow is concentrated in a narrow set of files, creating architectural bottlenecks.",
4852
+ evidenceMetrics: {
4853
+ fanConcentration: round45(fanConcentration),
4854
+ centralityConcentration: round45(centralityConcentration)
4855
+ },
4856
+ impact: round45(modularityPenalty * 0.25)
4857
+ });
4858
+ }
4859
+ if (structuralHotspotOverlap >= 0.5) {
4860
+ pushIssue(issues, {
4861
+ id: "health.modularity.hotspot_overlap",
4862
+ ruleId: "graph.hotspot_overlap",
4863
+ signal: "structural.evolution.hotspotOverlap",
4864
+ dimension: "modularity",
4865
+ target: input.structural.targetPath,
4866
+ message: "Structural hubs overlap with top churn hotspots, making change pressure harder to isolate.",
4867
+ evidenceMetrics: {
4868
+ structuralHotspotOverlap: round45(structuralHotspotOverlap)
4869
+ },
4870
+ impact: round45(modularityPenalty * 0.2)
4871
+ });
4872
+ }
4873
+ let churnConcentrationPenalty = 0;
4874
+ let volatilityConcentrationPenalty = 0;
4875
+ let coChangeClusterPenalty = 0;
4876
+ let top10PercentFilesChurnShare = 0;
4877
+ let top10PercentFilesVolatilityShare = 0;
4878
+ let denseCoChangePairRatio = 0;
5005
4879
  if (input.evolution.available) {
5006
4880
  const evolutionSourceFiles = input.evolution.files.filter(
5007
4881
  (file) => sourceFileSet.has(file.filePath)
5008
4882
  );
5009
- churnConcentration = concentration(evolutionSourceFiles.map((file) => file.churnTotal));
5010
- volatilityConcentration = concentration(
5011
- evolutionSourceFiles.map((file) => file.recentVolatility)
4883
+ const evolutionFileCount = evolutionSourceFiles.length;
4884
+ top10PercentFilesChurnShare = topPercentShare(
4885
+ evolutionSourceFiles.map((file) => file.churnTotal),
4886
+ 0.1
4887
+ );
4888
+ top10PercentFilesVolatilityShare = topPercentShare(
4889
+ evolutionSourceFiles.map((file) => file.recentVolatility),
4890
+ 0.1
5012
4891
  );
5013
- const fileCount = Math.max(1, evolutionSourceFiles.length);
5014
- const maxPairs = fileCount * (fileCount - 1) / 2;
5015
4892
  const sourcePairs = input.evolution.coupling.pairs.filter(
5016
4893
  (pair) => sourceFileSet.has(pair.fileA) && sourceFileSet.has(pair.fileB)
5017
4894
  );
5018
- couplingDensity = maxPairs <= 0 ? 0 : clamp01(sourcePairs.length / maxPairs);
5019
- couplingIntensity = average(sourcePairs.map((pair) => pair.couplingScore));
5020
- if (churnConcentration >= 0.45) {
4895
+ const maxPairs = evolutionFileCount * (evolutionFileCount - 1) / 2;
4896
+ const densePairs = sourcePairs.filter((pair) => pair.couplingScore >= 0.55);
4897
+ denseCoChangePairRatio = maxPairs <= 0 ? 0 : clamp01(densePairs.length / maxPairs);
4898
+ const couplingScoreConcentration = concentration(sourcePairs.map((pair) => pair.couplingScore));
4899
+ churnConcentrationPenalty = dampenForSmallSamples(
4900
+ clamp01((top10PercentFilesChurnShare - 0.35) / 0.55),
4901
+ evolutionFileCount,
4902
+ 12,
4903
+ 0.3
4904
+ );
4905
+ volatilityConcentrationPenalty = dampenForSmallSamples(
4906
+ clamp01((top10PercentFilesVolatilityShare - 0.35) / 0.55),
4907
+ evolutionFileCount,
4908
+ 12,
4909
+ 0.3
4910
+ );
4911
+ const coChangeRaw = average([
4912
+ clamp01(denseCoChangePairRatio / 0.2),
4913
+ couplingScoreConcentration
4914
+ ]);
4915
+ coChangeClusterPenalty = dampenForSmallSamples(coChangeRaw, sourcePairs.length, 20, 0.35);
4916
+ if (churnConcentrationPenalty >= 0.35) {
5021
4917
  const mostChurn = [...evolutionSourceFiles].sort(
5022
4918
  (a, b) => b.churnTotal - a.churnTotal || a.filePath.localeCompare(b.filePath)
5023
4919
  )[0];
5024
4920
  pushIssue(issues, {
5025
- id: "quality.change_hygiene.churn_concentration",
5026
- ruleId: "git.churn_concentration",
4921
+ id: "health.change_hygiene.churn_concentration",
4922
+ ruleId: "git.churn_distribution",
4923
+ signal: "evolution.top10PercentFilesChurnShare",
5027
4924
  dimension: "changeHygiene",
5028
4925
  target: mostChurn?.filePath ?? input.structural.targetPath,
5029
- message: "Churn is concentrated in a narrow part of the codebase.",
5030
- impact: round45(churnConcentration * 0.4)
4926
+ message: "A small slice of files carries most churn, reducing change predictability.",
4927
+ evidenceMetrics: {
4928
+ top10PercentFilesChurnShare: round45(top10PercentFilesChurnShare)
4929
+ },
4930
+ impact: round45(churnConcentrationPenalty * 0.4)
5031
4931
  });
5032
4932
  }
5033
- if (volatilityConcentration >= 0.45) {
4933
+ if (volatilityConcentrationPenalty >= 0.35) {
5034
4934
  const volatileFile = [...evolutionSourceFiles].sort(
5035
4935
  (a, b) => b.recentVolatility - a.recentVolatility || a.filePath.localeCompare(b.filePath)
5036
4936
  )[0];
5037
4937
  pushIssue(issues, {
5038
- id: "quality.change_hygiene.volatility_concentration",
5039
- ruleId: "git.volatility_concentration",
4938
+ id: "health.change_hygiene.volatility_concentration",
4939
+ ruleId: "git.volatility_distribution",
4940
+ signal: "evolution.top10PercentFilesVolatilityShare",
5040
4941
  dimension: "changeHygiene",
5041
4942
  target: volatileFile?.filePath ?? input.structural.targetPath,
5042
- message: "Recent volatility is concentrated in files that change frequently.",
5043
- impact: round45(volatilityConcentration * 0.3)
4943
+ message: "Recent volatility is concentrated, increasing review and release uncertainty.",
4944
+ evidenceMetrics: {
4945
+ top10PercentFilesVolatilityShare: round45(top10PercentFilesVolatilityShare)
4946
+ },
4947
+ impact: round45(volatilityConcentrationPenalty * 0.3)
5044
4948
  });
5045
4949
  }
5046
- if (couplingDensity >= 0.35 || couplingIntensity >= 0.45) {
4950
+ if (coChangeClusterPenalty >= 0.35) {
5047
4951
  const strongestPair = [...sourcePairs].sort(
5048
4952
  (a, b) => b.couplingScore - a.couplingScore || `${a.fileA}|${a.fileB}`.localeCompare(`${b.fileA}|${b.fileB}`)
5049
4953
  )[0];
5050
4954
  pushIssue(issues, {
5051
- id: "quality.change_hygiene.coupling_density",
5052
- ruleId: "git.coupling_density",
4955
+ id: "health.change_hygiene.dense_co_change_clusters",
4956
+ ruleId: "git.co_change_clusters",
4957
+ signal: "evolution.denseCoChangePairRatio",
5053
4958
  dimension: "changeHygiene",
5054
4959
  target: strongestPair === void 0 ? input.structural.targetPath : `${strongestPair.fileA}<->${strongestPair.fileB}`,
5055
- message: "Co-change relationships are dense, increasing coordination overhead.",
5056
- impact: round45(average([couplingDensity, couplingIntensity]) * 0.3)
4960
+ message: "Dense co-change clusters suggest wider coordination scope per change.",
4961
+ evidenceMetrics: {
4962
+ denseCoChangePairRatio: round45(denseCoChangePairRatio)
4963
+ },
4964
+ impact: round45(coChangeClusterPenalty * 0.3)
5057
4965
  });
5058
4966
  }
5059
4967
  }
5060
- const todoFixmeCommentCount = Math.max(0, signals?.todoFixmeCommentCount ?? 0);
5061
- const todoFixmePenalty = logScaled(todoFixmeCommentCount, 80) * 0.08;
5062
- if (todoFixmeCommentCount > 0) {
5063
- pushIssue(issues, {
5064
- id: "quality.change_hygiene.todo_fixme_load",
5065
- ruleId: "comments.todo_fixme",
5066
- dimension: "changeHygiene",
5067
- target: input.structural.targetPath,
5068
- message: `Found ${todoFixmeCommentCount} TODO/FIXME comment marker(s); cleanup debt is accumulating.`,
5069
- impact: round45(todoFixmePenalty * 0.4)
5070
- });
5071
- }
5072
4968
  const changeHygieneFactors = [
5073
4969
  {
5074
- factorId: "quality.change_hygiene.churn_concentration",
5075
- penalty: churnConcentration,
4970
+ factorId: "health.change_hygiene.churn_concentration",
4971
+ penalty: churnConcentrationPenalty,
5076
4972
  rawMetrics: {
5077
- churnConcentration: round45(churnConcentration)
4973
+ top10PercentFilesChurnShare: round45(top10PercentFilesChurnShare)
5078
4974
  },
5079
4975
  normalizedMetrics: {
5080
- churnConcentration: round45(churnConcentration)
5081
- },
5082
- weight: 0.35,
5083
- evidence: [{ kind: "repository_metric", metric: "evolution.churn" }]
5084
- },
5085
- {
5086
- factorId: "quality.change_hygiene.volatility_concentration",
5087
- penalty: volatilityConcentration,
5088
- rawMetrics: {
5089
- volatilityConcentration: round45(volatilityConcentration)
4976
+ churnConcentrationPenalty: round45(churnConcentrationPenalty)
5090
4977
  },
5091
- normalizedMetrics: {
5092
- volatilityConcentration: round45(volatilityConcentration)
5093
- },
5094
- weight: 0.25,
5095
- evidence: [{ kind: "repository_metric", metric: "evolution.recentVolatility" }]
4978
+ weight: 0.4,
4979
+ evidence: [{ kind: "repository_metric", metric: "evolution.top10PercentFilesChurnShare" }]
5096
4980
  },
5097
4981
  {
5098
- factorId: "quality.change_hygiene.coupling_density",
5099
- penalty: average([couplingDensity, couplingIntensity]),
4982
+ factorId: "health.change_hygiene.volatility_concentration",
4983
+ penalty: volatilityConcentrationPenalty,
5100
4984
  rawMetrics: {
5101
- couplingDensity: round45(couplingDensity),
5102
- couplingIntensity: round45(couplingIntensity)
4985
+ top10PercentFilesVolatilityShare: round45(top10PercentFilesVolatilityShare)
5103
4986
  },
5104
4987
  normalizedMetrics: {
5105
- couplingPressure: round45(average([couplingDensity, couplingIntensity]))
4988
+ volatilityConcentrationPenalty: round45(volatilityConcentrationPenalty)
5106
4989
  },
5107
4990
  weight: 0.3,
5108
- evidence: [{ kind: "repository_metric", metric: "evolution.coupling" }]
4991
+ evidence: [
4992
+ {
4993
+ kind: "repository_metric",
4994
+ metric: "evolution.top10PercentFilesVolatilityShare"
4995
+ }
4996
+ ]
5109
4997
  },
5110
4998
  {
5111
- factorId: "quality.change_hygiene.todo_fixme_load",
5112
- penalty: todoFixmePenalty,
4999
+ factorId: "health.change_hygiene.dense_co_change_clusters",
5000
+ penalty: coChangeClusterPenalty,
5113
5001
  rawMetrics: {
5114
- todoFixmeCommentCount
5002
+ denseCoChangePairRatio: round45(denseCoChangePairRatio)
5115
5003
  },
5116
5004
  normalizedMetrics: {
5117
- todoFixmePenalty: round45(todoFixmePenalty)
5005
+ coChangeClusterPenalty: round45(coChangeClusterPenalty)
5118
5006
  },
5119
- weight: 0.1,
5120
- evidence: [{ kind: "repository_metric", metric: "comments.todo_fixme" }]
5007
+ weight: 0.3,
5008
+ evidence: [{ kind: "repository_metric", metric: "evolution.denseCoChangePairRatio" }]
5121
5009
  }
5122
5010
  ];
5123
- const changeHygienePenalty = input.evolution.available ? clamp01(changeHygieneFactors.reduce((sum, factor) => sum + factor.penalty * factor.weight, 0)) : 0.2;
5124
- const eslint = signals?.eslint;
5125
- const tsc = signals?.typescript;
5126
- const sourceCount = Math.max(1, input.structural.files.length);
5127
- const eslintErrorRate = (eslint?.errorCount ?? 0) / sourceCount;
5128
- const eslintWarnRate = (eslint?.warningCount ?? 0) / sourceCount;
5129
- const tsErrorRate = (tsc?.errorCount ?? 0) / sourceCount;
5130
- const tsWarnRate = (tsc?.warningCount ?? 0) / sourceCount;
5131
- const staticAnalysisFactors = [
5132
- {
5133
- factorId: "quality.static_analysis.eslint_errors",
5134
- penalty: clamp01(eslintErrorRate / 0.5),
5135
- rawMetrics: {
5136
- eslintErrorCount: eslint?.errorCount ?? 0,
5137
- eslintFilesWithIssues: eslint?.filesWithIssues ?? 0
5138
- },
5139
- normalizedMetrics: {
5140
- eslintErrorRate: round45(eslintErrorRate)
5141
- },
5142
- weight: 0.5,
5143
- evidence: [{ kind: "repository_metric", metric: "eslint.errorCount" }]
5144
- },
5011
+ const changeHygienePenalty = input.evolution.available ? weightedPenalty(changeHygieneFactors) : 0.12;
5012
+ const paths = filePaths(input.structural);
5013
+ const testFiles = paths.filter((path) => isTestPath(path)).length;
5014
+ const sourceFiles = paths.filter((path) => isSourcePath(path)).length;
5015
+ const testRatio = sourceFiles <= 0 ? 1 : testFiles / sourceFiles;
5016
+ const testingDirectoryPresent = hasTestDirectory(paths);
5017
+ const testPresencePenalty = sourceFiles <= 0 ? 0 : testFiles === 0 ? 1 : 0;
5018
+ const testRatioPenalty = sourceFiles <= 0 ? 0 : 1 - clamp01(testRatio / 0.25);
5019
+ const testingDirectoryPenalty = sourceFiles <= 0 ? 0 : testingDirectoryPresent ? 0 : 0.35;
5020
+ const testHealthFactors = [
5145
5021
  {
5146
- factorId: "quality.static_analysis.eslint_warnings",
5147
- penalty: clamp01(eslintWarnRate / 1.2),
5022
+ factorId: "health.test_health.test_file_presence",
5023
+ penalty: testPresencePenalty,
5148
5024
  rawMetrics: {
5149
- eslintWarningCount: eslint?.warningCount ?? 0
5025
+ sourceFiles,
5026
+ testFiles
5150
5027
  },
5151
5028
  normalizedMetrics: {
5152
- eslintWarningRate: round45(eslintWarnRate)
5029
+ testPresencePenalty: round45(testPresencePenalty)
5153
5030
  },
5154
- weight: 0.2,
5155
- evidence: [{ kind: "repository_metric", metric: "eslint.warningCount" }]
5031
+ weight: 0.4,
5032
+ evidence: [{ kind: "repository_metric", metric: "tests.filePresence" }]
5156
5033
  },
5157
5034
  {
5158
- factorId: "quality.static_analysis.typescript_errors",
5159
- penalty: clamp01(tsErrorRate / 0.35),
5035
+ factorId: "health.test_health.test_to_source_ratio",
5036
+ penalty: testRatioPenalty,
5160
5037
  rawMetrics: {
5161
- typeScriptErrorCount: tsc?.errorCount ?? 0,
5162
- typeScriptFilesWithDiagnostics: tsc?.filesWithDiagnostics ?? 0
5038
+ testToSourceRatio: round45(testRatio)
5163
5039
  },
5164
5040
  normalizedMetrics: {
5165
- typeScriptErrorRate: round45(tsErrorRate)
5041
+ testRatioPenalty: round45(testRatioPenalty)
5166
5042
  },
5167
- weight: 0.2,
5168
- evidence: [{ kind: "repository_metric", metric: "typescript.errorCount" }]
5043
+ weight: 0.45,
5044
+ evidence: [{ kind: "repository_metric", metric: "tests.testToSourceRatio" }]
5169
5045
  },
5170
5046
  {
5171
- factorId: "quality.static_analysis.typescript_warnings",
5172
- penalty: clamp01(tsWarnRate / 0.9),
5047
+ factorId: "health.test_health.testing_directory_presence",
5048
+ penalty: testingDirectoryPenalty,
5173
5049
  rawMetrics: {
5174
- typeScriptWarningCount: tsc?.warningCount ?? 0
5050
+ testingDirectoryPresent: testingDirectoryPresent ? 1 : 0
5175
5051
  },
5176
5052
  normalizedMetrics: {
5177
- typeScriptWarningRate: round45(tsWarnRate)
5053
+ testingDirectoryPenalty: round45(testingDirectoryPenalty)
5178
5054
  },
5179
- weight: 0.1,
5180
- evidence: [{ kind: "repository_metric", metric: "typescript.warningCount" }]
5055
+ weight: 0.15,
5056
+ evidence: [{ kind: "repository_metric", metric: "tests.directoryPresence" }]
5181
5057
  }
5182
5058
  ];
5183
- const staticAnalysisPenalty = clamp01(
5184
- staticAnalysisFactors.reduce((sum, factor) => sum + factor.penalty * factor.weight, 0)
5059
+ const testHealthPenalty = dampenForSmallSamples(
5060
+ weightedPenalty(testHealthFactors),
5061
+ sourceFiles,
5062
+ 10,
5063
+ 0.3
5185
5064
  );
5186
- if ((eslint?.errorCount ?? 0) > 0) {
5187
- const topRule = [...eslint?.ruleCounts ?? []].sort(
5188
- (a, b) => b.count - a.count || a.ruleId.localeCompare(b.ruleId)
5189
- )[0];
5065
+ if (sourceFiles > 0 && testFiles === 0) {
5190
5066
  pushIssue(issues, {
5191
- id: "quality.static_analysis.eslint_errors",
5192
- ruleId: topRule?.ruleId ?? "eslint",
5193
- dimension: "staticAnalysis",
5067
+ id: "health.test_health.low_test_presence",
5068
+ ruleId: "tests.file_presence",
5069
+ signal: "tests.filePresence",
5070
+ dimension: "testHealth",
5194
5071
  target: input.structural.targetPath,
5195
- message: topRule === void 0 ? `ESLint reported ${eslint?.errorCount ?? 0} error(s).` : `ESLint reported ${eslint?.errorCount ?? 0} error(s); top rule ${topRule.ruleId} (${topRule.count}).`,
5196
- severity: "error",
5197
- impact: round45(staticAnalysisPenalty * 0.5)
5072
+ message: `No test files detected for ${sourceFiles} source file(s).`,
5073
+ severity: sourceFiles >= 12 ? "error" : "warn",
5074
+ evidenceMetrics: {
5075
+ sourceFiles,
5076
+ testFiles,
5077
+ testToSourceRatio: round45(testRatio)
5078
+ },
5079
+ impact: round45(testHealthPenalty * 0.45)
5198
5080
  });
5199
5081
  }
5200
- if ((tsc?.errorCount ?? 0) > 0) {
5082
+ if (sourceFiles > 0 && testRatio < 0.12) {
5201
5083
  pushIssue(issues, {
5202
- id: "quality.static_analysis.typescript_errors",
5203
- ruleId: "typescript",
5204
- dimension: "staticAnalysis",
5084
+ id: "health.test_health.low_test_ratio",
5085
+ ruleId: "tests.ratio",
5086
+ signal: "tests.testToSourceRatio",
5087
+ dimension: "testHealth",
5205
5088
  target: input.structural.targetPath,
5206
- message: `TypeScript reported ${tsc?.errorCount ?? 0} error diagnostic(s).`,
5207
- severity: "error",
5208
- impact: round45(staticAnalysisPenalty * 0.4)
5089
+ message: "Test-to-source ratio is low; long-term change confidence may degrade.",
5090
+ evidenceMetrics: {
5091
+ sourceFiles,
5092
+ testFiles,
5093
+ testToSourceRatio: round45(testRatio)
5094
+ },
5095
+ impact: round45(testHealthPenalty * 0.35)
5209
5096
  });
5210
5097
  }
5211
- const complexity = signals?.complexity;
5212
- const avgComplexity = complexity?.averageCyclomatic ?? 0;
5213
- const maxComplexity = complexity?.maxCyclomatic ?? 0;
5214
- const highComplexityRatio = (complexity?.analyzedFileCount ?? 0) === 0 ? 0 : (complexity?.highComplexityFileCount ?? 0) / Math.max(1, complexity?.analyzedFileCount ?? 1);
5215
- const complexityFactors = [
5216
- {
5217
- factorId: "quality.complexity.average_cyclomatic",
5218
- penalty: clamp01(avgComplexity / 16),
5219
- rawMetrics: {
5220
- averageCyclomatic: round45(avgComplexity)
5098
+ if (input.evolution.available) {
5099
+ const evolutionSourceFiles = input.evolution.files.filter(
5100
+ (file) => sourceFileSet.has(file.filePath)
5101
+ );
5102
+ const authorTotals = /* @__PURE__ */ new Map();
5103
+ const moduleTotals = /* @__PURE__ */ new Map();
5104
+ const moduleAuthors = /* @__PURE__ */ new Map();
5105
+ let singleContributorFiles = 0;
5106
+ let trackedFiles = 0;
5107
+ for (const file of evolutionSourceFiles) {
5108
+ if (file.commitCount <= 0 || file.authorDistribution.length === 0) {
5109
+ continue;
5110
+ }
5111
+ trackedFiles += 1;
5112
+ const dominantShare = clamp01(file.authorDistribution[0]?.share ?? 0);
5113
+ if (file.authorDistribution.length === 1 || dominantShare >= 0.9) {
5114
+ singleContributorFiles += 1;
5115
+ }
5116
+ for (const author of file.authorDistribution) {
5117
+ const commits = Math.max(0, author.commits);
5118
+ if (commits <= 0) {
5119
+ continue;
5120
+ }
5121
+ const moduleName = moduleNameFromPath(file.filePath);
5122
+ const moduleAuthorTotals = moduleAuthors.get(moduleName) ?? /* @__PURE__ */ new Map();
5123
+ if (moduleAuthors.has(moduleName) === false) {
5124
+ moduleAuthors.set(moduleName, moduleAuthorTotals);
5125
+ }
5126
+ authorTotals.set(author.authorId, (authorTotals.get(author.authorId) ?? 0) + commits);
5127
+ moduleTotals.set(moduleName, (moduleTotals.get(moduleName) ?? 0) + commits);
5128
+ moduleAuthorTotals.set(
5129
+ author.authorId,
5130
+ (moduleAuthorTotals.get(author.authorId) ?? 0) + commits
5131
+ );
5132
+ }
5133
+ }
5134
+ const totalAuthorCommits = [...authorTotals.values()].reduce((sum, value) => sum + value, 0);
5135
+ const highestAuthorCommits = [...authorTotals.values()].sort((a, b) => b - a)[0] ?? 0;
5136
+ const topAuthorCommitShare = totalAuthorCommits <= 0 ? 0 : clamp01(highestAuthorCommits / totalAuthorCommits);
5137
+ const filesWithSingleContributorRatio = trackedFiles === 0 ? 0 : clamp01(singleContributorFiles / trackedFiles);
5138
+ const authorEntropy = normalizedEntropy([...authorTotals.values()]);
5139
+ let dominatedModules = 0;
5140
+ let trackedModules = 0;
5141
+ for (const [moduleName, moduleCommitTotal] of moduleTotals.entries()) {
5142
+ if (moduleCommitTotal < 5) {
5143
+ continue;
5144
+ }
5145
+ const perModuleAuthors = moduleAuthors.get(moduleName);
5146
+ if (perModuleAuthors === void 0) {
5147
+ continue;
5148
+ }
5149
+ trackedModules += 1;
5150
+ const topAuthorModuleCommits = [...perModuleAuthors.values()].sort((a, b) => b - a)[0] ?? 0;
5151
+ const moduleTopShare = moduleCommitTotal <= 0 ? 0 : topAuthorModuleCommits / moduleCommitTotal;
5152
+ if (moduleTopShare >= 0.8) {
5153
+ dominatedModules += 1;
5154
+ }
5155
+ }
5156
+ const modulesDominatedBySingleContributorRatio = trackedModules === 0 ? 0 : clamp01(dominatedModules / trackedModules);
5157
+ const ownershipSampleSize = trackedFiles;
5158
+ const ownershipCommitVolume = totalAuthorCommits;
5159
+ const ownershipReliability = average([
5160
+ clamp01(ownershipSampleSize / 12),
5161
+ clamp01(ownershipCommitVolume / 180)
5162
+ ]);
5163
+ const topAuthorPenalty = clamp01((topAuthorCommitShare - 0.55) / 0.4);
5164
+ const singleContributorPenalty = clamp01((filesWithSingleContributorRatio - 0.35) / 0.6);
5165
+ const entropyPenalty = clamp01((0.75 - authorEntropy) / 0.75);
5166
+ const moduleDominancePenalty = clamp01((modulesDominatedBySingleContributorRatio - 0.4) / 0.6);
5167
+ const ownershipBasePenalty = weightedPenalty([
5168
+ {
5169
+ factorId: "health.ownership.top_author_commit_share",
5170
+ penalty: topAuthorPenalty,
5171
+ rawMetrics: {
5172
+ topAuthorCommitShare: round45(topAuthorCommitShare)
5173
+ },
5174
+ normalizedMetrics: {
5175
+ topAuthorPenalty: round45(topAuthorPenalty)
5176
+ },
5177
+ weight: 0.35,
5178
+ evidence: [{ kind: "repository_metric", metric: "ownership.topAuthorCommitShare" }]
5221
5179
  },
5222
- normalizedMetrics: {
5223
- averageCyclomaticPenalty: round45(clamp01(avgComplexity / 16))
5180
+ {
5181
+ factorId: "health.ownership.files_with_single_contributor_ratio",
5182
+ penalty: singleContributorPenalty,
5183
+ rawMetrics: {
5184
+ filesWithSingleContributorRatio: round45(filesWithSingleContributorRatio)
5185
+ },
5186
+ normalizedMetrics: {
5187
+ singleContributorPenalty: round45(singleContributorPenalty)
5188
+ },
5189
+ weight: 0.25,
5190
+ evidence: [
5191
+ {
5192
+ kind: "repository_metric",
5193
+ metric: "ownership.filesWithSingleContributorRatio"
5194
+ }
5195
+ ]
5224
5196
  },
5225
- weight: 0.4,
5226
- evidence: [{ kind: "repository_metric", metric: "complexity.averageCyclomatic" }]
5227
- },
5228
- {
5229
- factorId: "quality.complexity.max_cyclomatic",
5230
- penalty: clamp01(maxComplexity / 35),
5231
- rawMetrics: {
5232
- maxCyclomatic: round45(maxComplexity)
5197
+ {
5198
+ factorId: "health.ownership.author_entropy",
5199
+ penalty: entropyPenalty,
5200
+ rawMetrics: {
5201
+ authorEntropy: round45(authorEntropy)
5202
+ },
5203
+ normalizedMetrics: {
5204
+ authorEntropyPenalty: round45(entropyPenalty)
5205
+ },
5206
+ weight: 0.25,
5207
+ evidence: [{ kind: "repository_metric", metric: "ownership.authorEntropy" }]
5233
5208
  },
5234
- normalizedMetrics: {
5235
- maxCyclomaticPenalty: round45(clamp01(maxComplexity / 35))
5209
+ {
5210
+ factorId: "health.ownership.module_single_author_dominance",
5211
+ penalty: moduleDominancePenalty,
5212
+ rawMetrics: {
5213
+ modulesDominatedBySingleContributorRatio: round45(
5214
+ modulesDominatedBySingleContributorRatio
5215
+ )
5216
+ },
5217
+ normalizedMetrics: {
5218
+ moduleDominancePenalty: round45(moduleDominancePenalty)
5219
+ },
5220
+ weight: 0.15,
5221
+ evidence: [{ kind: "repository_metric", metric: "ownership.moduleDominance" }]
5222
+ }
5223
+ ]);
5224
+ const ownershipDistributionPenalty2 = clamp01(
5225
+ ownershipBasePenalty * (0.3 + 0.7 * ownershipReliability) * ownershipPenaltyMultiplier
5226
+ );
5227
+ const ownershipDistributionFactors2 = [
5228
+ {
5229
+ factorId: "health.ownership.top_author_commit_share",
5230
+ penalty: topAuthorPenalty,
5231
+ rawMetrics: {
5232
+ topAuthorCommitShare: round45(topAuthorCommitShare)
5233
+ },
5234
+ normalizedMetrics: {
5235
+ topAuthorPenalty: round45(topAuthorPenalty),
5236
+ ownershipPenaltyMultiplier: round45(ownershipPenaltyMultiplier),
5237
+ ownershipReliability: round45(ownershipReliability)
5238
+ },
5239
+ weight: 0.35,
5240
+ evidence: [{ kind: "repository_metric", metric: "ownership.topAuthorCommitShare" }]
5236
5241
  },
5237
- weight: 0.35,
5238
- evidence: [{ kind: "repository_metric", metric: "complexity.maxCyclomatic" }]
5239
- },
5240
- {
5241
- factorId: "quality.complexity.high_complexity_ratio",
5242
- penalty: clamp01(highComplexityRatio / 0.35),
5243
- rawMetrics: {
5244
- highComplexityFileCount: complexity?.highComplexityFileCount ?? 0,
5245
- analyzedFileCount: complexity?.analyzedFileCount ?? 0
5242
+ {
5243
+ factorId: "health.ownership.files_with_single_contributor_ratio",
5244
+ penalty: singleContributorPenalty,
5245
+ rawMetrics: {
5246
+ filesWithSingleContributorRatio: round45(filesWithSingleContributorRatio)
5247
+ },
5248
+ normalizedMetrics: {
5249
+ singleContributorPenalty: round45(singleContributorPenalty)
5250
+ },
5251
+ weight: 0.25,
5252
+ evidence: [
5253
+ {
5254
+ kind: "repository_metric",
5255
+ metric: "ownership.filesWithSingleContributorRatio"
5256
+ }
5257
+ ]
5246
5258
  },
5247
- normalizedMetrics: {
5248
- highComplexityRatio: round45(highComplexityRatio)
5259
+ {
5260
+ factorId: "health.ownership.author_entropy",
5261
+ penalty: entropyPenalty,
5262
+ rawMetrics: {
5263
+ authorEntropy: round45(authorEntropy)
5264
+ },
5265
+ normalizedMetrics: {
5266
+ authorEntropyPenalty: round45(entropyPenalty)
5267
+ },
5268
+ weight: 0.25,
5269
+ evidence: [{ kind: "repository_metric", metric: "ownership.authorEntropy" }]
5249
5270
  },
5250
- weight: 0.25,
5251
- evidence: [{ kind: "repository_metric", metric: "complexity.highComplexityFileCount" }]
5271
+ {
5272
+ factorId: "health.ownership.module_single_author_dominance",
5273
+ penalty: moduleDominancePenalty,
5274
+ rawMetrics: {
5275
+ modulesDominatedBySingleContributorRatio: round45(
5276
+ modulesDominatedBySingleContributorRatio
5277
+ )
5278
+ },
5279
+ normalizedMetrics: {
5280
+ moduleDominancePenalty: round45(moduleDominancePenalty)
5281
+ },
5282
+ weight: 0.15,
5283
+ evidence: [{ kind: "repository_metric", metric: "ownership.moduleDominance" }]
5284
+ }
5285
+ ];
5286
+ if (topAuthorPenalty >= 0.35) {
5287
+ pushIssue(issues, {
5288
+ id: "health.ownership.top_author_commit_share",
5289
+ ruleId: "ownership.top_author_share",
5290
+ signal: "ownership.topAuthorCommitShare",
5291
+ dimension: "ownershipDistribution",
5292
+ target: input.structural.targetPath,
5293
+ message: "A single contributor owns most commits, concentrating repository knowledge.",
5294
+ severity: topAuthorPenalty >= 0.75 ? "error" : "warn",
5295
+ evidenceMetrics: {
5296
+ topAuthorCommitShare: round45(topAuthorCommitShare),
5297
+ authorEntropy: round45(authorEntropy)
5298
+ },
5299
+ impact: round45(ownershipDistributionPenalty2 * 0.4)
5300
+ });
5252
5301
  }
5253
- ];
5254
- const complexityPenalty = clamp01(
5255
- complexityFactors.reduce((sum, factor) => sum + factor.penalty * factor.weight, 0)
5256
- );
5257
- if (maxComplexity >= 20 || highComplexityRatio >= 0.2) {
5258
- pushIssue(issues, {
5259
- id: "quality.complexity.high_cyclomatic",
5260
- ruleId: "complexity.cyclomatic",
5261
- dimension: "complexity",
5262
- target: input.structural.targetPath,
5263
- message: `Complexity is elevated (avg=${round45(avgComplexity)}, max=${round45(maxComplexity)}).`,
5264
- impact: round45(complexityPenalty * 0.6)
5265
- });
5266
- }
5267
- const duplication = signals?.duplication;
5268
- const duplicatedLineRatio = duplication?.duplicatedLineRatio ?? 0;
5269
- const duplicatedBlockCount = duplication?.duplicatedBlockCount ?? 0;
5270
- const duplicationFactors = [
5271
- {
5272
- factorId: "quality.duplication.line_ratio",
5273
- penalty: clamp01(duplicatedLineRatio / 0.25),
5274
- rawMetrics: {
5275
- duplicatedLineRatio: round45(duplicatedLineRatio)
5276
- },
5277
- normalizedMetrics: {
5278
- duplicatedLineRatioPenalty: round45(clamp01(duplicatedLineRatio / 0.25))
5279
- },
5280
- weight: 0.7,
5281
- evidence: [{ kind: "repository_metric", metric: "duplication.duplicatedLineRatio" }]
5282
- },
5283
- {
5284
- factorId: "quality.duplication.block_count",
5285
- penalty: logScaled(duplicatedBlockCount, 120),
5286
- rawMetrics: {
5287
- duplicatedBlockCount,
5288
- filesWithDuplication: duplication?.filesWithDuplication ?? 0
5289
- },
5290
- normalizedMetrics: {
5291
- duplicatedBlockPenalty: round45(logScaled(duplicatedBlockCount, 120))
5292
- },
5293
- weight: 0.3,
5294
- evidence: [{ kind: "repository_metric", metric: "duplication.duplicatedBlockCount" }]
5302
+ if (singleContributorPenalty >= 0.35) {
5303
+ pushIssue(issues, {
5304
+ id: "health.ownership.single_author_dominance",
5305
+ ruleId: "ownership.file_dominance",
5306
+ signal: "ownership.filesWithSingleContributorRatio",
5307
+ dimension: "ownershipDistribution",
5308
+ target: input.structural.targetPath,
5309
+ message: "Many files are dominated by a single contributor, reducing change resilience.",
5310
+ evidenceMetrics: {
5311
+ filesWithSingleContributorRatio: round45(filesWithSingleContributorRatio),
5312
+ modulesDominatedBySingleContributorRatio: round45(
5313
+ modulesDominatedBySingleContributorRatio
5314
+ )
5315
+ },
5316
+ impact: round45(ownershipDistributionPenalty2 * 0.35)
5317
+ });
5295
5318
  }
5296
- ];
5297
- const duplicationPenalty = clamp01(
5298
- duplicationFactors.reduce((sum, factor) => sum + factor.penalty * factor.weight, 0)
5299
- );
5300
- if (duplicatedLineRatio >= 0.08) {
5301
- pushIssue(issues, {
5302
- id: "quality.duplication.high_duplication",
5303
- ruleId: "duplication.line_ratio",
5304
- dimension: "duplication",
5305
- target: input.structural.targetPath,
5306
- message: `Duplication ratio is high (${toPercentage(duplicatedLineRatio)}%).`,
5307
- impact: round45(duplicationPenalty * 0.6)
5308
- });
5309
- }
5310
- const paths = filePaths(input.structural);
5311
- const testFiles = paths.filter((path) => isTestPath2(path)).length;
5312
- const sourceFiles = paths.filter((path) => isSourcePath(path)).length;
5313
- const testRatio = sourceFiles <= 0 ? 1 : testFiles / sourceFiles;
5314
- const testPresencePenalty = sourceFiles <= 0 ? 0 : 1 - clamp01(testRatio / 0.35);
5315
- const coverageSignals = signals?.coverage;
5316
- const coverageValues = [
5317
- coverageSignals?.lineCoverage,
5318
- coverageSignals?.branchCoverage,
5319
- coverageSignals?.functionCoverage,
5320
- coverageSignals?.statementCoverage
5321
- ].filter((value) => value !== null && value !== void 0);
5322
- const coverageRatio = coverageValues.length === 0 ? null : average(coverageValues);
5323
- const coveragePenalty = coverageRatio === null ? 0.2 : 1 - clamp01(coverageRatio / 0.8);
5324
- const testHealthFactors = [
5325
- {
5326
- factorId: "quality.test_health.test_presence",
5327
- penalty: testPresencePenalty,
5328
- rawMetrics: {
5329
- sourceFiles,
5330
- testFiles,
5331
- testRatio: round45(testRatio)
5332
- },
5333
- normalizedMetrics: {
5334
- testPresencePenalty: round45(testPresencePenalty)
5319
+ if (entropyPenalty >= 0.35) {
5320
+ pushIssue(issues, {
5321
+ id: "health.ownership.low_author_entropy",
5322
+ ruleId: "ownership.author_entropy",
5323
+ signal: "ownership.authorEntropy",
5324
+ dimension: "ownershipDistribution",
5325
+ target: input.structural.targetPath,
5326
+ message: "Contributor distribution is narrow across the repository.",
5327
+ evidenceMetrics: {
5328
+ authorEntropy: round45(authorEntropy),
5329
+ topAuthorCommitShare: round45(topAuthorCommitShare)
5330
+ },
5331
+ impact: round45(ownershipDistributionPenalty2 * 0.25)
5332
+ });
5333
+ }
5334
+ const modularityHealth2 = clamp01(1 - modularityPenalty);
5335
+ const changeHygieneHealth2 = clamp01(1 - changeHygienePenalty);
5336
+ const testHealthScore2 = clamp01(1 - testHealthPenalty);
5337
+ const ownershipDistributionHealth2 = clamp01(1 - ownershipDistributionPenalty2);
5338
+ const normalizedScore2 = clamp01(
5339
+ modularityHealth2 * DIMENSION_WEIGHTS.modularity + changeHygieneHealth2 * DIMENSION_WEIGHTS.changeHygiene + testHealthScore2 * DIMENSION_WEIGHTS.testHealth + ownershipDistributionHealth2 * DIMENSION_WEIGHTS.ownershipDistribution
5340
+ );
5341
+ const topIssues2 = [...issues].sort(
5342
+ (a, b) => b.impact - a.impact || a.id.localeCompare(b.id) || a.target.localeCompare(b.target)
5343
+ ).slice(0, 12).map(({ impact: _impact, ...issue }) => issue);
5344
+ return {
5345
+ healthScore: toPercentage(normalizedScore2),
5346
+ normalizedScore: round45(normalizedScore2),
5347
+ dimensions: {
5348
+ modularity: toPercentage(modularityHealth2),
5349
+ changeHygiene: toPercentage(changeHygieneHealth2),
5350
+ testHealth: toPercentage(testHealthScore2),
5351
+ ownershipDistribution: toPercentage(ownershipDistributionHealth2)
5335
5352
  },
5336
- weight: 0.55,
5337
- evidence: [{ kind: "repository_metric", metric: "tests.file_ratio" }]
5338
- },
5353
+ topIssues: topIssues2,
5354
+ trace: {
5355
+ schemaVersion: HEALTH_TRACE_VERSION,
5356
+ dimensions: [
5357
+ createDimensionTrace("modularity", modularityHealth2, modularityFactors),
5358
+ createDimensionTrace("changeHygiene", changeHygieneHealth2, changeHygieneFactors),
5359
+ createDimensionTrace("testHealth", testHealthScore2, testHealthFactors),
5360
+ createDimensionTrace(
5361
+ "ownershipDistribution",
5362
+ ownershipDistributionHealth2,
5363
+ ownershipDistributionFactors2
5364
+ )
5365
+ ]
5366
+ }
5367
+ };
5368
+ }
5369
+ const ownershipDistributionPenalty = clamp01(0.12 * ownershipPenaltyMultiplier);
5370
+ const ownershipDistributionFactors = [
5339
5371
  {
5340
- factorId: "quality.test_health.coverage",
5341
- penalty: coveragePenalty,
5372
+ factorId: "health.ownership.missing_git_history",
5373
+ penalty: ownershipDistributionPenalty,
5342
5374
  rawMetrics: {
5343
- lineCoverage: coverageSignals?.lineCoverage ?? null,
5344
- branchCoverage: coverageSignals?.branchCoverage ?? null,
5345
- functionCoverage: coverageSignals?.functionCoverage ?? null,
5346
- statementCoverage: coverageSignals?.statementCoverage ?? null
5375
+ gitHistoryAvailable: 0
5347
5376
  },
5348
5377
  normalizedMetrics: {
5349
- coverageRatio: coverageRatio === null ? null : round45(coverageRatio),
5350
- coveragePenalty: round45(coveragePenalty)
5378
+ ownershipDistributionPenalty: round45(ownershipDistributionPenalty)
5351
5379
  },
5352
- weight: 0.45,
5353
- evidence: [{ kind: "repository_metric", metric: "coverage.summary" }]
5380
+ weight: 1,
5381
+ evidence: [{ kind: "repository_metric", metric: "evolution.available" }]
5354
5382
  }
5355
5383
  ];
5356
- const testHealthPenalty = clamp01(
5357
- testHealthFactors.reduce((sum, factor) => sum + factor.penalty * factor.weight, 0)
5358
- );
5359
- if (sourceFiles > 0 && testRatio < 0.2) {
5360
- pushIssue(issues, {
5361
- id: "quality.test_health.low_test_presence",
5362
- ruleId: "tests.file_ratio",
5363
- dimension: "testHealth",
5364
- target: input.structural.targetPath,
5365
- message: `Detected ${testFiles} test file(s) for ${sourceFiles} source file(s).`,
5366
- severity: testRatio === 0 ? "error" : "warn",
5367
- impact: round45(testHealthPenalty * 0.4)
5368
- });
5369
- }
5370
- if (coverageRatio !== null && coverageRatio < 0.6) {
5371
- pushIssue(issues, {
5372
- id: "quality.test_health.low_coverage",
5373
- ruleId: "coverage.threshold",
5374
- dimension: "testHealth",
5375
- target: input.structural.targetPath,
5376
- message: `Coverage is below threshold (${toPercentage(coverageRatio)}%).`,
5377
- impact: round45(testHealthPenalty * 0.35)
5378
- });
5379
- }
5380
- const modularityQuality = clamp01(1 - modularityPenalty);
5381
- const changeHygieneQuality = clamp01(1 - changeHygienePenalty);
5382
- const staticAnalysisQuality = clamp01(1 - staticAnalysisPenalty);
5383
- const complexityQuality = clamp01(1 - complexityPenalty);
5384
- const duplicationQuality = clamp01(1 - duplicationPenalty);
5385
- const testHealthQuality = clamp01(1 - testHealthPenalty);
5384
+ const modularityHealth = clamp01(1 - modularityPenalty);
5385
+ const changeHygieneHealth = clamp01(1 - changeHygienePenalty);
5386
+ const testHealthScore = clamp01(1 - testHealthPenalty);
5387
+ const ownershipDistributionHealth = clamp01(1 - ownershipDistributionPenalty);
5386
5388
  const normalizedScore = clamp01(
5387
- modularityQuality * DIMENSION_WEIGHTS.modularity + changeHygieneQuality * DIMENSION_WEIGHTS.changeHygiene + staticAnalysisQuality * DIMENSION_WEIGHTS.staticAnalysis + complexityQuality * DIMENSION_WEIGHTS.complexity + duplicationQuality * DIMENSION_WEIGHTS.duplication + testHealthQuality * DIMENSION_WEIGHTS.testHealth
5389
+ modularityHealth * DIMENSION_WEIGHTS.modularity + changeHygieneHealth * DIMENSION_WEIGHTS.changeHygiene + testHealthScore * DIMENSION_WEIGHTS.testHealth + ownershipDistributionHealth * DIMENSION_WEIGHTS.ownershipDistribution
5388
5390
  );
5389
5391
  const topIssues = [...issues].sort(
5390
5392
  (a, b) => b.impact - a.impact || a.id.localeCompare(b.id) || a.target.localeCompare(b.target)
5391
5393
  ).slice(0, 12).map(({ impact: _impact, ...issue }) => issue);
5392
5394
  return {
5393
- qualityScore: toPercentage(normalizedScore),
5395
+ healthScore: toPercentage(normalizedScore),
5394
5396
  normalizedScore: round45(normalizedScore),
5395
5397
  dimensions: {
5396
- modularity: toPercentage(modularityQuality),
5397
- changeHygiene: toPercentage(changeHygieneQuality),
5398
- staticAnalysis: toPercentage(staticAnalysisQuality),
5399
- complexity: toPercentage(complexityQuality),
5400
- duplication: toPercentage(duplicationQuality),
5401
- testHealth: toPercentage(testHealthQuality)
5398
+ modularity: toPercentage(modularityHealth),
5399
+ changeHygiene: toPercentage(changeHygieneHealth),
5400
+ testHealth: toPercentage(testHealthScore),
5401
+ ownershipDistribution: toPercentage(ownershipDistributionHealth)
5402
5402
  },
5403
5403
  topIssues,
5404
5404
  trace: {
5405
- schemaVersion: QUALITY_TRACE_VERSION,
5405
+ schemaVersion: HEALTH_TRACE_VERSION,
5406
5406
  dimensions: [
5407
- createDimensionTrace("modularity", modularityQuality, modularityFactors),
5408
- createDimensionTrace("changeHygiene", changeHygieneQuality, changeHygieneFactors),
5409
- createDimensionTrace("staticAnalysis", staticAnalysisQuality, staticAnalysisFactors),
5410
- createDimensionTrace("complexity", complexityQuality, complexityFactors),
5411
- createDimensionTrace("duplication", duplicationQuality, duplicationFactors),
5412
- createDimensionTrace("testHealth", testHealthQuality, testHealthFactors)
5407
+ createDimensionTrace("modularity", modularityHealth, modularityFactors),
5408
+ createDimensionTrace("changeHygiene", changeHygieneHealth, changeHygieneFactors),
5409
+ createDimensionTrace("testHealth", testHealthScore, testHealthFactors),
5410
+ createDimensionTrace(
5411
+ "ownershipDistribution",
5412
+ ownershipDistributionHealth,
5413
+ ownershipDistributionFactors
5414
+ )
5413
5415
  ]
5414
5416
  }
5415
5417
  };
@@ -6653,8 +6655,8 @@ var evaluateRepositoryRisk = (input, options = {}) => {
6653
6655
  };
6654
6656
 
6655
6657
  // src/application/run-analyze-command.ts
6656
- var resolveTargetPath = (inputPath, cwd) => resolve4(cwd, inputPath ?? ".");
6657
- var riskProfileConfig = {
6658
+ var resolveTargetPath = (inputPath, cwd) => resolve3(cwd, inputPath ?? ".");
6659
+ var scoringProfileConfig = {
6658
6660
  default: void 0,
6659
6661
  personal: {
6660
6662
  evolutionFactorWeights: {
@@ -6666,8 +6668,17 @@ var riskProfileConfig = {
6666
6668
  }
6667
6669
  }
6668
6670
  };
6669
- var resolveRiskConfigForProfile = (riskProfile) => {
6670
- return riskProfileConfig[riskProfile ?? "default"];
6671
+ var healthProfileConfig = {
6672
+ default: void 0,
6673
+ personal: {
6674
+ ownershipPenaltyMultiplier: 0.25
6675
+ }
6676
+ };
6677
+ var resolveRiskConfigForProfile = (scoringProfile) => {
6678
+ return scoringProfileConfig[scoringProfile ?? "default"];
6679
+ };
6680
+ var resolveHealthConfigForProfile = (scoringProfile) => {
6681
+ return healthProfileConfig[scoringProfile ?? "default"];
6671
6682
  };
6672
6683
  var createExternalProgressReporter = (logger) => {
6673
6684
  let lastLoggedProgress = 0;
@@ -6822,16 +6833,10 @@ var collectAnalysisInputs = async (inputPath, authorIdentityMode, options = {},
6822
6833
  } else {
6823
6834
  logger.warn(`external analysis unavailable: ${external.reason}`);
6824
6835
  }
6825
- logger.info("collecting quality signals");
6826
- const qualitySignals = await collectQualitySignals(targetPath, structural, logger);
6827
- logger.debug(
6828
- `quality signals: todoFixmeCommentCount=${qualitySignals.todoFixmeCommentCount ?? 0}, eslintErrors=${qualitySignals.eslint?.errorCount ?? 0}, tscErrors=${qualitySignals.typescript?.errorCount ?? 0}`
6829
- );
6830
6836
  return {
6831
6837
  structural,
6832
6838
  evolution,
6833
- external,
6834
- qualitySignals
6839
+ external
6835
6840
  };
6836
6841
  };
6837
6842
  var runAnalyzeCommand = async (inputPath, authorIdentityMode, options = {}, logger = createSilentLogger()) => {
@@ -6842,32 +6847,33 @@ var runAnalyzeCommand = async (inputPath, authorIdentityMode, options = {}, logg
6842
6847
  logger
6843
6848
  );
6844
6849
  logger.info("computing risk summary");
6845
- const riskConfig = resolveRiskConfigForProfile(options.riskProfile);
6850
+ const riskConfig = resolveRiskConfigForProfile(options.scoringProfile);
6851
+ const healthConfig = resolveHealthConfigForProfile(options.scoringProfile);
6846
6852
  const risk = computeRepositoryRiskSummary({
6847
6853
  structural: analysisInputs.structural,
6848
6854
  evolution: analysisInputs.evolution,
6849
6855
  external: analysisInputs.external,
6850
6856
  ...riskConfig === void 0 ? {} : { config: riskConfig }
6851
6857
  });
6852
- const quality = computeRepositoryQualitySummary({
6858
+ const health = computeRepositoryHealthSummary({
6853
6859
  structural: analysisInputs.structural,
6854
6860
  evolution: analysisInputs.evolution,
6855
- signals: analysisInputs.qualitySignals
6861
+ ...healthConfig === void 0 ? {} : { config: healthConfig }
6856
6862
  });
6857
6863
  logger.info(
6858
- `analysis completed (riskScore=${risk.riskScore}, qualityScore=${quality.qualityScore})`
6864
+ `analysis completed (riskScore=${risk.riskScore}, healthScore=${health.healthScore})`
6859
6865
  );
6860
6866
  return {
6861
6867
  structural: analysisInputs.structural,
6862
6868
  evolution: analysisInputs.evolution,
6863
6869
  external: analysisInputs.external,
6864
6870
  risk,
6865
- quality
6871
+ health
6866
6872
  };
6867
6873
  };
6868
6874
 
6869
6875
  // src/application/run-check-command.ts
6870
- import { readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
6876
+ import { readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
6871
6877
 
6872
6878
  // src/application/build-analysis-snapshot.ts
6873
6879
  var buildAnalysisSnapshot = async (inputPath, authorIdentityMode, options, logger) => {
@@ -6879,7 +6885,8 @@ var buildAnalysisSnapshot = async (inputPath, authorIdentityMode, options, logge
6879
6885
  },
6880
6886
  logger
6881
6887
  );
6882
- const riskConfig = resolveRiskConfigForProfile(options.riskProfile);
6888
+ const riskConfig = resolveRiskConfigForProfile(options.scoringProfile);
6889
+ const healthConfig = resolveHealthConfigForProfile(options.scoringProfile);
6883
6890
  const evaluation = evaluateRepositoryRisk(
6884
6891
  {
6885
6892
  structural: analysisInputs.structural,
@@ -6894,10 +6901,10 @@ var buildAnalysisSnapshot = async (inputPath, authorIdentityMode, options, logge
6894
6901
  evolution: analysisInputs.evolution,
6895
6902
  external: analysisInputs.external,
6896
6903
  risk: evaluation.summary,
6897
- quality: computeRepositoryQualitySummary({
6904
+ health: computeRepositoryHealthSummary({
6898
6905
  structural: analysisInputs.structural,
6899
6906
  evolution: analysisInputs.evolution,
6900
- signals: analysisInputs.qualitySignals
6907
+ ...healthConfig === void 0 ? {} : { config: healthConfig }
6901
6908
  })
6902
6909
  };
6903
6910
  return createSnapshot({
@@ -6906,7 +6913,7 @@ var buildAnalysisSnapshot = async (inputPath, authorIdentityMode, options, logge
6906
6913
  analysisConfig: {
6907
6914
  authorIdentityMode,
6908
6915
  includeTrace: options.includeTrace,
6909
- riskProfile: options.riskProfile ?? "default",
6916
+ scoringProfile: options.scoringProfile ?? "default",
6910
6917
  recentWindowDays: analysisInputs.evolution.available ? analysisInputs.evolution.metrics.recentWindowDays : options.recentWindowDays ?? null
6911
6918
  }
6912
6919
  });
@@ -6941,7 +6948,7 @@ var runCheckCommand = async (inputPath, authorIdentityMode, options, logger = cr
6941
6948
  authorIdentityMode,
6942
6949
  {
6943
6950
  includeTrace: options.includeTrace,
6944
- ...options.riskProfile === void 0 ? {} : { riskProfile: options.riskProfile },
6951
+ ...options.scoringProfile === void 0 ? {} : { scoringProfile: options.scoringProfile },
6945
6952
  ...options.recentWindowDays === void 0 ? {} : { recentWindowDays: options.recentWindowDays }
6946
6953
  },
6947
6954
  logger
@@ -6976,7 +6983,7 @@ var runCheckCommand = async (inputPath, authorIdentityMode, options, logger = cr
6976
6983
  options.outputFormat
6977
6984
  );
6978
6985
  if (options.outputPath !== void 0) {
6979
- await writeFile2(options.outputPath, rendered, "utf8");
6986
+ await writeFile3(options.outputPath, rendered, "utf8");
6980
6987
  logger.info(`check output written: ${options.outputPath}`);
6981
6988
  }
6982
6989
  return {
@@ -6989,8 +6996,8 @@ var runCheckCommand = async (inputPath, authorIdentityMode, options, logger = cr
6989
6996
  };
6990
6997
 
6991
6998
  // src/application/run-ci-command.ts
6992
- import { readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
6993
- import { relative as relative3, resolve as resolve5 } from "path";
6999
+ import { readFile as readFile4, writeFile as writeFile4 } from "fs/promises";
7000
+ import { relative as relative2, resolve as resolve4 } from "path";
6994
7001
  var isPathOutsideBase = (value) => {
6995
7002
  return value === ".." || value.startsWith("../") || value.startsWith("..\\");
6996
7003
  };
@@ -7003,20 +7010,20 @@ var runCiCommand = async (inputPath, authorIdentityMode, options, logger = creat
7003
7010
  if (options.baselineSha !== void 0 && options.baselineRef !== "auto") {
7004
7011
  throw new GovernanceConfigurationError("baseline-sha requires --baseline-ref auto");
7005
7012
  }
7006
- const resolvedTargetPath = resolve5(inputPath ?? process.cwd());
7013
+ const resolvedTargetPath = resolve4(inputPath ?? process.cwd());
7007
7014
  logger.info("building current snapshot");
7008
7015
  const current = await buildAnalysisSnapshot(
7009
7016
  inputPath,
7010
7017
  authorIdentityMode,
7011
7018
  {
7012
7019
  includeTrace: options.includeTrace,
7013
- ...options.riskProfile === void 0 ? {} : { riskProfile: options.riskProfile },
7020
+ ...options.scoringProfile === void 0 ? {} : { scoringProfile: options.scoringProfile },
7014
7021
  ...options.recentWindowDays === void 0 ? {} : { recentWindowDays: options.recentWindowDays }
7015
7022
  },
7016
7023
  logger
7017
7024
  );
7018
7025
  if (options.snapshotPath !== void 0) {
7019
- await writeFile3(options.snapshotPath, JSON.stringify(current, null, 2), "utf8");
7026
+ await writeFile4(options.snapshotPath, JSON.stringify(current, null, 2), "utf8");
7020
7027
  logger.info(`snapshot written: ${options.snapshotPath}`);
7021
7028
  }
7022
7029
  let baseline;
@@ -7057,19 +7064,19 @@ var runCiCommand = async (inputPath, authorIdentityMode, options, logger = creat
7057
7064
  repositoryPath: resolvedTargetPath,
7058
7065
  baselineRef,
7059
7066
  analyzeWorktree: async (worktreePath, repositoryRoot) => {
7060
- const relativeTargetPath = relative3(repositoryRoot, resolvedTargetPath);
7067
+ const relativeTargetPath = relative2(repositoryRoot, resolvedTargetPath);
7061
7068
  if (isPathOutsideBase(relativeTargetPath)) {
7062
7069
  throw new GovernanceConfigurationError(
7063
7070
  `target path is outside git repository root: ${resolvedTargetPath}`
7064
7071
  );
7065
7072
  }
7066
- const baselineTargetPath = relativeTargetPath.length === 0 || relativeTargetPath === "." ? worktreePath : resolve5(worktreePath, relativeTargetPath);
7073
+ const baselineTargetPath = relativeTargetPath.length === 0 || relativeTargetPath === "." ? worktreePath : resolve4(worktreePath, relativeTargetPath);
7067
7074
  return buildAnalysisSnapshot(
7068
7075
  baselineTargetPath,
7069
7076
  authorIdentityMode,
7070
7077
  {
7071
7078
  includeTrace: options.includeTrace,
7072
- ...options.riskProfile === void 0 ? {} : { riskProfile: options.riskProfile },
7079
+ ...options.scoringProfile === void 0 ? {} : { scoringProfile: options.scoringProfile },
7073
7080
  ...options.recentWindowDays === void 0 ? {} : { recentWindowDays: options.recentWindowDays }
7074
7081
  },
7075
7082
  logger
@@ -7111,7 +7118,7 @@ var runCiCommand = async (inputPath, authorIdentityMode, options, logger = creat
7111
7118
 
7112
7119
  ${ciMarkdown}`;
7113
7120
  if (options.reportPath !== void 0) {
7114
- await writeFile3(options.reportPath, markdownSummary, "utf8");
7121
+ await writeFile4(options.reportPath, markdownSummary, "utf8");
7115
7122
  logger.info(`report written: ${options.reportPath}`);
7116
7123
  }
7117
7124
  const machineReadable = {
@@ -7123,7 +7130,7 @@ ${ciMarkdown}`;
7123
7130
  exitCode: gateResult.exitCode
7124
7131
  };
7125
7132
  if (options.jsonOutputPath !== void 0) {
7126
- await writeFile3(options.jsonOutputPath, JSON.stringify(machineReadable, null, 2), "utf8");
7133
+ await writeFile4(options.jsonOutputPath, JSON.stringify(machineReadable, null, 2), "utf8");
7127
7134
  logger.info(`ci machine output written: ${options.jsonOutputPath}`);
7128
7135
  }
7129
7136
  return {
@@ -7137,7 +7144,7 @@ ${ciMarkdown}`;
7137
7144
  };
7138
7145
 
7139
7146
  // src/application/run-report-command.ts
7140
- import { readFile as readFile5, writeFile as writeFile4 } from "fs/promises";
7147
+ import { readFile as readFile5, writeFile as writeFile5 } from "fs/promises";
7141
7148
  var runReportCommand = async (inputPath, authorIdentityMode, options, logger = createSilentLogger()) => {
7142
7149
  logger.info("building analysis snapshot");
7143
7150
  const current = await buildAnalysisSnapshot(
@@ -7145,13 +7152,13 @@ var runReportCommand = async (inputPath, authorIdentityMode, options, logger = c
7145
7152
  authorIdentityMode,
7146
7153
  {
7147
7154
  includeTrace: options.includeTrace,
7148
- ...options.riskProfile === void 0 ? {} : { riskProfile: options.riskProfile },
7155
+ ...options.scoringProfile === void 0 ? {} : { scoringProfile: options.scoringProfile },
7149
7156
  ...options.recentWindowDays === void 0 ? {} : { recentWindowDays: options.recentWindowDays }
7150
7157
  },
7151
7158
  logger
7152
7159
  );
7153
7160
  if (options.snapshotPath !== void 0) {
7154
- await writeFile4(options.snapshotPath, JSON.stringify(current, null, 2), "utf8");
7161
+ await writeFile5(options.snapshotPath, JSON.stringify(current, null, 2), "utf8");
7155
7162
  logger.info(`snapshot written: ${options.snapshotPath}`);
7156
7163
  }
7157
7164
  let report;
@@ -7166,7 +7173,7 @@ var runReportCommand = async (inputPath, authorIdentityMode, options, logger = c
7166
7173
  }
7167
7174
  const rendered = formatReport(report, options.format);
7168
7175
  if (options.outputPath !== void 0) {
7169
- await writeFile4(options.outputPath, rendered, "utf8");
7176
+ await writeFile5(options.outputPath, rendered, "utf8");
7170
7177
  logger.info(`report written: ${options.outputPath}`);
7171
7178
  }
7172
7179
  return { report, rendered };
@@ -7202,7 +7209,8 @@ var runExplainCommand = async (inputPath, authorIdentityMode, options, logger =
7202
7209
  logger
7203
7210
  );
7204
7211
  logger.info("computing explainable risk summary");
7205
- const riskConfig = resolveRiskConfigForProfile(options.riskProfile);
7212
+ const riskConfig = resolveRiskConfigForProfile(options.scoringProfile);
7213
+ const healthConfig = resolveHealthConfigForProfile(options.scoringProfile);
7206
7214
  const evaluation = evaluateRepositoryRisk(
7207
7215
  {
7208
7216
  structural: analysisInputs.structural,
@@ -7220,14 +7228,14 @@ var runExplainCommand = async (inputPath, authorIdentityMode, options, logger =
7220
7228
  evolution: analysisInputs.evolution,
7221
7229
  external: analysisInputs.external,
7222
7230
  risk: evaluation.summary,
7223
- quality: computeRepositoryQualitySummary({
7231
+ health: computeRepositoryHealthSummary({
7224
7232
  structural: analysisInputs.structural,
7225
7233
  evolution: analysisInputs.evolution,
7226
- signals: analysisInputs.qualitySignals
7234
+ ...healthConfig === void 0 ? {} : { config: healthConfig }
7227
7235
  })
7228
7236
  };
7229
7237
  logger.info(
7230
- `explanation completed (riskScore=${summary.risk.riskScore}, qualityScore=${summary.quality.qualityScore})`
7238
+ `explanation completed (riskScore=${summary.risk.riskScore}, healthScore=${summary.health.healthScore})`
7231
7239
  );
7232
7240
  return {
7233
7241
  summary,
@@ -7238,7 +7246,7 @@ var runExplainCommand = async (inputPath, authorIdentityMode, options, logger =
7238
7246
 
7239
7247
  // src/index.ts
7240
7248
  var program = new Command();
7241
- var packageJsonPath = resolve6(dirname2(fileURLToPath(import.meta.url)), "../package.json");
7249
+ var packageJsonPath = resolve5(dirname2(fileURLToPath(import.meta.url)), "../package.json");
7242
7250
  var { version } = JSON.parse(readFileSync2(packageJsonPath, "utf8"));
7243
7251
  var parseRecentWindowDays = (value) => {
7244
7252
  const parsed = Number.parseInt(value, 10);
@@ -7284,7 +7292,7 @@ var renderReportHighlightsText = (report) => {
7284
7292
  lines.push("Repository Summary");
7285
7293
  lines.push(` target: ${report.repository.targetPath}`);
7286
7294
  lines.push(` riskScore: ${report.repository.riskScore}`);
7287
- lines.push(` qualityScore: ${report.quality.qualityScore}`);
7295
+ lines.push(` healthScore: ${report.health.healthScore}`);
7288
7296
  lines.push(` normalizedScore: ${report.repository.normalizedScore}`);
7289
7297
  lines.push(` riskTier: ${report.repository.riskTier}`);
7290
7298
  lines.push("");
@@ -7300,7 +7308,7 @@ var renderReportHighlightsMarkdown = (report) => {
7300
7308
  lines.push("## Repository Summary");
7301
7309
  lines.push(`- target: \`${report.repository.targetPath}\``);
7302
7310
  lines.push(`- riskScore: \`${report.repository.riskScore}\``);
7303
- lines.push(`- qualityScore: \`${report.quality.qualityScore}\``);
7311
+ lines.push(`- healthScore: \`${report.health.healthScore}\``);
7304
7312
  lines.push(`- normalizedScore: \`${report.repository.normalizedScore}\``);
7305
7313
  lines.push(`- riskTier: \`${report.repository.riskTier}\``);
7306
7314
  lines.push("");
@@ -7318,7 +7326,7 @@ var renderCompactText = (report, explainSummary) => {
7318
7326
  lines.push("Repository");
7319
7327
  lines.push(` target: ${report.repository.targetPath}`);
7320
7328
  lines.push(` riskScore: ${report.repository.riskScore}`);
7321
- lines.push(` qualityScore: ${report.quality.qualityScore}`);
7329
+ lines.push(` healthScore: ${report.health.healthScore}`);
7322
7330
  lines.push(` riskTier: ${report.repository.riskTier}`);
7323
7331
  lines.push(
7324
7332
  ` dimensions: structural=${report.repository.dimensionScores.structural ?? "n/a"}, evolution=${report.repository.dimensionScores.evolution ?? "n/a"}, external=${report.repository.dimensionScores.external ?? "n/a"}, interactions=${report.repository.dimensionScores.interactions ?? "n/a"}`
@@ -7344,7 +7352,7 @@ var renderCompactMarkdown = (report, explainSummary) => {
7344
7352
  lines.push("## Repository");
7345
7353
  lines.push(`- target: \`${report.repository.targetPath}\``);
7346
7354
  lines.push(`- riskScore: \`${report.repository.riskScore}\``);
7347
- lines.push(`- qualityScore: \`${report.quality.qualityScore}\``);
7355
+ lines.push(`- healthScore: \`${report.health.healthScore}\``);
7348
7356
  lines.push(`- riskTier: \`${report.repository.riskTier}\``);
7349
7357
  lines.push(
7350
7358
  `- dimensions: structural=\`${report.repository.dimensionScores.structural ?? "n/a"}\`, evolution=\`${report.repository.dimensionScores.evolution ?? "n/a"}\`, external=\`${report.repository.dimensionScores.external ?? "n/a"}\`, interactions=\`${report.repository.dimensionScores.interactions ?? "n/a"}\``
@@ -7363,12 +7371,12 @@ var renderCompactMarkdown = (report, explainSummary) => {
7363
7371
  }
7364
7372
  return lines.join("\n");
7365
7373
  };
7366
- var riskProfileOption = () => new Option(
7367
- "--risk-profile <profile>",
7368
- "risk profile: default (balanced) or personal (down-weights single-maintainer ownership penalties)"
7374
+ var scoringProfileOption = () => new Option(
7375
+ "--scoring-profile <profile>",
7376
+ "scoring profile: default (balanced) or personal (down-weights single-maintainer ownership penalties for risk and health ownership)"
7369
7377
  ).choices(["default", "personal"]).default("default");
7370
7378
  program.name("codesentinel").description("Structural and evolutionary risk analysis for TypeScript/JavaScript codebases").version(version);
7371
- program.command("analyze").argument("[path]", "path to the project to analyze").addOption(riskProfileOption()).addOption(
7379
+ program.command("analyze").argument("[path]", "path to the project to analyze").addOption(scoringProfileOption()).addOption(
7372
7380
  new Option(
7373
7381
  "--author-identity <mode>",
7374
7382
  "author identity mode: likely_merge (heuristic) or strict_email (deterministic)"
@@ -7391,7 +7399,7 @@ program.command("analyze").argument("[path]", "path to the project to analyze").
7391
7399
  const summary = await runAnalyzeCommand(
7392
7400
  path,
7393
7401
  options.authorIdentity,
7394
- { recentWindowDays: options.recentWindowDays, riskProfile: options.riskProfile },
7402
+ { recentWindowDays: options.recentWindowDays, scoringProfile: options.scoringProfile },
7395
7403
  logger
7396
7404
  );
7397
7405
  const outputMode = options.json === true ? "json" : options.output;
@@ -7399,7 +7407,7 @@ program.command("analyze").argument("[path]", "path to the project to analyze").
7399
7407
  `);
7400
7408
  }
7401
7409
  );
7402
- program.command("explain").argument("[path]", "path to the project to analyze").addOption(riskProfileOption()).addOption(
7410
+ program.command("explain").argument("[path]", "path to the project to analyze").addOption(scoringProfileOption()).addOption(
7403
7411
  new Option(
7404
7412
  "--author-identity <mode>",
7405
7413
  "author identity mode: likely_merge (heuristic) or strict_email (deterministic)"
@@ -7425,7 +7433,7 @@ program.command("explain").argument("[path]", "path to the project to analyze").
7425
7433
  ...options.module === void 0 ? {} : { module: options.module },
7426
7434
  top: Number.isFinite(top) ? top : 5,
7427
7435
  recentWindowDays: options.recentWindowDays,
7428
- riskProfile: options.riskProfile,
7436
+ scoringProfile: options.scoringProfile,
7429
7437
  format: options.format
7430
7438
  };
7431
7439
  const result = await runExplainCommand(path, options.authorIdentity, explainOptions, logger);
@@ -7463,7 +7471,7 @@ program.command("dependency-risk").argument("<dependency>", "dependency spec to
7463
7471
  `);
7464
7472
  }
7465
7473
  );
7466
- program.command("report").argument("[path]", "path to the project to analyze").addOption(riskProfileOption()).addOption(
7474
+ program.command("report").argument("[path]", "path to the project to analyze").addOption(scoringProfileOption()).addOption(
7467
7475
  new Option(
7468
7476
  "--author-identity <mode>",
7469
7477
  "author identity mode: likely_merge (heuristic) or strict_email (deterministic)"
@@ -7492,7 +7500,7 @@ program.command("report").argument("[path]", "path to the project to analyze").a
7492
7500
  ...options.compare === void 0 ? {} : { comparePath: options.compare },
7493
7501
  ...options.snapshot === void 0 ? {} : { snapshotPath: options.snapshot },
7494
7502
  includeTrace: options.trace,
7495
- riskProfile: options.riskProfile,
7503
+ scoringProfile: options.scoringProfile,
7496
7504
  recentWindowDays: options.recentWindowDays
7497
7505
  },
7498
7506
  logger
@@ -7503,7 +7511,7 @@ program.command("report").argument("[path]", "path to the project to analyze").a
7503
7511
  }
7504
7512
  }
7505
7513
  );
7506
- program.command("run").argument("[path]", "path to the project to analyze").addOption(riskProfileOption()).addOption(
7514
+ program.command("run").argument("[path]", "path to the project to analyze").addOption(scoringProfileOption()).addOption(
7507
7515
  new Option(
7508
7516
  "--author-identity <mode>",
7509
7517
  "author identity mode: likely_merge (heuristic) or strict_email (deterministic)"
@@ -7535,7 +7543,7 @@ program.command("run").argument("[path]", "path to the project to analyze").addO
7535
7543
  top: Number.isFinite(top) ? top : 5,
7536
7544
  format: options.format,
7537
7545
  recentWindowDays: options.recentWindowDays,
7538
- riskProfile: options.riskProfile
7546
+ scoringProfile: options.scoringProfile
7539
7547
  },
7540
7548
  logger
7541
7549
  );
@@ -7544,7 +7552,7 @@ program.command("run").argument("[path]", "path to the project to analyze").addO
7544
7552
  ...options.trace === true ? { trace: explain.trace } : {}
7545
7553
  });
7546
7554
  if (options.snapshot !== void 0) {
7547
- await writeFile5(options.snapshot, JSON.stringify(snapshot, null, 2), "utf8");
7555
+ await writeFile6(options.snapshot, JSON.stringify(snapshot, null, 2), "utf8");
7548
7556
  logger.info(`snapshot written: ${options.snapshot}`);
7549
7557
  }
7550
7558
  const report = options.compare === void 0 ? createReport(snapshot) : createReport(
@@ -7593,7 +7601,7 @@ program.command("run").argument("[path]", "path to the project to analyze").addO
7593
7601
  },
7594
7602
  report: {
7595
7603
  repository: report.repository,
7596
- quality: report.quality,
7604
+ health: report.health,
7597
7605
  hotspots: report.hotspots.slice(0, 5),
7598
7606
  structural: report.structural,
7599
7607
  external: report.external
@@ -7742,27 +7750,27 @@ var parseMainBranches = (options) => {
7742
7750
  };
7743
7751
  var buildGateConfigFromOptions = (options) => {
7744
7752
  const maxRiskDelta = parseGateNumber(options.maxRiskDelta, "--max-risk-delta");
7745
- const maxQualityDelta = parseGateNumber(options.maxQualityDelta, "--max-quality-delta");
7753
+ const maxHealthDelta = parseGateNumber(options.maxHealthDelta, "--max-health-delta");
7746
7754
  const maxNewHotspots = parseGateNumber(options.maxNewHotspots, "--max-new-hotspots");
7747
7755
  const maxRiskScore = parseGateNumber(options.maxRiskScore, "--max-risk-score");
7748
- const minQualityScore = parseGateNumber(options.minQualityScore, "--min-quality-score");
7756
+ const minHealthScore = parseGateNumber(options.minHealthScore, "--min-health-score");
7749
7757
  const newHotspotScoreThreshold = parseGateNumber(
7750
7758
  options.newHotspotScoreThreshold,
7751
7759
  "--new-hotspot-score-threshold"
7752
7760
  );
7753
7761
  return {
7754
7762
  ...maxRiskDelta === void 0 ? {} : { maxRiskDelta },
7755
- ...maxQualityDelta === void 0 ? {} : { maxQualityDelta },
7763
+ ...maxHealthDelta === void 0 ? {} : { maxHealthDelta },
7756
7764
  ...options.noNewCycles === true ? { noNewCycles: true } : {},
7757
7765
  ...options.noNewHighRiskDeps === true ? { noNewHighRiskDeps: true } : {},
7758
7766
  ...maxNewHotspots === void 0 ? {} : { maxNewHotspots },
7759
7767
  ...maxRiskScore === void 0 ? {} : { maxRiskScore },
7760
- ...minQualityScore === void 0 ? {} : { minQualityScore },
7768
+ ...minHealthScore === void 0 ? {} : { minHealthScore },
7761
7769
  ...newHotspotScoreThreshold === void 0 ? {} : { newHotspotScoreThreshold },
7762
7770
  failOn: options.failOn
7763
7771
  };
7764
7772
  };
7765
- program.command("check").argument("[path]", "path to the project to analyze").addOption(riskProfileOption()).addOption(
7773
+ program.command("check").argument("[path]", "path to the project to analyze").addOption(scoringProfileOption()).addOption(
7766
7774
  new Option(
7767
7775
  "--author-identity <mode>",
7768
7776
  "author identity mode: likely_merge (heuristic) or strict_email (deterministic)"
@@ -7773,9 +7781,9 @@ program.command("check").argument("[path]", "path to the project to analyze").ad
7773
7781
  "log verbosity: silent, error, warn, info, debug (logs are written to stderr)"
7774
7782
  ).choices(["silent", "error", "warn", "info", "debug"]).default(parseLogLevel(process.env["CODESENTINEL_LOG_LEVEL"]))
7775
7783
  ).option("--compare <baseline>", "baseline snapshot path").option("--max-risk-delta <value>", "maximum allowed normalized risk score increase").option(
7776
- "--max-quality-delta <value>",
7777
- "maximum allowed normalized quality score regression versus baseline (requires --compare)"
7778
- ).option("--no-new-cycles", "fail if new structural cycles are introduced").option("--no-new-high-risk-deps", "fail if new high-risk direct dependencies are introduced").option("--max-new-hotspots <count>", "maximum allowed number of new hotspots").option("--new-hotspot-score-threshold <score>", "minimum hotspot score to count as new hotspot").option("--max-risk-score <score>", "absolute risk score limit (0..100)").option("--min-quality-score <score>", "minimum quality score threshold (0..100)").addOption(
7784
+ "--max-health-delta <value>",
7785
+ "maximum allowed normalized health score regression versus baseline (requires --compare)"
7786
+ ).option("--no-new-cycles", "fail if new structural cycles are introduced").option("--no-new-high-risk-deps", "fail if new high-risk direct dependencies are introduced").option("--max-new-hotspots <count>", "maximum allowed number of new hotspots").option("--new-hotspot-score-threshold <score>", "minimum hotspot score to count as new hotspot").option("--max-risk-score <score>", "absolute risk score limit (0..100)").option("--min-health-score <score>", "minimum health score threshold (0..100)").addOption(
7779
7787
  new Option("--fail-on <level>", "failing severity threshold").choices(["error", "warn"]).default("error")
7780
7788
  ).addOption(
7781
7789
  new Option("--format <mode>", "output format: text, json, md").choices(["text", "json", "md"]).default("text")
@@ -7795,7 +7803,7 @@ program.command("check").argument("[path]", "path to the project to analyze").ad
7795
7803
  {
7796
7804
  ...options.compare === void 0 ? {} : { baselinePath: options.compare },
7797
7805
  includeTrace: options.trace,
7798
- riskProfile: options.riskProfile,
7806
+ scoringProfile: options.scoringProfile,
7799
7807
  recentWindowDays: options.recentWindowDays,
7800
7808
  gateConfig,
7801
7809
  outputFormat: options.format,
@@ -7819,7 +7827,7 @@ program.command("check").argument("[path]", "path to the project to analyze").ad
7819
7827
  }
7820
7828
  }
7821
7829
  );
7822
- program.command("ci").argument("[path]", "path to the project to analyze").addOption(riskProfileOption()).addOption(
7830
+ program.command("ci").argument("[path]", "path to the project to analyze").addOption(scoringProfileOption()).addOption(
7823
7831
  new Option(
7824
7832
  "--author-identity <mode>",
7825
7833
  "author identity mode: likely_merge (heuristic) or strict_email (deterministic)"
@@ -7844,9 +7852,9 @@ program.command("ci").argument("[path]", "path to the project to analyze").addOp
7844
7852
  "--main-branches <names>",
7845
7853
  "comma-separated default branch candidates for auto baseline resolution (for example: main,master)"
7846
7854
  ).option("--snapshot <path>", "write current snapshot JSON to path").option("--report <path>", "write markdown CI summary report").option("--json-output <path>", "write machine-readable CI JSON output").option("--max-risk-delta <value>", "maximum allowed normalized risk score increase").option(
7847
- "--max-quality-delta <value>",
7848
- "maximum allowed normalized quality score regression versus baseline"
7849
- ).option("--no-new-cycles", "fail if new structural cycles are introduced").option("--no-new-high-risk-deps", "fail if new high-risk direct dependencies are introduced").option("--max-new-hotspots <count>", "maximum allowed number of new hotspots").option("--new-hotspot-score-threshold <score>", "minimum hotspot score to count as new hotspot").option("--max-risk-score <score>", "absolute risk score limit (0..100)").option("--min-quality-score <score>", "minimum quality score threshold (0..100)").addOption(
7855
+ "--max-health-delta <value>",
7856
+ "maximum allowed normalized health score regression versus baseline"
7857
+ ).option("--no-new-cycles", "fail if new structural cycles are introduced").option("--no-new-high-risk-deps", "fail if new high-risk direct dependencies are introduced").option("--max-new-hotspots <count>", "maximum allowed number of new hotspots").option("--new-hotspot-score-threshold <score>", "minimum hotspot score to count as new hotspot").option("--max-risk-score <score>", "absolute risk score limit (0..100)").option("--min-health-score <score>", "minimum health score threshold (0..100)").addOption(
7850
7858
  new Option("--fail-on <level>", "failing severity threshold").choices(["error", "warn"]).default("error")
7851
7859
  ).option("--no-trace", "disable trace embedding in generated snapshot").addOption(
7852
7860
  new Option(
@@ -7871,7 +7879,7 @@ program.command("ci").argument("[path]", "path to the project to analyze").addOp
7871
7879
  ...options.report === void 0 ? {} : { reportPath: options.report },
7872
7880
  ...options.jsonOutput === void 0 ? {} : { jsonOutputPath: options.jsonOutput },
7873
7881
  includeTrace: options.trace,
7874
- riskProfile: options.riskProfile,
7882
+ scoringProfile: options.scoringProfile,
7875
7883
  recentWindowDays: options.recentWindowDays,
7876
7884
  gateConfig
7877
7885
  },