codarch 0.1.1 → 0.1.3

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/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // apps/cli/src/commands/analyze.ts
4
- import fs7 from "node:fs";
4
+ import fs6 from "node:fs";
5
5
  import path5 from "node:path";
6
6
 
7
7
  // packages/parsers/src/ts/parser.ts
@@ -39,7 +39,9 @@ var TypeScriptParser = class {
39
39
  }
40
40
  parse(source, filePath) {
41
41
  const parser = usesTsxGrammar(filePath) ? this.tsxParser : this.tsParser;
42
- const root = parser.parse(source).rootNode;
42
+ const root = parser.parse(source, void 0, {
43
+ bufferSize: Math.max(64 * 1024, Buffer.byteLength(source, "utf8") + 1)
44
+ }).rootNode;
43
45
  return {
44
46
  metrics: measureSource(source, root),
45
47
  imports: collectImports(root)
@@ -256,6 +258,34 @@ var MAX_RESOLVE_DEPTH = 8;
256
258
  var BUILTINS = new Set(
257
259
  builtinModules.flatMap((name) => [name, `node:${name}`])
258
260
  );
261
+ var ASSET_EXTENSIONS = [
262
+ ".css",
263
+ ".scss",
264
+ ".sass",
265
+ ".less",
266
+ ".svg",
267
+ ".png",
268
+ ".jpg",
269
+ ".jpeg",
270
+ ".gif",
271
+ ".webp",
272
+ ".avif",
273
+ ".ico",
274
+ ".woff",
275
+ ".woff2",
276
+ ".ttf",
277
+ ".otf",
278
+ ".eot",
279
+ ".mp4",
280
+ ".webm",
281
+ ".mp3",
282
+ ".wav",
283
+ ".pdf"
284
+ ];
285
+ function isAssetSpecifier(specifier) {
286
+ const clean = specifier.split(/[?#]/, 1)[0]?.toLowerCase() ?? "";
287
+ return ASSET_EXTENSIONS.some((extension) => clean.endsWith(extension));
288
+ }
259
289
  var ImportResolver = class {
260
290
  warnings = [];
261
291
  root;
@@ -815,7 +845,7 @@ function stronglyConnectedComponents(nodes, adjacency, reverse) {
815
845
  }
816
846
  function componentLevels(components, edges, componentOf) {
817
847
  const condensation = /* @__PURE__ */ new Map();
818
- const indegree = new Array(components.length).fill(0);
848
+ const indegree = Array.from({ length: components.length }).fill(0);
819
849
  for (const edge of edges) {
820
850
  const from = componentOf.get(edge.from);
821
851
  const to = componentOf.get(edge.to);
@@ -827,7 +857,7 @@ function componentLevels(components, edges, componentOf) {
827
857
  indegree[to] = (indegree[to] ?? 0) + 1;
828
858
  }
829
859
  }
830
- const levels = new Array(components.length).fill(0);
860
+ const levels = Array.from({ length: components.length }).fill(0);
831
861
  const queue = [];
832
862
  for (let index = 0; index < components.length; index++) {
833
863
  if (indegree[index] === 0) queue.push(index);
@@ -857,22 +887,22 @@ import path4 from "node:path";
857
887
  // packages/schema/src/errors.ts
858
888
  var CcJsonError = class extends Error {
859
889
  path;
860
- constructor(message, path8) {
861
- super(`${message} (en ${path8})`);
890
+ constructor(message, path9) {
891
+ super(`${message} (en ${path9})`);
862
892
  this.name = "CcJsonError";
863
- this.path = path8;
893
+ this.path = path9;
864
894
  }
865
895
  };
866
896
  function isRecord2(value) {
867
897
  return typeof value === "object" && value !== null && !Array.isArray(value);
868
898
  }
869
- function requireRecord(value, path8) {
870
- if (!isRecord2(value)) throw new CcJsonError("se esperaba un objeto", path8);
899
+ function requireRecord(value, path9) {
900
+ if (!isRecord2(value)) throw new CcJsonError("se esperaba un objeto", path9);
871
901
  return value;
872
902
  }
873
- function requireString(value, path8, field5) {
903
+ function requireString(value, path9, field5) {
874
904
  if (typeof value !== "string" || value.length === 0) {
875
- throw new CcJsonError(`"${field5}" debe ser una cadena no vac\xEDa`, path8);
905
+ throw new CcJsonError(`"${field5}" debe ser una cadena no vac\xEDa`, path9);
876
906
  }
877
907
  return value;
878
908
  }
@@ -883,9 +913,9 @@ var ID_LENGTH = 16;
883
913
  function normalizeName(name) {
884
914
  return name.normalize("NFC");
885
915
  }
886
- function pathSegments(path8) {
916
+ function pathSegments(path9) {
887
917
  const segments = [];
888
- for (const raw of path8.replace(/\\/g, "/").split("/")) {
918
+ for (const raw of path9.replace(/\\/g, "/").split("/")) {
889
919
  const segment = normalizeName(raw);
890
920
  if (segment === "" || segment === ".") continue;
891
921
  if (segment === "..") {
@@ -899,18 +929,18 @@ function pathSegments(path8) {
899
929
  function canonicalPath(segments) {
900
930
  return `/${segments.map(normalizeName).join("/")}`;
901
931
  }
902
- function idFromCanonicalPath(path8, type = "File") {
903
- const canonical = path8.startsWith("/") ? path8 : `/${path8}`;
932
+ function idFromCanonicalPath(path9, type = "File") {
933
+ const canonical = path9.startsWith("/") ? path9 : `/${path9}`;
904
934
  return createHash("sha256").update(type + canonical, "utf8").digest("hex").slice(0, ID_LENGTH);
905
935
  }
906
936
  function nodeId(type, segments) {
907
937
  return idFromCanonicalPath(canonicalPath(segments), type);
908
938
  }
909
- function fileId(path8) {
910
- return nodeId("File", pathSegments(path8));
939
+ function fileId(path9) {
940
+ return nodeId("File", pathSegments(path9));
911
941
  }
912
- function folderId(path8) {
913
- return nodeId("Folder", pathSegments(path8));
942
+ function folderId(path9) {
943
+ return nodeId("Folder", pathSegments(path9));
914
944
  }
915
945
 
916
946
  // packages/schema/src/legacy.ts
@@ -966,15 +996,15 @@ function normalizeLegacyCcJson(input) {
966
996
  const metrics = {};
967
997
  const built = [];
968
998
  const ids = /* @__PURE__ */ new Map();
969
- const buildNode = (value, parentPath, path8, depth) => {
999
+ const buildNode = (value, parentPath, path9, depth) => {
970
1000
  if (depth > MAX_DEPTH) {
971
- throw new CcJsonError(`profundidad m\xE1xima ${MAX_DEPTH} superada`, path8);
1001
+ throw new CcJsonError(`profundidad m\xE1xima ${MAX_DEPTH} superada`, path9);
972
1002
  }
973
- const raw = requireRecord(value, path8);
974
- const name = readNodeName(raw.name, `${path8}.name`);
1003
+ const raw = requireRecord(value, path9);
1004
+ const name = readNodeName(raw.name, `${path9}.name`);
975
1005
  const type = raw.type;
976
1006
  if (type !== "File" && type !== "Folder") {
977
- throw new CcJsonError('"type" debe ser "File" o "Folder"', `${path8}.type`);
1007
+ throw new CcJsonError('"type" debe ser "File" o "Folder"', `${path9}.type`);
978
1008
  }
979
1009
  const fullPath = [...parentPath, name];
980
1010
  const id = nodeId(type, fullPath.slice(1));
@@ -982,13 +1012,13 @@ function normalizeLegacyCcJson(input) {
982
1012
  if (previous !== void 0) {
983
1013
  throw new CcJsonError(
984
1014
  `id duplicado "${id}" para "${previous}" y "${fullPath.join("/")}"`,
985
- `${path8}.name`
1015
+ `${path9}.name`
986
1016
  );
987
1017
  }
988
1018
  ids.set(id, fullPath.join("/"));
989
1019
  built.push({ id, name, fullPath: fullPath.join("/") });
990
1020
  const node = { id, name, type };
991
- const attributes = readMetrics(raw.attributes, `${path8}.attributes`);
1021
+ const attributes = readMetrics(raw.attributes, `${path9}.attributes`);
992
1022
  if (attributes) metrics[id] = attributes;
993
1023
  const contentHash = readOptionalString(raw.checksum) ?? readOptionalString(raw.contentHash);
994
1024
  if (contentHash) node.contentHash = contentHash;
@@ -996,14 +1026,14 @@ function normalizeLegacyCcJson(input) {
996
1026
  if (link) node.link = link;
997
1027
  if (raw.children !== void 0) {
998
1028
  if (!Array.isArray(raw.children)) {
999
- throw new CcJsonError('"children" debe ser un array', `${path8}.children`);
1029
+ throw new CcJsonError('"children" debe ser un array', `${path9}.children`);
1000
1030
  }
1001
1031
  if (type === "File" && raw.children.length > 0) {
1002
- throw new CcJsonError("un fichero no puede tener hijos", `${path8}.children`);
1032
+ throw new CcJsonError("un fichero no puede tener hijos", `${path9}.children`);
1003
1033
  }
1004
1034
  if (type === "Folder" && raw.children.length > 0) {
1005
1035
  node.children = raw.children.map(
1006
- (child, index) => buildNode(child, fullPath, `${path8}.children[${index}]`, depth + 1)
1036
+ (child, index) => buildNode(child, fullPath, `${path9}.children[${index}]`, depth + 1)
1007
1037
  );
1008
1038
  }
1009
1039
  }
@@ -1020,15 +1050,15 @@ function normalizeLegacyCcJson(input) {
1020
1050
  throw new CcJsonError('"edges" debe ser un array', `${base}.edges`);
1021
1051
  }
1022
1052
  edgesRaw.forEach((edge, index) => {
1023
- const path8 = `${base}.edges[${index}]`;
1024
- const raw = requireRecord(edge, path8);
1025
- const fromName = requireString(raw.fromNodeName, `${path8}.fromNodeName`, "fromNodeName");
1026
- const toName = requireString(raw.toNodeName, `${path8}.toNodeName`, "toNodeName");
1053
+ const path9 = `${base}.edges[${index}]`;
1054
+ const raw = requireRecord(edge, path9);
1055
+ const fromName = requireString(raw.fromNodeName, `${path9}.fromNodeName`, "fromNodeName");
1056
+ const toName = requireString(raw.toNodeName, `${path9}.toNodeName`, "toNodeName");
1027
1057
  const normalized = {
1028
- fromId: resolveEndpoint(fromName, pathIndex, stripNames, `${path8}.fromNodeName`),
1029
- toId: resolveEndpoint(toName, pathIndex, stripNames, `${path8}.toNodeName`)
1058
+ fromId: resolveEndpoint(fromName, pathIndex, stripNames, `${path9}.fromNodeName`),
1059
+ toId: resolveEndpoint(toName, pathIndex, stripNames, `${path9}.toNodeName`)
1030
1060
  };
1031
- const attributes = readMetrics(raw.attributes, `${path8}.attributes`);
1061
+ const attributes = readMetrics(raw.attributes, `${path9}.attributes`);
1032
1062
  if (attributes) normalized.attributes = attributes;
1033
1063
  if (typeof raw.isCyclic === "boolean") normalized.isCyclic = raw.isCyclic;
1034
1064
  if (typeof raw.isPointingUpwards === "boolean") {
@@ -1113,10 +1143,10 @@ function buildPathIndex(built, multipleRoots) {
1113
1143
  }
1114
1144
  return index;
1115
1145
  }
1116
- function resolveEndpoint(endpoint, index, stripNames, path8) {
1146
+ function resolveEndpoint(endpoint, index, stripNames, path9) {
1117
1147
  const segments = canonicalSegments(endpoint);
1118
1148
  if (segments.length === 0) {
1119
- throw new CcJsonError("extremo de arista vac\xEDo", path8);
1149
+ throw new CcJsonError("extremo de arista vac\xEDo", path9);
1120
1150
  }
1121
1151
  const candidates = [segments.join("/")];
1122
1152
  if (segments.length > 1 && stripNames.has(segments[0])) {
@@ -1128,7 +1158,7 @@ function resolveEndpoint(endpoint, index, stripNames, path8) {
1128
1158
  }
1129
1159
  const bare = index.get(segments[segments.length - 1]);
1130
1160
  if (bare) return bare;
1131
- throw new CcJsonError(`no se pudo resolver el nodo "${endpoint}"`, path8);
1161
+ throw new CcJsonError(`no se pudo resolver el nodo "${endpoint}"`, path9);
1132
1162
  }
1133
1163
  function canonicalSegments(value) {
1134
1164
  const result = [];
@@ -1143,85 +1173,85 @@ function canonicalSegments(value) {
1143
1173
  }
1144
1174
  return result;
1145
1175
  }
1146
- function readNodeName(value, path8) {
1147
- const name = normalizeName(requireString(value, path8, "name"));
1176
+ function readNodeName(value, path9) {
1177
+ const name = normalizeName(requireString(value, path9, "name"));
1148
1178
  if (name === "." || name === "..") {
1149
- throw new CcJsonError('el nombre no puede ser "." ni ".."', path8);
1179
+ throw new CcJsonError('el nombre no puede ser "." ni ".."', path9);
1150
1180
  }
1151
1181
  if (name.includes("/")) {
1152
- throw new CcJsonError('el nombre no puede contener "/"', path8);
1182
+ throw new CcJsonError('el nombre no puede contener "/"', path9);
1153
1183
  }
1154
1184
  return name;
1155
1185
  }
1156
1186
  function readOptionalString(value) {
1157
1187
  return typeof value === "string" && value.length > 0 ? value : void 0;
1158
1188
  }
1159
- function readMetrics(value, path8) {
1189
+ function readMetrics(value, path9) {
1160
1190
  if (value === void 0) return void 0;
1161
- const raw = requireRecord(value, path8);
1191
+ const raw = requireRecord(value, path9);
1162
1192
  const out = {};
1163
1193
  for (const [metric, metricValue] of Object.entries(raw)) {
1164
1194
  if (typeof metricValue !== "number" || !Number.isFinite(metricValue)) {
1165
1195
  throw new CcJsonError(
1166
1196
  `la m\xE9trica "${metric}" debe ser un n\xFAmero finito`,
1167
- `${path8}["${metric}"]`
1197
+ `${path9}["${metric}"]`
1168
1198
  );
1169
1199
  }
1170
1200
  out[metric] = metricValue;
1171
1201
  }
1172
1202
  return Object.keys(out).length > 0 ? out : void 0;
1173
1203
  }
1174
- function readAttributeTypes(value, path8) {
1204
+ function readAttributeTypes(value, path9) {
1175
1205
  if (value === void 0) return {};
1176
- const raw = requireRecord(value, path8);
1206
+ const raw = requireRecord(value, path9);
1177
1207
  if ("nodes" in raw || "edges" in raw) {
1178
- const nodes2 = raw.nodes === void 0 ? void 0 : readTypeMap(raw.nodes, `${path8}.nodes`);
1179
- const edges = raw.edges === void 0 ? void 0 : readTypeMap(raw.edges, `${path8}.edges`);
1208
+ const nodes2 = raw.nodes === void 0 ? void 0 : readTypeMap(raw.nodes, `${path9}.nodes`);
1209
+ const edges = raw.edges === void 0 ? void 0 : readTypeMap(raw.edges, `${path9}.edges`);
1180
1210
  return {
1181
1211
  ...nodes2 && Object.keys(nodes2).length > 0 ? { nodes: nodes2 } : {},
1182
1212
  ...edges && Object.keys(edges).length > 0 ? { edges } : {}
1183
1213
  };
1184
1214
  }
1185
- const nodes = readTypeMap(raw, path8);
1215
+ const nodes = readTypeMap(raw, path9);
1186
1216
  return Object.keys(nodes).length > 0 ? { nodes } : {};
1187
1217
  }
1188
- function readTypeMap(value, path8) {
1189
- const raw = requireRecord(value, path8);
1218
+ function readTypeMap(value, path9) {
1219
+ const raw = requireRecord(value, path9);
1190
1220
  const out = {};
1191
1221
  for (const [metric, type] of Object.entries(raw)) {
1192
1222
  if (type !== "absolute" && type !== "relative") {
1193
1223
  throw new CcJsonError(
1194
1224
  `el tipo de "${metric}" debe ser "absolute" o "relative"`,
1195
- `${path8}["${metric}"]`
1225
+ `${path9}["${metric}"]`
1196
1226
  );
1197
1227
  }
1198
1228
  out[metric] = type;
1199
1229
  }
1200
1230
  return out;
1201
1231
  }
1202
- function readDescriptors(value, path8) {
1232
+ function readDescriptors(value, path9) {
1203
1233
  if (value === void 0) return {};
1204
- const raw = requireRecord(value, path8);
1234
+ const raw = requireRecord(value, path9);
1205
1235
  if ("nodes" in raw || "edges" in raw) {
1206
- const nodes = raw.nodes === void 0 ? void 0 : readDescriptorMap(raw.nodes, `${path8}.nodes`);
1207
- const edges = raw.edges === void 0 ? void 0 : readDescriptorMap(raw.edges, `${path8}.edges`);
1236
+ const nodes = raw.nodes === void 0 ? void 0 : readDescriptorMap(raw.nodes, `${path9}.nodes`);
1237
+ const edges = raw.edges === void 0 ? void 0 : readDescriptorMap(raw.edges, `${path9}.edges`);
1208
1238
  return {
1209
1239
  ...nodes && Object.keys(nodes).length > 0 ? { nodes } : {},
1210
1240
  ...edges && Object.keys(edges).length > 0 ? { edges } : {}
1211
1241
  };
1212
1242
  }
1213
- const flat = readDescriptorMap(raw, path8);
1243
+ const flat = readDescriptorMap(raw, path9);
1214
1244
  return Object.keys(flat).length > 0 ? { flat } : {};
1215
1245
  }
1216
- function readDescriptorMap(value, path8) {
1217
- const raw = requireRecord(value, path8);
1246
+ function readDescriptorMap(value, path9) {
1247
+ const raw = requireRecord(value, path9);
1218
1248
  const out = {};
1219
1249
  for (const [metric, descriptor] of Object.entries(raw)) {
1220
- const record = requireRecord(descriptor, `${path8}["${metric}"]`);
1250
+ const record = requireRecord(descriptor, `${path9}["${metric}"]`);
1221
1251
  if (record.direction !== void 0 && typeof record.direction !== "number") {
1222
1252
  throw new CcJsonError(
1223
1253
  `"direction" de "${metric}" debe ser un n\xFAmero`,
1224
- `${path8}["${metric}"].direction`
1254
+ `${path9}["${metric}"].direction`
1225
1255
  );
1226
1256
  }
1227
1257
  out[metric] = record;
@@ -1229,11 +1259,10 @@ function readDescriptorMap(value, path8) {
1229
1259
  return out;
1230
1260
  }
1231
1261
 
1232
- // packages/schema/src/parse.ts
1262
+ // packages/schema/src/parse2.ts
1233
1263
  var MAX_DEPTH2 = 512;
1234
- function parseCcJson(input) {
1264
+ function parseCcJson2(input) {
1235
1265
  if (!isRecord2(input)) throw new CcJsonError("se esperaba un objeto", "$");
1236
- if (isLegacyCcJson(input)) return normalizeLegacyCcJson(input);
1237
1266
  const metaRaw = requireRecord(input.meta, "$.meta");
1238
1267
  const apiVersion = requireString(metaRaw.apiVersion, "$.meta.apiVersion", "apiVersion");
1239
1268
  if (!apiVersion.startsWith("2.")) {
@@ -1247,26 +1276,26 @@ function parseCcJson(input) {
1247
1276
  const roots = Array.isArray(filesRaw) ? filesRaw : [filesRaw];
1248
1277
  if (roots.length === 0) throw new CcJsonError("el \xE1rbol est\xE1 vac\xEDo", "$.files");
1249
1278
  const ids = /* @__PURE__ */ new Set();
1250
- const validateNode = (value, path8, depth) => {
1251
- if (depth > MAX_DEPTH2) throw new CcJsonError(`profundidad m\xE1xima ${MAX_DEPTH2} superada`, path8);
1252
- const raw = requireRecord(value, path8);
1253
- const id = requireString(raw.id, `${path8}.id`, "id");
1254
- if (ids.has(id)) throw new CcJsonError(`id duplicado "${id}"`, `${path8}.id`);
1279
+ const validateNode = (value, path9, depth) => {
1280
+ if (depth > MAX_DEPTH2) throw new CcJsonError(`profundidad m\xE1xima ${MAX_DEPTH2} superada`, path9);
1281
+ const raw = requireRecord(value, path9);
1282
+ const id = requireString(raw.id, `${path9}.id`, "id");
1283
+ if (ids.has(id)) throw new CcJsonError(`id duplicado "${id}"`, `${path9}.id`);
1255
1284
  ids.add(id);
1256
- const name = requireString(raw.name, `${path8}.name`, "name");
1285
+ const name = requireString(raw.name, `${path9}.name`, "name");
1257
1286
  const type = raw.type;
1258
1287
  if (type !== "File" && type !== "Folder") {
1259
- throw new CcJsonError('"type" debe ser "File" o "Folder"', `${path8}.type`);
1288
+ throw new CcJsonError('"type" debe ser "File" o "Folder"', `${path9}.type`);
1260
1289
  }
1261
1290
  const node = { id, name, type };
1262
1291
  if (typeof raw.contentHash === "string") node.contentHash = raw.contentHash;
1263
1292
  if (typeof raw.link === "string") node.link = raw.link;
1264
1293
  if (raw.children !== void 0) {
1265
1294
  if (!Array.isArray(raw.children)) {
1266
- throw new CcJsonError('"children" debe ser un array', `${path8}.children`);
1295
+ throw new CcJsonError('"children" debe ser un array', `${path9}.children`);
1267
1296
  }
1268
1297
  node.children = raw.children.map(
1269
- (child, index) => validateNode(child, `${path8}.children[${index}]`, depth + 1)
1298
+ (child, index) => validateNode(child, `${path9}.children[${index}]`, depth + 1)
1270
1299
  );
1271
1300
  }
1272
1301
  return node;
@@ -1297,10 +1326,10 @@ function parseCcJson(input) {
1297
1326
  throw new CcJsonError('"edges" debe ser un array', "$.lenses.dependency.edges");
1298
1327
  }
1299
1328
  dependency.edges.forEach((edge, index) => {
1300
- const path8 = `$.lenses.dependency.edges[${index}]`;
1301
- const raw = requireRecord(edge, path8);
1302
- requireString(raw.fromId, `${path8}.fromId`, "fromId");
1303
- requireString(raw.toId, `${path8}.toId`, "toId");
1329
+ const path9 = `$.lenses.dependency.edges[${index}]`;
1330
+ const raw = requireRecord(edge, path9);
1331
+ requireString(raw.fromId, `${path9}.fromId`, "fromId");
1332
+ requireString(raw.toId, `${path9}.toId`, "toId");
1304
1333
  });
1305
1334
  }
1306
1335
  }
@@ -1313,6 +1342,12 @@ function parseCcJson(input) {
1313
1342
  };
1314
1343
  }
1315
1344
 
1345
+ // packages/schema/src/parse.ts
1346
+ function parseCcJson(input) {
1347
+ if (isLegacyCcJson(input)) return normalizeLegacyCcJson(input);
1348
+ return parseCcJson2(input);
1349
+ }
1350
+
1316
1351
  // packages/schema/src/tree.ts
1317
1352
  function walkTree(roots, visit) {
1318
1353
  const stack = roots.map((node) => ({ node, path: node.name, depth: 0 })).reverse();
@@ -1383,9 +1418,9 @@ function topByMetric(cc, metric, limit = 10) {
1383
1418
  const ranked = [];
1384
1419
  for (const [id, values] of Object.entries(attributes)) {
1385
1420
  const value = values[metric];
1386
- const path8 = paths.get(id);
1387
- if (value === void 0 || path8 === void 0) continue;
1388
- ranked.push({ id, path: path8, value });
1421
+ const path9 = paths.get(id);
1422
+ if (value === void 0 || path9 === void 0) continue;
1423
+ ranked.push({ id, path: path9, value });
1389
1424
  }
1390
1425
  ranked.sort((a, b) => b.value - a.value || a.path.localeCompare(b.path));
1391
1426
  return ranked.slice(0, limit);
@@ -1954,8 +1989,8 @@ function buildSnapshot(input) {
1954
1989
  isCyclic: edge.isCyclic
1955
1990
  }));
1956
1991
  const dependencyNodes = {};
1957
- for (const [path8, metrics] of input.graph.metrics) {
1958
- dependencyNodes[fileId(path8)] = { level: metrics.dependencyLevel };
1992
+ for (const [path9, metrics] of input.graph.metrics) {
1993
+ dependencyNodes[fileId(path9)] = { level: metrics.dependencyLevel };
1959
1994
  }
1960
1995
  const migrationLens = input.migration ? migrationLensFrom(input.migration) : void 0;
1961
1996
  const lenses = {
@@ -2219,6 +2254,7 @@ function analyzeRepository(rootInput, options = {}) {
2219
2254
  let fileUnresolvedImports = 0;
2220
2255
  for (const imported of parsed.imports) {
2221
2256
  if (imported.typeOnly) fileTypeOnlyImports++;
2257
+ if (isAssetSpecifier(imported.specifier)) continue;
2222
2258
  const result = resolver.resolve(file.absolutePath, imported.specifier);
2223
2259
  if (result.kind === "internal") {
2224
2260
  const targetLanguage = languageForPath(result.file.relativePath);
@@ -2369,1900 +2405,1902 @@ function readProjectName(root) {
2369
2405
  return path4.basename(root) || "Proyecto";
2370
2406
  }
2371
2407
 
2372
- // packages/metrics/src/config.ts
2373
- import fs5 from "node:fs";
2374
- import { parse as parseYaml } from "yaml";
2375
- var ConfigError = class extends Error {
2408
+ // packages/metrics/src/risk.ts
2409
+ var COMPLEXITY_METRIC_CANDIDATES = [
2410
+ "maxComplexity",
2411
+ "max_complexity_per_function",
2412
+ "complexity",
2413
+ "logic_complexity",
2414
+ "sonar_cognitive_complexity"
2415
+ ];
2416
+ var CHURN_METRIC_CANDIDATES = [
2417
+ "number_of_commits",
2418
+ "number_of_authors",
2419
+ "number_of_renames"
2420
+ ];
2421
+ var RISK_WEIGHTS = {
2422
+ complexity: 0.5,
2423
+ churn: 0.32,
2424
+ size: 0.18
2425
+ };
2426
+ var RISK_GAMMA = 1.7;
2427
+ function riskScores(files) {
2428
+ const complexityP95 = percentile(
2429
+ files.map((file) => file.complexity).filter((value) => value > 0),
2430
+ 0.95
2431
+ );
2432
+ const churnP95 = percentile(
2433
+ files.map((file) => file.churn).filter((value) => value > 0),
2434
+ 0.95
2435
+ );
2436
+ const sizeP95 = percentile(
2437
+ files.map((file) => file.size).filter((value) => value > 0),
2438
+ 0.95
2439
+ );
2440
+ const complexityWeight = complexityP95 > 0 ? RISK_WEIGHTS.complexity : 0;
2441
+ const churnWeight = churnP95 > 0 ? RISK_WEIGHTS.churn : 0;
2442
+ const sizeWeight = sizeP95 > 0 ? RISK_WEIGHTS.size : 0;
2443
+ const totalWeight = complexityWeight + churnWeight + sizeWeight;
2444
+ if (totalWeight === 0) return files.map(() => 0);
2445
+ return files.map((file) => {
2446
+ const raw = (complexityWeight * norm(file.complexity, complexityP95) + churnWeight * norm(file.churn, churnP95) + sizeWeight * norm(file.size, sizeP95)) / totalWeight;
2447
+ return Math.pow(clamp01(raw), RISK_GAMMA);
2448
+ });
2449
+ }
2450
+ function firstMetric(available, candidates) {
2451
+ const set = available instanceof Set ? available : new Set(available);
2452
+ for (const candidate of candidates) {
2453
+ if (set.has(candidate)) return candidate;
2454
+ }
2455
+ return null;
2456
+ }
2457
+ function norm(value, p95) {
2458
+ return p95 > 0 ? clamp01(value / p95) : 0;
2459
+ }
2460
+ function percentile(values, p) {
2461
+ if (values.length === 0) return 0;
2462
+ const sorted = [...values].sort((a, b) => a - b);
2463
+ const index = Math.min(
2464
+ sorted.length - 1,
2465
+ Math.max(0, Math.floor((sorted.length - 1) * p))
2466
+ );
2467
+ return sorted[index] ?? 0;
2468
+ }
2469
+ function clamp01(value) {
2470
+ if (!Number.isFinite(value)) return 0;
2471
+ return Math.max(0, Math.min(1, value));
2472
+ }
2473
+
2474
+ // packages/render/src/model.ts
2475
+ var RenderError = class extends Error {
2376
2476
  constructor(message) {
2377
2477
  super(message);
2378
- this.name = "ConfigError";
2478
+ this.name = "RenderError";
2379
2479
  }
2380
2480
  };
2381
- var SEVERITIES = /* @__PURE__ */ new Set(["info", "warn", "error"]);
2382
- function parseRuleConfig(input) {
2383
- const root = asObject(input, "config");
2384
- if (root.version !== 1) {
2385
- throw new ConfigError("config.version debe ser 1");
2386
- }
2387
- const layers = parseLayers(root.layers);
2388
- const layerNames = new Set(layers.map((layer) => layer.name));
2389
- const rules = parseRules(root.rules, layerNames);
2390
- return { version: 1, layers, rules };
2481
+ var PALETTE = [
2482
+ "#b9c4d0",
2483
+ "#c9b8a8",
2484
+ "#a9bfae",
2485
+ "#c4b6c9",
2486
+ "#c9c2a6",
2487
+ "#d5d2ca",
2488
+ "#c2c8d0",
2489
+ "#d6c6bd",
2490
+ "#b7c6c2",
2491
+ "#cbbfb0",
2492
+ "#bcc3b9",
2493
+ "#cfc6c9"
2494
+ ];
2495
+ var CURATED_METRICS = [
2496
+ "rloc",
2497
+ "maxComplexity",
2498
+ "max_complexity_per_function",
2499
+ "complexity",
2500
+ "functions",
2501
+ "number_of_functions",
2502
+ "maxFunctionLength",
2503
+ "loc",
2504
+ "imports",
2505
+ "externalImports",
2506
+ "builtinImports",
2507
+ "typeOnlyImports",
2508
+ "unresolvedImports",
2509
+ "fanIn",
2510
+ "fanOut",
2511
+ "inCycle",
2512
+ "cycleSize",
2513
+ "dependencyLevel",
2514
+ "number_of_commits",
2515
+ "number_of_authors",
2516
+ "incoming_dependencies",
2517
+ "outgoing_dependencies"
2518
+ ];
2519
+ function isRecord3(value) {
2520
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2391
2521
  }
2392
- function loadRuleConfig(file) {
2393
- let text;
2394
- try {
2395
- text = fs5.readFileSync(file, "utf8");
2396
- } catch (error) {
2397
- throw new ConfigError(
2398
- `no se pudo leer ${file}: ${error.message}`
2399
- );
2522
+ function valueOf(values, key) {
2523
+ if (!key || !values) return void 0;
2524
+ const value = values[key];
2525
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
2526
+ }
2527
+ function percentile2(values, p) {
2528
+ if (values.length === 0) return 0;
2529
+ const sorted = [...values].sort((a, b) => a - b);
2530
+ const index = Math.min(
2531
+ sorted.length - 1,
2532
+ Math.max(0, Math.floor((sorted.length - 1) * p))
2533
+ );
2534
+ return sorted[index] ?? 0;
2535
+ }
2536
+ function hashString(value) {
2537
+ let hash = 2166136261;
2538
+ for (let index = 0; index < value.length; index++) {
2539
+ hash ^= value.charCodeAt(index);
2540
+ hash = Math.imul(hash, 16777619);
2400
2541
  }
2401
- let value;
2402
- try {
2403
- value = parseYaml(text);
2404
- } catch (error) {
2405
- throw new ConfigError(
2406
- `YAML inv\xE1lido en ${file}: ${error.message}`
2407
- );
2542
+ return hash >>> 0;
2543
+ }
2544
+ function sectionForPath(path9) {
2545
+ const folders = path9.split("/").filter(Boolean).slice(0, -1);
2546
+ if (folders.length === 0) return "(ra\xEDz)";
2547
+ if (folders.length === 1) return folders[0];
2548
+ return folders.slice(0, 3).join("/");
2549
+ }
2550
+ function asEdges(value) {
2551
+ if (!Array.isArray(value)) return [];
2552
+ const edges = [];
2553
+ for (const item of value) {
2554
+ if (!isRecord3(item)) continue;
2555
+ if (typeof item.fromId !== "string" || typeof item.toId !== "string") continue;
2556
+ edges.push({
2557
+ fromId: item.fromId,
2558
+ toId: item.toId,
2559
+ ...item.isCyclic === true ? { isCyclic: true } : {}
2560
+ });
2408
2561
  }
2409
- try {
2410
- return parseRuleConfig(value);
2411
- } catch (error) {
2412
- if (error instanceof ConfigError) {
2413
- throw new ConfigError(`${file}: ${error.message}`);
2414
- }
2415
- throw error;
2562
+ return edges;
2563
+ }
2564
+ function metricTitle(attributes, key) {
2565
+ const descriptor = attributes?.[key];
2566
+ if (isRecord3(descriptor) && typeof descriptor.title === "string" && descriptor.title) {
2567
+ return descriptor.title;
2416
2568
  }
2569
+ return key;
2417
2570
  }
2418
- function parseLayers(value) {
2419
- if (value === void 0) return [];
2420
- if (!Array.isArray(value)) {
2421
- throw new ConfigError("config.layers debe ser una lista");
2571
+ function metricDirection(attributes, key) {
2572
+ const descriptor = attributes?.[key];
2573
+ if (isRecord3(descriptor) && typeof descriptor.direction === "number") {
2574
+ return descriptor.direction;
2422
2575
  }
2423
- const layers = [];
2424
- const names = /* @__PURE__ */ new Set();
2425
- value.forEach((raw, index) => {
2426
- const layer = asObject(raw, `config.layers[${index}]`);
2427
- const name = asString(layer.name, `config.layers[${index}].name`);
2428
- if (names.has(name)) {
2429
- throw new ConfigError(`capa duplicada: ${name}`);
2430
- }
2431
- names.add(name);
2432
- if (layer.patterns !== void 0 && layer.paths !== void 0) {
2433
- throw new ConfigError(
2434
- `config.layers[${index}] no puede tener patterns y paths a la vez`
2435
- );
2436
- }
2437
- const rawPatterns = layer.patterns ?? layer.paths;
2438
- if (!Array.isArray(rawPatterns) || rawPatterns.length === 0) {
2439
- throw new ConfigError(
2440
- `config.layers[${index}].patterns debe ser una lista no vac\xEDa`
2576
+ return 0;
2577
+ }
2578
+ function buildRenderModel(cc, options = {}) {
2579
+ const stats = getStats(cc);
2580
+ const coverage = new Set(stats.metricCoverage.map((entry) => entry.name));
2581
+ const attributes = cc.lenses?.metrics?.attributes ?? {};
2582
+ const descriptors = cc.lenses?.metrics?.attributeDescriptors;
2583
+ let areaMetric;
2584
+ if (options.areaMetric) {
2585
+ if (!coverage.has(options.areaMetric)) {
2586
+ throw new RenderError(
2587
+ `la m\xE9trica de \xE1rea "${options.areaMetric}" no existe en este cc.json`
2441
2588
  );
2442
2589
  }
2443
- const patterns = rawPatterns.map((rawPattern, patternIndex) => {
2444
- const pattern = asString(
2445
- rawPattern,
2446
- `config.layers[${index}].patterns[${patternIndex}]`
2447
- ).replaceAll("\\", "/").replace(/^\.\//, "");
2448
- if (pattern.startsWith("/") || /^[A-Za-z]:\//.test(pattern)) {
2449
- throw new ConfigError(
2450
- `el patr\xF3n "${pattern}" debe ser relativo al root del proyecto`
2451
- );
2452
- }
2453
- return pattern;
2454
- });
2455
- layers.push({ name, patterns });
2456
- });
2457
- return layers;
2458
- }
2459
- function parseRules(value, layerNames) {
2460
- if (value === void 0) return [];
2461
- if (!Array.isArray(value)) {
2462
- throw new ConfigError("config.rules debe ser una lista");
2590
+ areaMetric = options.areaMetric;
2591
+ } else {
2592
+ areaMetric = defaultMetric(stats) ?? null;
2463
2593
  }
2464
- const rules = [];
2465
- const names = /* @__PURE__ */ new Set();
2466
- value.forEach((raw, index) => {
2467
- const rule = asObject(raw, `config.rules[${index}]`);
2468
- const name = asString(rule.name, `config.rules[${index}].name`);
2469
- if (names.has(name)) {
2470
- throw new ConfigError(`regla duplicada: ${name}`);
2471
- }
2472
- names.add(name);
2473
- const type = asString(rule.type, `config.rules[${index}].type`);
2474
- const severity = parseSeverity(
2475
- rule.severity,
2476
- `config.rules[${index}].severity`
2477
- );
2478
- const message = rule.message === void 0 ? void 0 : asString(rule.message, `config.rules[${index}].message`);
2479
- if (type === "forbidden") {
2480
- const from = asString(rule.from, `config.rules[${index}].from`);
2481
- const to = asString(rule.to, `config.rules[${index}].to`);
2482
- if (!layerNames.has(from)) {
2483
- throw new ConfigError(
2484
- `config.rules[${index}].from apunta a una capa inexistente: ${from}`
2485
- );
2486
- }
2487
- if (!layerNames.has(to)) {
2488
- throw new ConfigError(
2489
- `config.rules[${index}].to apunta a una capa inexistente: ${to}`
2490
- );
2491
- }
2492
- rules.push({ type, name, from, to, severity, message });
2493
- return;
2494
- }
2495
- if (type === "threshold") {
2496
- const metric = asString(
2497
- rule.metric,
2498
- `config.rules[${index}].metric`
2594
+ const complexityKey = firstMetric(coverage, COMPLEXITY_METRIC_CANDIDATES);
2595
+ const churnKey = firstMetric(coverage, CHURN_METRIC_CANDIDATES);
2596
+ const metricKeys = [];
2597
+ const addMetric = (key) => {
2598
+ if (key && coverage.has(key) && !metricKeys.includes(key)) metricKeys.push(key);
2599
+ };
2600
+ for (const key of CURATED_METRICS) addMetric(key);
2601
+ addMetric(areaMetric);
2602
+ addMetric(complexityKey);
2603
+ addMetric(churnKey);
2604
+ if (options.color && options.color !== "risk" && options.color !== "structure") {
2605
+ addMetric(options.color);
2606
+ }
2607
+ const singleRoot = cc.files.length === 1 && cc.files[0]?.type === "Folder" ? cc.files[0] : null;
2608
+ const prefix = singleRoot ? `${singleRoot.name}/` : "";
2609
+ const rootName = singleRoot?.name ?? "";
2610
+ const warnings = [];
2611
+ const files = [];
2612
+ const fileIndexById = /* @__PURE__ */ new Map();
2613
+ walkFiles(cc.files, (entry) => {
2614
+ const fullPath = entry.path;
2615
+ const path9 = prefix && fullPath.startsWith(prefix) ? fullPath.slice(prefix.length) : fullPath;
2616
+ const values = attributes[entry.node.id];
2617
+ const rawArea = valueOf(values, areaMetric);
2618
+ const index = files.length;
2619
+ fileIndexById.set(entry.node.id, index);
2620
+ files.push({
2621
+ index,
2622
+ nodeId: entry.node.id,
2623
+ path: path9,
2624
+ name: entry.node.name,
2625
+ section: -1,
2626
+ area: Math.max(1, rawArea ?? 0),
2627
+ actualArea: rawArea ?? 0,
2628
+ risk: 0,
2629
+ complexity: valueOf(values, complexityKey) ?? 0,
2630
+ churn: valueOf(values, churnKey) ?? 0,
2631
+ values: []
2632
+ });
2633
+ });
2634
+ if (areaMetric && files.length > 0) {
2635
+ const missing = files.filter((file) => file.actualArea === 0).length;
2636
+ if (missing > 0) {
2637
+ warnings.push(
2638
+ `${missing} ficheros sin "${areaMetric}"; se dibujan con \xE1rea m\xEDnima para no desaparecer`
2499
2639
  );
2500
- const min = optionalNumber(rule.min, `config.rules[${index}].min`);
2501
- const max = optionalNumber(rule.max, `config.rules[${index}].max`);
2502
- if (min === void 0 && max === void 0) {
2503
- throw new ConfigError(
2504
- `config.rules[${index}] necesita min o max`
2505
- );
2506
- }
2507
- if (min !== void 0 && max !== void 0 && min > max) {
2508
- throw new ConfigError(
2509
- `config.rules[${index}]: min no puede ser mayor que max`
2510
- );
2511
- }
2512
- rules.push({ type, name, metric, min, max, severity, message });
2513
- return;
2514
2640
  }
2515
- if (type === "cycles") {
2516
- rules.push({ type, name, severity, message });
2517
- return;
2641
+ if (!options.areaMetric && areaMetric !== "rloc") {
2642
+ warnings.push(`"rloc" no est\xE1 presente; se usa "${areaMetric}" como \xE1rea`);
2518
2643
  }
2519
- throw new ConfigError(
2520
- `config.rules[${index}].type desconocido: ${type}`
2521
- );
2522
- });
2523
- return rules;
2524
- }
2525
- function parseSeverity(value, path8) {
2526
- if (value === void 0) return "error";
2527
- if (typeof value !== "string" || !SEVERITIES.has(value)) {
2528
- throw new ConfigError(`${path8} debe ser info, warn o error`);
2529
- }
2530
- return value;
2531
- }
2532
- function optionalNumber(value, path8) {
2533
- if (value === void 0) return void 0;
2534
- if (typeof value !== "number" || !Number.isFinite(value)) {
2535
- throw new ConfigError(`${path8} debe ser un n\xFAmero`);
2536
- }
2537
- return value;
2538
- }
2539
- function asObject(value, path8) {
2540
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
2541
- throw new ConfigError(`${path8} debe ser un objeto`);
2542
2644
  }
2543
- return value;
2544
- }
2545
- function asString(value, path8) {
2546
- if (typeof value !== "string" || value.trim() === "") {
2547
- throw new ConfigError(`${path8} debe ser un texto no vac\xEDo`);
2548
- }
2549
- return value.trim();
2550
- }
2551
-
2552
- // packages/metrics/src/rules.ts
2553
- import { minimatch } from "minimatch";
2554
- function emptyRuleReport() {
2555
- return {
2556
- configured: false,
2557
- summary: {
2558
- layers: 0,
2559
- rules: 0,
2560
- introduced: 0,
2561
- existing: 0,
2562
- resolved: 0,
2563
- introducedErrors: 0,
2564
- introducedWarnings: 0
2565
- },
2566
- violations: { introduced: [], existing: [], resolved: [] }
2567
- };
2568
- }
2569
- function evaluateRules(base, head, config) {
2570
- const baseViolations = evaluateInput(base, config);
2571
- const headViolations = evaluateInput(head, config);
2572
- const baseByKey = new Map(
2573
- baseViolations.map((violation) => [violation.key, violation])
2574
- );
2575
- const headByKey = new Map(
2576
- headViolations.map((violation) => [violation.key, violation])
2645
+ const risks = riskScores(
2646
+ files.map((file) => ({
2647
+ complexity: file.complexity,
2648
+ churn: file.churn,
2649
+ size: file.actualArea
2650
+ }))
2577
2651
  );
2578
- const introduced = [];
2579
- const existing = [];
2580
- const resolved = [];
2581
- for (const [key, violation] of headByKey) {
2582
- if (baseByKey.has(key)) existing.push(violation);
2583
- else introduced.push(violation);
2652
+ for (const [index, file] of files.entries()) {
2653
+ file.risk = risks[index] ?? 0;
2584
2654
  }
2585
- for (const [key, violation] of baseByKey) {
2586
- if (!headByKey.has(key)) resolved.push(violation);
2655
+ const sectionAccumulator = /* @__PURE__ */ new Map();
2656
+ for (const file of files) {
2657
+ const key = sectionForPath(file.path);
2658
+ const current = sectionAccumulator.get(key) ?? { files: 0, area: 0 };
2659
+ current.files++;
2660
+ current.area += file.actualArea;
2661
+ sectionAccumulator.set(key, current);
2587
2662
  }
2588
- introduced.sort(compareViolations);
2589
- existing.sort(compareViolations);
2590
- resolved.sort(compareViolations);
2591
- return {
2592
- configured: true,
2593
- summary: {
2594
- layers: config.layers.length,
2595
- rules: config.rules.length,
2596
- introduced: introduced.length,
2597
- existing: existing.length,
2598
- resolved: resolved.length,
2599
- introducedErrors: introduced.filter(
2600
- (violation) => violation.severity === "error"
2601
- ).length,
2602
- introducedWarnings: introduced.filter(
2603
- (violation) => violation.severity === "warn"
2604
- ).length
2605
- },
2606
- violations: { introduced, existing, resolved }
2607
- };
2608
- }
2609
- function evaluateInput(input, config) {
2610
- const layers = layerIndex(input.files, config.layers);
2611
- const violations = [];
2612
- for (const rule of config.rules) {
2613
- if (rule.type === "forbidden") {
2614
- evaluateForbidden(rule, input, layers, violations);
2615
- } else if (rule.type === "threshold") {
2616
- evaluateThreshold(rule, input, violations);
2617
- } else {
2618
- evaluateCycles(rule, input.cycles, violations);
2619
- }
2663
+ const sections = [...sectionAccumulator.entries()].map(([key, value]) => ({
2664
+ key,
2665
+ label: key,
2666
+ files: value.files,
2667
+ area: value.area,
2668
+ color: PALETTE[hashString(key) % PALETTE.length]
2669
+ })).sort((a, b) => b.area - a.area || a.key.localeCompare(b.key));
2670
+ const sectionIndex = new Map(sections.map((section, index) => [section.key, index]));
2671
+ for (const file of files) {
2672
+ file.section = sectionIndex.get(sectionForPath(file.path)) ?? 0;
2620
2673
  }
2621
- const byKey = /* @__PURE__ */ new Map();
2622
- for (const violation of violations) byKey.set(violation.key, violation);
2623
- return [...byKey.values()].sort(compareViolations);
2624
- }
2625
- function layerIndex(files, layers) {
2626
- const index = /* @__PURE__ */ new Map();
2674
+ const metrics = metricKeys.map((key) => {
2675
+ const present = files.map((file) => valueOf(attributes[file.nodeId], key)).filter((value) => value !== void 0);
2676
+ const min = present.length > 0 ? Math.min(...present) : 0;
2677
+ const actualMax = present.length > 0 ? Math.max(...present) : 0;
2678
+ const p95 = percentile2(present, 0.95);
2679
+ const skewed = min >= 0 && p95 > 0 && actualMax > p95 * 4;
2680
+ const max = skewed ? actualMax : p95 > 0 ? p95 : actualMax;
2681
+ return {
2682
+ key,
2683
+ title: metricTitle(descriptors, key),
2684
+ direction: metricDirection(descriptors, key),
2685
+ min,
2686
+ max,
2687
+ actualMax,
2688
+ p95,
2689
+ mode: skewed ? "log" : "linear",
2690
+ coverage: present.length
2691
+ };
2692
+ });
2627
2693
  for (const file of files) {
2628
- for (const layer of layers) {
2629
- if (layer.patterns.some(
2630
- (pattern) => minimatch(file.path, pattern, { dot: true })
2631
- )) {
2632
- index.set(file.id, layer.name);
2633
- break;
2634
- }
2635
- }
2694
+ const values = attributes[file.nodeId];
2695
+ file.values = metrics.map((metric) => valueOf(values, metric.key) ?? null);
2636
2696
  }
2637
- return index;
2638
- }
2639
- function evaluateForbidden(rule, input, layers, violations) {
2640
- for (const edge of input.edges) {
2641
- if (layers.get(edge.fromId) !== rule.from) continue;
2642
- if (layers.get(edge.toId) !== rule.to) continue;
2643
- violations.push({
2644
- key: `forbidden:${rule.name}:${edge.fromId}->${edge.toId}`,
2645
- rule: rule.name,
2646
- type: "forbidden",
2647
- severity: rule.severity,
2648
- message: rule.message ?? `${edge.from} no puede depender de ${edge.to}`,
2649
- files: [edge.from, edge.to],
2650
- edge: {
2651
- fromId: edge.fromId,
2652
- toId: edge.toId,
2653
- from: edge.from,
2654
- to: edge.to
2697
+ const roots = buildRoots(cc, singleRoot, fileIndexById, files);
2698
+ const folderCount = countFolders(roots);
2699
+ const dependency = cc.lenses?.dependency;
2700
+ const leafEdges = asEdges(dependency?.leafEdges);
2701
+ const rawEdges = leafEdges.length > 0 ? leafEdges : asEdges(dependency?.edges);
2702
+ const edgeSet = /* @__PURE__ */ new Set();
2703
+ const edges = [];
2704
+ const n = files.length;
2705
+ for (const edge of rawEdges) {
2706
+ const from = fileIndexById.get(edge.fromId);
2707
+ const to = fileIndexById.get(edge.toId);
2708
+ if (from === void 0 || to === void 0 || from === to) continue;
2709
+ const key = from * n + to;
2710
+ if (edgeSet.has(key)) continue;
2711
+ edgeSet.add(key);
2712
+ edges.push({ from, to });
2713
+ if (edges.length >= 2e4) {
2714
+ warnings.push("se han limitado las dependencias a 20.000 aristas");
2715
+ break;
2716
+ }
2717
+ }
2718
+ edges.sort((a, b) => a.from - b.from || a.to - b.to);
2719
+ const flaggedEdges = /* @__PURE__ */ new Set();
2720
+ for (const edge of rawEdges) {
2721
+ if (edge.isCyclic !== true) continue;
2722
+ const from = fileIndexById.get(edge.fromId);
2723
+ const to = fileIndexById.get(edge.toId);
2724
+ if (from === void 0 || to === void 0) continue;
2725
+ flaggedEdges.add(from * n + to);
2726
+ }
2727
+ const cycleSet = /* @__PURE__ */ new Set();
2728
+ const cycles = [];
2729
+ for (const edge of edges) {
2730
+ const key = edge.from * n + edge.to;
2731
+ const backward = edgeSet.has(edge.to * n + edge.from);
2732
+ if (backward || flaggedEdges.has(key)) {
2733
+ if (!cycleSet.has(key)) {
2734
+ cycleSet.add(key);
2735
+ cycles.push(edge);
2655
2736
  }
2656
- });
2737
+ }
2738
+ }
2739
+ cycles.sort((a, b) => a.from - b.from || a.to - b.to);
2740
+ if (rawEdges.length === 0) {
2741
+ warnings.push("este cc.json no trae una lente de dependencias utilizable");
2742
+ }
2743
+ const totalArea = files.reduce((sum, file) => sum + file.actualArea, 0);
2744
+ const hotspotCount = files.filter((file) => file.risk >= 0.66).length;
2745
+ let initialLens = "risk";
2746
+ let initialMetric = null;
2747
+ if (options.color === "structure") {
2748
+ initialLens = "structure";
2749
+ } else if (options.color && options.color !== "risk") {
2750
+ if (!coverage.has(options.color)) {
2751
+ throw new RenderError(`la m\xE9trica de color "${options.color}" no existe en este cc.json`);
2752
+ }
2753
+ initialLens = "metric";
2754
+ initialMetric = options.color;
2657
2755
  }
2756
+ const defaultSelection = pickDefaultSelection(files, initialLens, initialMetric, metrics);
2757
+ const project = cc.meta.projectName || options.title || "Proyecto sin nombre";
2758
+ return {
2759
+ meta: {
2760
+ project,
2761
+ title: options.title || project,
2762
+ commit: cc.meta.commitHash || "",
2763
+ checksum: cc.meta.checksum || "",
2764
+ apiVersion: cc.meta.apiVersion,
2765
+ rootName
2766
+ },
2767
+ files,
2768
+ sections,
2769
+ metrics,
2770
+ edges,
2771
+ cycles,
2772
+ roots,
2773
+ areaMetric,
2774
+ areaTitle: areaMetric ? metricTitle(descriptors, areaMetric) : "\xC1rea",
2775
+ totalArea,
2776
+ fileCount: files.length,
2777
+ folderCount,
2778
+ hotspotCount,
2779
+ defaultSelection,
2780
+ initialLens,
2781
+ initialMetric,
2782
+ warnings
2783
+ };
2658
2784
  }
2659
- function evaluateThreshold(rule, input, violations) {
2660
- for (const file of input.files) {
2661
- const value = file.metrics[rule.metric];
2662
- if (typeof value !== "number" || !Number.isFinite(value)) continue;
2663
- const tooHigh = rule.max !== void 0 && value > rule.max;
2664
- const tooLow = rule.min !== void 0 && value < rule.min;
2665
- if (!tooHigh && !tooLow) continue;
2666
- const limit = tooHigh && rule.max !== void 0 ? `m\xE1ximo ${rule.max}` : `m\xEDnimo ${rule.min}`;
2667
- violations.push({
2668
- key: `threshold:${rule.name}:${file.id}`,
2669
- rule: rule.name,
2670
- type: "threshold",
2671
- severity: rule.severity,
2672
- message: rule.message ?? `${file.path}: ${rule.metric} = ${value} (${limit})`,
2673
- files: [file.path],
2674
- metric: {
2675
- name: rule.metric,
2676
- value,
2677
- ...rule.min !== void 0 ? { min: rule.min } : {},
2678
- ...rule.max !== void 0 ? { max: rule.max } : {}
2679
- }
2680
- });
2785
+ function buildRoots(cc, singleRoot, fileIndexById, files) {
2786
+ const build = (node, path9) => {
2787
+ if (node.type === "File") {
2788
+ const index = fileIndexById.get(node.id);
2789
+ const file = index === void 0 ? void 0 : files[index];
2790
+ return {
2791
+ kind: "file",
2792
+ key: `f:${index ?? -1}`,
2793
+ name: node.name,
2794
+ path: path9,
2795
+ value: file?.area ?? 1,
2796
+ fileCount: 1,
2797
+ section: file?.section ?? 0,
2798
+ fileIndex: index
2799
+ };
2800
+ }
2801
+ const children = (node.children ?? []).map(
2802
+ (child) => build(child, path9 ? `${path9}/${child.name}` : child.name)
2803
+ );
2804
+ const folder = {
2805
+ kind: "folder",
2806
+ key: `d:${path9}`,
2807
+ name: node.name,
2808
+ path: path9,
2809
+ value: children.reduce((sum, child) => sum + child.value, 0) || 1,
2810
+ fileCount: children.reduce((sum, child) => sum + child.fileCount, 0),
2811
+ section: -1,
2812
+ children
2813
+ };
2814
+ while (folder.children?.length === 1 && folder.children[0]?.kind === "folder") {
2815
+ const only = folder.children[0];
2816
+ folder.name = `${folder.name}/${only.name}`;
2817
+ folder.path = only.path;
2818
+ folder.key = `d:${only.path}`;
2819
+ folder.value = only.value;
2820
+ folder.fileCount = only.fileCount;
2821
+ folder.children = only.children;
2822
+ }
2823
+ return folder;
2824
+ };
2825
+ if (singleRoot) {
2826
+ return (singleRoot.children ?? []).map((child) => build(child, child.name));
2827
+ }
2828
+ return cc.files.map((root) => build(root, root.name));
2829
+ }
2830
+ function countFolders(nodes) {
2831
+ let count = 0;
2832
+ const stack = [...nodes];
2833
+ while (stack.length > 0) {
2834
+ const node = stack.pop();
2835
+ if (node.kind === "folder") count++;
2836
+ if (node.children) stack.push(...node.children);
2681
2837
  }
2838
+ return count;
2682
2839
  }
2683
- function evaluateCycles(rule, cycles, violations) {
2684
- for (const cycle of cycles) {
2685
- const paths = cycle.nodes.map((node) => node.path);
2686
- violations.push({
2687
- key: `cycles:${rule.name}:${cycle.nodes.map((node) => node.id).sort().join("|")}`,
2688
- rule: rule.name,
2689
- type: "cycles",
2690
- severity: rule.severity,
2691
- message: rule.message ?? cycleMessage(paths),
2692
- files: paths,
2693
- cycle: {
2694
- size: cycle.size,
2695
- nodes: cycle.nodes.map((node) => ({ ...node }))
2840
+ function pickDefaultSelection(files, lens, metric, metrics) {
2841
+ if (files.length === 0) return null;
2842
+ if (lens === "metric" && metric) {
2843
+ const index = metrics.findIndex((entry) => entry.key === metric);
2844
+ if (index >= 0) {
2845
+ let best = null;
2846
+ for (const file of files) {
2847
+ const value = file.values[index];
2848
+ if (value === null || value === void 0) continue;
2849
+ if (!best || value > (best.values[index] ?? -Infinity)) best = file;
2696
2850
  }
2697
- });
2851
+ if (best) return best.index;
2852
+ }
2853
+ }
2854
+ if (lens === "structure") {
2855
+ return files.reduce(
2856
+ (best, file) => file.actualArea > best.actualArea ? file : best
2857
+ ).index;
2698
2858
  }
2859
+ return files.reduce((best, file) => file.risk > best.risk ? file : best).index;
2699
2860
  }
2700
- function cycleMessage(paths) {
2701
- const visible = paths.slice(0, 6);
2702
- const suffix = paths.length > visible.length ? " \u2192 \u2026" : "";
2703
- return `Ciclo de ${paths.length} ficheros: ${visible.join(" \u2192 ")}${suffix}`;
2861
+
2862
+ // packages/render/src/html.ts
2863
+ import { readFileSync } from "node:fs";
2864
+
2865
+ // packages/render/src/color.ts
2866
+ var RISK_STOPS = [
2867
+ [0, "#ebe7df"],
2868
+ [0.35, "#e9cd98"],
2869
+ [0.65, "#d68a54"],
2870
+ [1, "#a42e2c"]
2871
+ ];
2872
+ var METRIC_STOPS = [
2873
+ [0, "#ebe7df"],
2874
+ [0.5, "#b9c9cf"],
2875
+ [1, "#355b6b"]
2876
+ ];
2877
+ var RISK_GRADIENT = "linear-gradient(90deg, #ebe7df 0%, #e9cd98 35%, #d68a54 65%, #a42e2c 100%)";
2878
+ var METRIC_GRADIENT = "linear-gradient(90deg, #ebe7df 0%, #b9c9cf 50%, #355b6b 100%)";
2879
+ function clamp012(value) {
2880
+ if (!Number.isFinite(value)) return 0;
2881
+ return Math.max(0, Math.min(1, value));
2704
2882
  }
2705
- function compareViolations(left, right) {
2706
- return compareStrings4(left.key, right.key);
2883
+ function hexToRgb(hex) {
2884
+ const value = hex.replace("#", "");
2885
+ return [
2886
+ Number.parseInt(value.slice(0, 2), 16),
2887
+ Number.parseInt(value.slice(2, 4), 16),
2888
+ Number.parseInt(value.slice(4, 6), 16)
2889
+ ];
2707
2890
  }
2708
- function compareStrings4(left, right) {
2709
- return left < right ? -1 : left > right ? 1 : 0;
2891
+ function rgbToHex(rgb) {
2892
+ return `#${rgb.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
2710
2893
  }
2711
-
2712
- // packages/metrics/src/diff.ts
2713
- function diffSnapshots(base, head, options = {}) {
2714
- const top = Math.max(1, Math.floor(options.top ?? 10));
2715
- const baseIndex = indexSnapshot(base);
2716
- const headIndex = indexSnapshot(head);
2717
- const added = difference(headIndex, baseIndex);
2718
- const removed = difference(baseIndex, headIndex);
2719
- const changed = changedFiles(baseIndex, headIndex);
2720
- const edgeDiff = diffEdges(baseIndex, headIndex);
2721
- const baseCycles = cycleGroups(baseIndex);
2722
- const headCycles = cycleGroups(headIndex);
2723
- const introducedCycles = cycleDifference(headCycles, baseCycles);
2724
- const fixedCycles = cycleDifference(baseCycles, headCycles);
2725
- const hotspotMetric = chooseHotspotMetric(
2726
- baseIndex,
2727
- headIndex,
2728
- options.hotspotMetric
2729
- );
2730
- const hotspots = hotspotMetric ? buildHotspots(baseIndex, headIndex, added, changed, hotspotMetric, top) : [];
2731
- const rules = options.rules ? evaluateRules(
2732
- ruleInput(baseIndex, [...baseCycles.values()].map((group) => group.component)),
2733
- ruleInput(headIndex, [...headCycles.values()].map((group) => group.component)),
2734
- options.rules
2735
- ) : emptyRuleReport();
2736
- return {
2737
- schemaVersion: "1.0",
2738
- base: summarize(baseIndex),
2739
- head: summarize(headIndex),
2740
- summary: {
2741
- addedFiles: added.length,
2742
- removedFiles: removed.length,
2743
- changedFiles: changed.length,
2744
- addedEdges: edgeDiff.added.length,
2745
- removedEdges: edgeDiff.removed.length,
2746
- cyclicEdgesAdded: edgeDiff.cyclicAdded.length,
2747
- cyclicEdgesRemoved: edgeDiff.cyclicRemoved.length,
2748
- introducedCycles: introducedCycles.length,
2749
- fixedCycles: fixedCycles.length,
2750
- hotspots: hotspots.length
2751
- },
2752
- hotspotMetric,
2753
- rules,
2754
- files: { added, removed, changed },
2755
- edges: edgeDiff,
2756
- cycles: { introduced: introducedCycles, fixed: fixedCycles },
2757
- hotspots
2758
- };
2894
+ function mix(a, b, t) {
2895
+ const from = hexToRgb(a);
2896
+ const to = hexToRgb(b);
2897
+ return rgbToHex([
2898
+ Math.round(from[0] + (to[0] - from[0]) * t),
2899
+ Math.round(from[1] + (to[1] - from[1]) * t),
2900
+ Math.round(from[2] + (to[2] - from[2]) * t)
2901
+ ]);
2759
2902
  }
2760
- function indexSnapshot(cc) {
2761
- const paths = /* @__PURE__ */ new Map();
2762
- for (const root of cc.files) {
2763
- walkFiles([root], (entry) => {
2764
- paths.set(entry.node.id, relativePath(root, entry.path, entry.node));
2765
- });
2766
- }
2767
- const nodes = [...paths.keys()].sort(
2768
- (left, right) => compareStrings5(paths.get(left) ?? left, paths.get(right) ?? right)
2769
- );
2770
- const metrics = cc.lenses?.metrics?.attributes ?? {};
2771
- const edges = /* @__PURE__ */ new Map();
2772
- for (const edge of cc.lenses?.dependency?.edges ?? []) {
2773
- const key = edgeKey(edge.fromId, edge.toId);
2774
- const weight = finiteNumber(edge.attributes?.weight) ?? 1;
2775
- const typeOnly = finiteNumber(edge.attributes?.typeOnly) ?? 0;
2776
- const existing = edges.get(key);
2777
- if (existing) {
2778
- existing.weight += weight;
2779
- existing.typeOnly += typeOnly;
2780
- existing.isCyclic = existing.isCyclic || edge.isCyclic === true;
2781
- } else {
2782
- edges.set(key, {
2783
- fromId: edge.fromId,
2784
- toId: edge.toId,
2785
- weight,
2786
- typeOnly,
2787
- isCyclic: edge.isCyclic === true
2788
- });
2903
+ function ramp(stops, t) {
2904
+ const value = clamp012(t);
2905
+ for (let index = 0; index < stops.length - 1; index++) {
2906
+ const from = stops[index];
2907
+ const to = stops[index + 1];
2908
+ if (value <= to[0]) {
2909
+ const span2 = to[0] - from[0] || 1;
2910
+ return mix(from[1], to[1], (value - from[0]) / span2);
2789
2911
  }
2790
2912
  }
2791
- return { cc, paths, nodes, metrics, edges };
2792
- }
2793
- function relativePath(root, fullPath, leaf) {
2794
- if (fullPath === root.name) return leaf.name;
2795
- const prefix = `${root.name}/`;
2796
- return fullPath.startsWith(prefix) ? fullPath.slice(prefix.length) : fullPath;
2913
+ return stops[stops.length - 1][1];
2797
2914
  }
2798
- function summarize(index) {
2799
- let folders = 0;
2800
- walkTree(index.cc.files, (entry) => {
2801
- if (entry.node.type === "Folder" && entry.depth > 0) folders++;
2802
- });
2803
- return {
2804
- projectName: index.cc.meta.projectName ?? "",
2805
- checksum: index.cc.meta.checksum ?? "",
2806
- files: index.paths.size,
2807
- folders,
2808
- edges: index.edges.size
2809
- };
2915
+ function riskColor(risk) {
2916
+ return ramp(RISK_STOPS, risk);
2810
2917
  }
2811
- function difference(present, absent) {
2812
- const files = [];
2813
- for (const [id, path8] of present.paths) {
2814
- if (absent.paths.has(id)) continue;
2815
- files.push({
2816
- id,
2817
- path: path8,
2818
- metrics: sortedMetrics(present.metrics[id])
2819
- });
2918
+ function metricColor(value, metric) {
2919
+ if (value === null) return METRIC_STOPS[0][1];
2920
+ let t;
2921
+ if (metric.mode === "log") {
2922
+ const span2 = Math.max(1e-9, metric.max - metric.min);
2923
+ t = Math.log1p(Math.max(0, value - metric.min)) / Math.log1p(span2);
2924
+ } else {
2925
+ const span2 = metric.max - metric.min;
2926
+ t = span2 > 0 ? (value - metric.min) / span2 : 0;
2820
2927
  }
2821
- return files.sort(compareFiles);
2928
+ return ramp(METRIC_STOPS, t);
2822
2929
  }
2823
- function changedFiles(base, head) {
2824
- const changed = [];
2825
- for (const [id, path8] of head.paths) {
2826
- if (!base.paths.has(id)) continue;
2827
- const baseValues = base.metrics[id] ?? {};
2828
- const headValues = head.metrics[id] ?? {};
2829
- const keys = /* @__PURE__ */ new Set([...Object.keys(baseValues), ...Object.keys(headValues)]);
2830
- const changes = [];
2831
- for (const metric of [...keys].sort(compareStrings5)) {
2832
- const baseValue = finiteNumber(baseValues[metric]);
2833
- const headValue = finiteNumber(headValues[metric]);
2834
- if (baseValue === null && headValue === null) continue;
2835
- if (baseValue !== null && headValue !== null && baseValue === headValue) {
2836
- continue;
2837
- }
2838
- changes.push({
2839
- metric,
2840
- base: baseValue,
2841
- head: headValue,
2842
- delta: baseValue !== null && headValue !== null ? headValue - baseValue : null
2843
- });
2930
+ function inkFor(color) {
2931
+ const [r, g, b] = hexToRgb(color);
2932
+ const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
2933
+ return luminance > 0.52 ? "#1a1a1a" : "#f7f5f1";
2934
+ }
2935
+ function colorForFile(lens, risk, section, value, metric) {
2936
+ if (lens === "structure") return section?.color ?? "#ddd8cf";
2937
+ if (lens === "risk" || !metric) return riskColor(risk);
2938
+ return metricColor(value, metric);
2939
+ }
2940
+
2941
+ // packages/layouts/src/squarify.ts
2942
+ function squarify(items, rect) {
2943
+ if (rect.w <= 0 || rect.h <= 0) return [];
2944
+ const usable = items.filter(
2945
+ (item) => Number.isFinite(item.value) && item.value > 0
2946
+ );
2947
+ const total = usable.reduce((sum, item) => sum + item.value, 0);
2948
+ if (usable.length === 0 || total <= 0) return [];
2949
+ const sorted = usable.map((item, index2) => ({ item, index: index2 })).sort((a, b) => b.item.value - a.item.value || a.index - b.index).map(({ item }) => item);
2950
+ const scale = rect.w * rect.h / total;
2951
+ const placed = [];
2952
+ let x = rect.x;
2953
+ let y = rect.y;
2954
+ let w = rect.w;
2955
+ let h = rect.h;
2956
+ let row = [];
2957
+ const area = (item) => item.value * scale;
2958
+ function worst(candidate, side) {
2959
+ let sum = 0;
2960
+ let max = -Infinity;
2961
+ let min = Infinity;
2962
+ for (const item of candidate) {
2963
+ const value = area(item);
2964
+ sum += value;
2965
+ if (value > max) max = value;
2966
+ if (value < min) min = value;
2844
2967
  }
2845
- if (changes.length > 0) changed.push({ id, path: path8, changes });
2968
+ if (sum <= 0 || side <= 0) return Infinity;
2969
+ return Math.max(
2970
+ side * side * max / (sum * sum),
2971
+ sum * sum / (side * side * min)
2972
+ );
2846
2973
  }
2847
- return changed.sort(compareChangedFiles);
2848
- }
2849
- function diffEdges(base, head) {
2850
- const added = [];
2851
- const removed = [];
2852
- for (const [key, edge] of head.edges) {
2853
- if (!base.edges.has(key)) added.push(edgeChange(head, edge));
2974
+ function place(current, horizontal) {
2975
+ if (current.length === 0) return;
2976
+ const sum = current.reduce((acc, item) => acc + area(item), 0);
2977
+ if (sum <= 0) return;
2978
+ if (horizontal) {
2979
+ if (w <= 0) return;
2980
+ const thickness = sum / w;
2981
+ if (!Number.isFinite(thickness) || thickness <= 0) return;
2982
+ let position = x;
2983
+ for (const item of current) {
2984
+ const length = area(item) / thickness;
2985
+ placed.push({ item, x: position, y, w: length, h: thickness });
2986
+ position += length;
2987
+ }
2988
+ y += thickness;
2989
+ h -= thickness;
2990
+ } else {
2991
+ if (h <= 0) return;
2992
+ const thickness = sum / h;
2993
+ if (!Number.isFinite(thickness) || thickness <= 0) return;
2994
+ let position = y;
2995
+ for (const item of current) {
2996
+ const length = area(item) / thickness;
2997
+ placed.push({ item, x, y: position, w: thickness, h: length });
2998
+ position += length;
2999
+ }
3000
+ x += thickness;
3001
+ w -= thickness;
3002
+ }
2854
3003
  }
2855
- for (const [key, edge] of base.edges) {
2856
- if (!head.edges.has(key)) removed.push(edgeChange(base, edge));
3004
+ let index = 0;
3005
+ while (index < sorted.length) {
3006
+ const horizontal = w >= h;
3007
+ const side = horizontal ? w : h;
3008
+ const next = sorted[index];
3009
+ if (row.length === 0) {
3010
+ row.push(next);
3011
+ index++;
3012
+ continue;
3013
+ }
3014
+ if (worst([...row, next], side) <= worst(row, side)) {
3015
+ row.push(next);
3016
+ index++;
3017
+ } else {
3018
+ place(row, horizontal);
3019
+ row = [];
3020
+ }
2857
3021
  }
2858
- added.sort(compareEdgeChanges);
2859
- removed.sort(compareEdgeChanges);
2860
- return {
2861
- added,
2862
- removed,
2863
- cyclicAdded: added.filter((edge) => edge.isCyclic),
2864
- cyclicRemoved: removed.filter((edge) => edge.isCyclic)
3022
+ if (row.length > 0) place(row, w >= h);
3023
+ return placed;
3024
+ }
3025
+
3026
+ // packages/render/src/layout.ts
3027
+ var VIEW_WIDTH = 1400;
3028
+ var VIEW_HEIGHT = 900;
3029
+ var VIEW_PADDING = 8;
3030
+ var MAX_DEPTH3 = 12;
3031
+ function layoutMap(roots, width = VIEW_WIDTH, height = VIEW_HEIGHT) {
3032
+ const rect = {
3033
+ x: VIEW_PADDING,
3034
+ y: VIEW_PADDING,
3035
+ w: Math.max(1, width - VIEW_PADDING * 2),
3036
+ h: Math.max(1, height - VIEW_PADDING * 2)
2865
3037
  };
3038
+ return layoutChildren(roots, rect, 0);
2866
3039
  }
2867
- function edgeChange(index, edge) {
2868
- return {
2869
- fromId: edge.fromId,
2870
- toId: edge.toId,
2871
- from: index.paths.get(edge.fromId) ?? edge.fromId,
2872
- to: index.paths.get(edge.toId) ?? edge.toId,
2873
- weight: edge.weight,
2874
- typeOnly: edge.typeOnly,
2875
- isCyclic: edge.isCyclic
2876
- };
3040
+ function layoutChildren(nodes, rect, depth) {
3041
+ if (nodes.length === 0 || rect.w < 2 || rect.h < 2) return [];
3042
+ const items = nodes.map((node) => ({
3043
+ key: node.key,
3044
+ value: Math.max(1, node.value),
3045
+ node
3046
+ }));
3047
+ return squarify(items, rect).map(
3048
+ (placed) => layoutNode(placed.item.node, {
3049
+ x: placed.x,
3050
+ y: placed.y,
3051
+ w: placed.w,
3052
+ h: placed.h
3053
+ }, depth)
3054
+ );
2877
3055
  }
2878
- function cycleGroups(index) {
2879
- const adjacency = /* @__PURE__ */ new Map();
2880
- const reverse = /* @__PURE__ */ new Map();
2881
- for (const node of index.nodes) {
2882
- adjacency.set(node, []);
2883
- reverse.set(node, []);
2884
- }
2885
- for (const edge of index.edges.values()) {
2886
- if (!index.paths.has(edge.fromId) || !index.paths.has(edge.toId)) continue;
2887
- adjacency.get(edge.fromId)?.push(edge.toId);
2888
- reverse.get(edge.toId)?.push(edge.fromId);
2889
- }
2890
- for (const [node, neighbors] of adjacency) {
2891
- adjacency.set(node, [...new Set(neighbors)].sort(compareStrings5));
2892
- }
2893
- for (const [node, neighbors] of reverse) {
2894
- reverse.set(node, [...new Set(neighbors)].sort(compareStrings5));
2895
- }
2896
- const visited = /* @__PURE__ */ new Set();
2897
- const order = [];
2898
- for (const start of index.nodes) {
2899
- if (visited.has(start)) continue;
2900
- visited.add(start);
2901
- const stack = [
2902
- { node: start, neighbors: adjacency.get(start) ?? [], next: 0 }
2903
- ];
2904
- while (stack.length > 0) {
2905
- const frame = stack[stack.length - 1];
2906
- if (frame.next < frame.neighbors.length) {
2907
- const next = frame.neighbors[frame.next];
2908
- frame.next++;
2909
- if (!visited.has(next)) {
2910
- visited.add(next);
2911
- stack.push({
2912
- node: next,
2913
- neighbors: adjacency.get(next) ?? [],
2914
- next: 0
2915
- });
2916
- }
2917
- } else {
2918
- order.push(frame.node);
2919
- stack.pop();
2920
- }
2921
- }
2922
- }
2923
- const assigned = /* @__PURE__ */ new Set();
2924
- const groups = /* @__PURE__ */ new Map();
2925
- for (let position = order.length - 1; position >= 0; position--) {
2926
- const start = order[position];
2927
- if (assigned.has(start)) continue;
2928
- assigned.add(start);
2929
- const component = [];
2930
- const stack = [start];
2931
- while (stack.length > 0) {
2932
- const node = stack.pop();
2933
- component.push(node);
2934
- for (const next of reverse.get(node) ?? []) {
2935
- if (assigned.has(next)) continue;
2936
- assigned.add(next);
2937
- stack.push(next);
2938
- }
2939
- }
2940
- const hasSelfLoop = component.some(
2941
- (node) => (adjacency.get(node) ?? []).includes(node)
2942
- );
2943
- if (component.length < 2 && !hasSelfLoop) continue;
2944
- const nodes = component.map((id) => ({
2945
- id,
2946
- path: index.paths.get(id) ?? id
2947
- })).sort((left, right) => {
2948
- return compareStrings5(left.path, right.path) || compareStrings5(left.id, right.id);
2949
- });
2950
- const key = [...component].sort(compareStrings5).join("\0");
2951
- groups.set(key, { key, component: { size: nodes.length, nodes } });
3056
+ function layoutNode(node, rect, depth) {
3057
+ if (node.kind === "file" || !node.children || node.children.length === 0) {
3058
+ return { node, rect, headerHeight: 0, children: [] };
2952
3059
  }
2953
- return groups;
2954
- }
2955
- function cycleDifference(present, absent) {
2956
- const cycles = [];
2957
- for (const [key, group] of present) {
2958
- if (!absent.has(key)) cycles.push(group.component);
3060
+ let headerHeight = 0;
3061
+ if (rect.w >= 30 && rect.h >= 32) {
3062
+ headerHeight = rect.h >= 62 ? 17 : 13;
2959
3063
  }
2960
- return cycles.sort(compareCycles);
3064
+ const inner = {
3065
+ x: rect.x + 1,
3066
+ y: rect.y + headerHeight + 1,
3067
+ w: Math.max(0, rect.w - 2),
3068
+ h: Math.max(0, rect.h - headerHeight - 2)
3069
+ };
3070
+ const children = depth < MAX_DEPTH3 && inner.w >= 1 && inner.h >= 1 ? layoutChildren(node.children, inner, depth + 1) : [];
3071
+ return { node, rect, headerHeight, children };
2961
3072
  }
2962
- function ruleInput(index, cycles) {
2963
- const files = index.nodes.map((id) => ({
2964
- id,
2965
- path: index.paths.get(id) ?? id,
2966
- metrics: sortedMetrics(index.metrics[id])
2967
- }));
2968
- const edges = [];
2969
- for (const edge of index.edges.values()) {
2970
- if (!index.paths.has(edge.fromId) || !index.paths.has(edge.toId)) {
2971
- continue;
2972
- }
2973
- edges.push({
2974
- fromId: edge.fromId,
2975
- toId: edge.toId,
2976
- from: index.paths.get(edge.fromId) ?? edge.fromId,
2977
- to: index.paths.get(edge.toId) ?? edge.toId
2978
- });
2979
- }
2980
- return { files, edges, cycles };
3073
+
3074
+ // packages/render/src/svg.ts
3075
+ function escapeHtml(value) {
3076
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
2981
3077
  }
2982
- function chooseHotspotMetric(base, head, requested) {
2983
- if (requested) return requested;
2984
- for (const metric of ["maxComplexity", "rloc"]) {
2985
- if (hasMetric(base, metric) || hasMetric(head, metric)) return metric;
2986
- }
2987
- return null;
3078
+ function escapeAttr(value) {
3079
+ return escapeHtml(value);
2988
3080
  }
2989
- function hasMetric(index, metric) {
2990
- for (const values of Object.values(index.metrics)) {
2991
- if (finiteNumber(values[metric]) !== null) return true;
2992
- }
2993
- return false;
3081
+ function formatInt(value) {
3082
+ if (!Number.isFinite(value)) return "\u2014";
3083
+ return Math.round(value).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ".");
2994
3084
  }
2995
- function buildHotspots(base, head, added, changed, metric, top) {
2996
- const addedIds = new Set(added.map((file) => file.id));
2997
- const candidates = [...added, ...changed];
2998
- const hotspots = [];
2999
- for (const file of candidates) {
3000
- const baseValue = finiteNumber(base.metrics[file.id]?.[metric]);
3001
- const headValue = finiteNumber(head.metrics[file.id]?.[metric]);
3002
- if (headValue === null) continue;
3003
- const before = baseValue ?? 0;
3004
- const delta = headValue - before;
3005
- if (delta <= 0) continue;
3006
- hotspots.push({
3007
- id: file.id,
3008
- path: file.path,
3009
- metric,
3010
- base: before,
3011
- head: headValue,
3012
- delta,
3013
- change: addedIds.has(file.id) ? "added" : "changed"
3014
- });
3085
+ function formatCompact(value) {
3086
+ if (!Number.isFinite(value)) return "\u2014";
3087
+ const absolute = Math.abs(value);
3088
+ if (absolute >= 1e6) {
3089
+ return `${(value / 1e6).toFixed(1).replace(".0", "").replace(".", ",")}M`;
3015
3090
  }
3016
- return hotspots.sort(
3017
- (left, right) => right.delta - left.delta || right.head - left.head || compareStrings5(left.path, right.path)
3018
- ).slice(0, top);
3019
- }
3020
- function sortedMetrics(values) {
3021
- const sorted = {};
3022
- if (!values) return sorted;
3023
- for (const key of Object.keys(values).sort(compareStrings5)) {
3024
- const value = finiteNumber(values[key]);
3025
- if (value !== null) sorted[key] = value;
3091
+ if (absolute >= 1e3) {
3092
+ return `${(value / 1e3).toFixed(1).replace(".0", "").replace(".", ",")}k`;
3026
3093
  }
3027
- return sorted;
3028
- }
3029
- function finiteNumber(value) {
3030
- return typeof value === "number" && Number.isFinite(value) ? value : null;
3031
- }
3032
- function edgeKey(fromId, toId) {
3033
- return `${fromId}\0${toId}`;
3034
- }
3035
- function compareFiles(left, right) {
3036
- return compareStrings5(left.path, right.path) || compareStrings5(left.id, right.id);
3094
+ return formatInt(value);
3037
3095
  }
3038
- function compareChangedFiles(left, right) {
3039
- return compareStrings5(left.path, right.path) || compareStrings5(left.id, right.id);
3096
+ function coord(value) {
3097
+ const rounded = Math.round(value);
3098
+ return Object.is(rounded, -0) ? "0" : String(rounded);
3040
3099
  }
3041
- function compareEdgeChanges(left, right) {
3042
- return compareStrings5(left.from, right.from) || compareStrings5(left.to, right.to) || compareStrings5(left.fromId, right.fromId) || compareStrings5(left.toId, right.toId);
3100
+ function span(value) {
3101
+ return String(Math.max(1, Math.round(value)));
3043
3102
  }
3044
- function compareCycles(left, right) {
3045
- return compareStrings5(
3046
- left.nodes[0]?.path ?? "",
3047
- right.nodes[0]?.path ?? ""
3048
- ) || left.size - right.size;
3103
+ function truncate(value, maxWidth, fontSize) {
3104
+ const maxChars = Math.floor(maxWidth / (fontSize * 0.56));
3105
+ if (maxChars <= 0) return "";
3106
+ if (value.length <= maxChars) return value;
3107
+ if (maxChars === 1) return "\u2026";
3108
+ return `${value.slice(0, maxChars - 1)}\u2026`;
3049
3109
  }
3050
- function compareStrings5(left, right) {
3051
- return left < right ? -1 : left > right ? 1 : 0;
3110
+ function labelFor(nodeName, rect, out, ink) {
3111
+ if (rect.w < 44 || rect.h < 13) return;
3112
+ const available = rect.w - 8;
3113
+ const dot = nodeName.lastIndexOf(".");
3114
+ const twoLines = rect.h >= 27 && rect.w >= 64 && dot > 0 && dot < nodeName.length - 1;
3115
+ if (twoLines) {
3116
+ const base = truncate(nodeName.slice(0, dot), available, 12.5);
3117
+ const extension = truncate(nodeName.slice(dot), available, 10);
3118
+ if (base) {
3119
+ out.push(
3120
+ `<text class="a" x="${coord(rect.x + 4)}" y="${coord(rect.y + 12)}" fill="${ink}">${escapeHtml(base)}</text>`
3121
+ );
3122
+ }
3123
+ if (extension) {
3124
+ out.push(
3125
+ `<text class="b" x="${coord(rect.x + 4)}" y="${coord(rect.y + 22.5)}" fill="${ink}">${escapeHtml(extension)}</text>`
3126
+ );
3127
+ }
3128
+ return;
3129
+ }
3130
+ const label = truncate(nodeName, available, 12.5);
3131
+ if (!label) return;
3132
+ out.push(
3133
+ `<text class="a" x="${coord(rect.x + 4)}" y="${coord(rect.y + 13)}" fill="${ink}">${escapeHtml(label)}</text>`
3134
+ );
3052
3135
  }
3053
-
3054
- // packages/metrics/src/risk.ts
3055
- var COMPLEXITY_METRIC_CANDIDATES = [
3056
- "maxComplexity",
3057
- "max_complexity_per_function",
3058
- "complexity",
3059
- "logic_complexity",
3060
- "sonar_cognitive_complexity"
3061
- ];
3062
- var CHURN_METRIC_CANDIDATES = [
3063
- "number_of_commits",
3064
- "number_of_authors",
3065
- "number_of_renames"
3066
- ];
3067
- var RISK_WEIGHTS = {
3068
- complexity: 0.5,
3069
- churn: 0.32,
3070
- size: 0.18
3071
- };
3072
- var RISK_GAMMA = 1.7;
3073
- function riskScores(files) {
3074
- const complexityP95 = percentile(
3075
- files.map((file) => file.complexity).filter((value) => value > 0),
3076
- 0.95
3077
- );
3078
- const churnP95 = percentile(
3079
- files.map((file) => file.churn).filter((value) => value > 0),
3080
- 0.95
3136
+ function renderPlaced(placed, out, model, metricIndex, metric) {
3137
+ const node = placed.node;
3138
+ const rect = placed.rect;
3139
+ if (node.kind === "file") {
3140
+ const index = node.fileIndex;
3141
+ if (index === void 0) return;
3142
+ const file = model.files[index];
3143
+ if (!file) return;
3144
+ const value = metricIndex >= 0 ? file.values[metricIndex] ?? null : null;
3145
+ const fill = colorForFile(
3146
+ model.initialLens,
3147
+ file.risk,
3148
+ model.sections[file.section],
3149
+ value,
3150
+ metric
3151
+ );
3152
+ const ink = inkFor(fill);
3153
+ out.push(
3154
+ `<rect data-i="${index}" x="${coord(rect.x)}" y="${coord(rect.y)}" width="${span(rect.w)}" height="${span(rect.h)}" fill="${fill}"/>`
3155
+ );
3156
+ labelFor(file.name, rect, out, ink);
3157
+ return;
3158
+ }
3159
+ const folder = node;
3160
+ out.push(
3161
+ `<g class="folder" data-nm="${escapeAttr(folder.name)}" data-n="${folder.fileCount}" data-a="${coord(folder.value)}">`
3081
3162
  );
3082
- const sizeP95 = percentile(
3083
- files.map((file) => file.size).filter((value) => value > 0),
3084
- 0.95
3163
+ out.push(
3164
+ `<rect class="fr" x="${coord(rect.x)}" y="${coord(rect.y)}" width="${span(rect.w)}" height="${span(rect.h)}"/>`
3085
3165
  );
3086
- const complexityWeight = complexityP95 > 0 ? RISK_WEIGHTS.complexity : 0;
3087
- const churnWeight = churnP95 > 0 ? RISK_WEIGHTS.churn : 0;
3088
- const sizeWeight = sizeP95 > 0 ? RISK_WEIGHTS.size : 0;
3089
- const totalWeight = complexityWeight + churnWeight + sizeWeight;
3090
- if (totalWeight === 0) return files.map(() => 0);
3091
- return files.map((file) => {
3092
- const raw = (complexityWeight * norm(file.complexity, complexityP95) + churnWeight * norm(file.churn, churnP95) + sizeWeight * norm(file.size, sizeP95)) / totalWeight;
3093
- return Math.pow(clamp01(raw), RISK_GAMMA);
3094
- });
3095
- }
3096
- function firstMetric(available, candidates) {
3097
- const set = available instanceof Set ? available : new Set(available);
3098
- for (const candidate of candidates) {
3099
- if (set.has(candidate)) return candidate;
3166
+ if (placed.headerHeight >= 13 && rect.w >= 30) {
3167
+ const headerHeight = placed.headerHeight;
3168
+ out.push(
3169
+ `<rect class="fh" x="${coord(rect.x + 1)}" y="${coord(rect.y + 1)}" width="${span(rect.w - 2)}" height="${span(headerHeight - 1)}"/>`
3170
+ );
3171
+ const count = formatCompact(folder.value);
3172
+ const showCount = rect.w >= 86;
3173
+ const countWidth = showCount ? count.length * 5.8 + 12 : 0;
3174
+ const name = truncate(folder.name, Math.max(8, rect.w - 12 - countWidth), 11.5);
3175
+ if (name) {
3176
+ out.push(
3177
+ `<text class="ft" x="${coord(rect.x + 6)}" y="${coord(rect.y + headerHeight / 2 + 4)}">${escapeHtml(name)}</text>`
3178
+ );
3179
+ }
3180
+ if (showCount) {
3181
+ out.push(
3182
+ `<text class="fn" x="${coord(rect.x + rect.w - 6)}" y="${coord(rect.y + headerHeight / 2 + 3.6)}">${escapeHtml(count)}</text>`
3183
+ );
3184
+ }
3100
3185
  }
3101
- return null;
3102
- }
3103
- function norm(value, p95) {
3104
- return p95 > 0 ? clamp01(value / p95) : 0;
3105
- }
3106
- function percentile(values, p) {
3107
- if (values.length === 0) return 0;
3108
- const sorted = [...values].sort((a, b) => a - b);
3109
- const index = Math.min(
3110
- sorted.length - 1,
3111
- Math.max(0, Math.floor((sorted.length - 1) * p))
3112
- );
3113
- return sorted[index] ?? 0;
3114
- }
3115
- function clamp01(value) {
3116
- if (!Number.isFinite(value)) return 0;
3117
- return Math.max(0, Math.min(1, value));
3118
- }
3119
-
3120
- // packages/render/src/model.ts
3121
- var RenderError = class extends Error {
3122
- constructor(message) {
3123
- super(message);
3124
- this.name = "RenderError";
3186
+ for (const child of placed.children) {
3187
+ renderPlaced(child, out, model, metricIndex, metric);
3125
3188
  }
3126
- };
3127
- var PALETTE = [
3128
- "#b9c4d0",
3129
- "#c9b8a8",
3130
- "#a9bfae",
3131
- "#c4b6c9",
3132
- "#c9c2a6",
3133
- "#d5d2ca",
3134
- "#c2c8d0",
3135
- "#d6c6bd",
3136
- "#b7c6c2",
3137
- "#cbbfb0",
3138
- "#bcc3b9",
3139
- "#cfc6c9"
3140
- ];
3141
- var CURATED_METRICS = [
3142
- "rloc",
3143
- "maxComplexity",
3144
- "max_complexity_per_function",
3145
- "complexity",
3146
- "functions",
3147
- "number_of_functions",
3148
- "maxFunctionLength",
3149
- "loc",
3150
- "imports",
3151
- "externalImports",
3152
- "builtinImports",
3153
- "typeOnlyImports",
3154
- "unresolvedImports",
3155
- "fanIn",
3156
- "fanOut",
3157
- "inCycle",
3158
- "cycleSize",
3159
- "dependencyLevel",
3160
- "number_of_commits",
3161
- "number_of_authors",
3162
- "incoming_dependencies",
3163
- "outgoing_dependencies"
3164
- ];
3165
- function isRecord3(value) {
3166
- return typeof value === "object" && value !== null && !Array.isArray(value);
3167
- }
3168
- function valueOf(values, key) {
3169
- if (!key || !values) return void 0;
3170
- const value = values[key];
3171
- return typeof value === "number" && Number.isFinite(value) ? value : void 0;
3189
+ out.push("</g>");
3172
3190
  }
3173
- function percentile2(values, p) {
3174
- if (values.length === 0) return 0;
3175
- const sorted = [...values].sort((a, b) => a - b);
3176
- const index = Math.min(
3177
- sorted.length - 1,
3178
- Math.max(0, Math.floor((sorted.length - 1) * p))
3191
+ function renderSvg(model) {
3192
+ const metricIndex = model.initialMetric ? model.metrics.findIndex((metric2) => metric2.key === model.initialMetric) : -1;
3193
+ const metric = metricIndex >= 0 ? model.metrics[metricIndex] : void 0;
3194
+ const out = [];
3195
+ out.push(
3196
+ `<svg id="map" viewBox="0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="Mapa de arquitectura de ${escapeAttr(model.meta.project)}">`
3179
3197
  );
3180
- return sorted[index] ?? 0;
3181
- }
3182
- function hashString(value) {
3183
- let hash = 2166136261;
3184
- for (let index = 0; index < value.length; index++) {
3185
- hash ^= value.charCodeAt(index);
3186
- hash = Math.imul(hash, 16777619);
3187
- }
3188
- return hash >>> 0;
3189
- }
3190
- function sectionForPath(path8) {
3191
- const folders = path8.split("/").filter(Boolean).slice(0, -1);
3192
- if (folders.length === 0) return "(ra\xEDz)";
3193
- if (folders.length === 1) return folders[0];
3194
- return folders.slice(0, 3).join("/");
3195
- }
3196
- function asEdges(value) {
3197
- if (!Array.isArray(value)) return [];
3198
- const edges = [];
3199
- for (const item of value) {
3200
- if (!isRecord3(item)) continue;
3201
- if (typeof item.fromId !== "string" || typeof item.toId !== "string") continue;
3202
- edges.push({
3203
- fromId: item.fromId,
3204
- toId: item.toId,
3205
- ...item.isCyclic === true ? { isCyclic: true } : {}
3206
- });
3198
+ out.push(
3199
+ `<rect class="canvas" x="0" y="0" width="${VIEW_WIDTH}" height="${VIEW_HEIGHT}"/>`
3200
+ );
3201
+ out.push(`<g id="cells">`);
3202
+ for (const placed of layoutMap(model.roots)) {
3203
+ renderPlaced(placed, out, model, metricIndex, metric);
3207
3204
  }
3208
- return edges;
3205
+ out.push(`</g><g id="edges"></g></svg>`);
3206
+ return out.join("");
3209
3207
  }
3210
- function metricTitle(attributes, key) {
3211
- const descriptor = attributes?.[key];
3212
- if (isRecord3(descriptor) && typeof descriptor.title === "string" && descriptor.title) {
3213
- return descriptor.title;
3214
- }
3215
- return key;
3208
+
3209
+ // packages/render/src/html.ts
3210
+ var STYLES = readFileSync(new URL("./styles.css", import.meta.url), "utf8");
3211
+ var CLIENT = readFileSync(new URL("./client.js", import.meta.url), "utf8").replace(
3212
+ /<\/script/gi,
3213
+ "<\\/script"
3214
+ );
3215
+ function round(value, digits = 2) {
3216
+ const factor = 10 ** digits;
3217
+ return Math.round(value * factor) / factor;
3216
3218
  }
3217
- function metricDirection(attributes, key) {
3218
- const descriptor = attributes?.[key];
3219
- if (isRecord3(descriptor) && typeof descriptor.direction === "number") {
3220
- return descriptor.direction;
3221
- }
3222
- return 0;
3219
+ function jsonForScript(value) {
3220
+ return JSON.stringify(value).replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
3223
3221
  }
3224
- function buildRenderModel(cc, options = {}) {
3225
- const stats = getStats(cc);
3226
- const coverage = new Set(stats.metricCoverage.map((entry) => entry.name));
3227
- const attributes = cc.lenses?.metrics?.attributes ?? {};
3228
- const descriptors = cc.lenses?.metrics?.attributeDescriptors;
3229
- let areaMetric;
3230
- if (options.areaMetric) {
3231
- if (!coverage.has(options.areaMetric)) {
3232
- throw new RenderError(
3233
- `la m\xE9trica de \xE1rea "${options.areaMetric}" no existe en este cc.json`
3234
- );
3235
- }
3236
- areaMetric = options.areaMetric;
3237
- } else {
3238
- areaMetric = defaultMetric(stats) ?? null;
3239
- }
3240
- const complexityKey = firstMetric(coverage, COMPLEXITY_METRIC_CANDIDATES);
3241
- const churnKey = firstMetric(coverage, CHURN_METRIC_CANDIDATES);
3242
- const metricKeys = [];
3243
- const addMetric = (key) => {
3244
- if (key && coverage.has(key) && !metricKeys.includes(key)) metricKeys.push(key);
3245
- };
3246
- for (const key of CURATED_METRICS) addMetric(key);
3247
- addMetric(areaMetric);
3248
- addMetric(complexityKey);
3249
- addMetric(churnKey);
3250
- if (options.color && options.color !== "risk" && options.color !== "structure") {
3251
- addMetric(options.color);
3252
- }
3253
- const singleRoot = cc.files.length === 1 && cc.files[0]?.type === "Folder" ? cc.files[0] : null;
3254
- const prefix = singleRoot ? `${singleRoot.name}/` : "";
3255
- const rootName = singleRoot?.name ?? "";
3256
- const warnings = [];
3257
- const files = [];
3258
- const fileIndexById = /* @__PURE__ */ new Map();
3259
- walkFiles(cc.files, (entry) => {
3260
- const fullPath = entry.path;
3261
- const path8 = prefix && fullPath.startsWith(prefix) ? fullPath.slice(prefix.length) : fullPath;
3262
- const values = attributes[entry.node.id];
3263
- const rawArea = valueOf(values, areaMetric);
3264
- const index = files.length;
3265
- fileIndexById.set(entry.node.id, index);
3266
- files.push({
3267
- index,
3268
- nodeId: entry.node.id,
3269
- path: path8,
3270
- name: entry.node.name,
3271
- section: -1,
3272
- area: Math.max(1, rawArea ?? 0),
3273
- actualArea: rawArea ?? 0,
3274
- risk: 0,
3275
- complexity: valueOf(values, complexityKey) ?? 0,
3276
- churn: valueOf(values, churnKey) ?? 0,
3277
- values: []
3278
- });
3279
- });
3280
- if (areaMetric && files.length > 0) {
3281
- const missing = files.filter((file) => file.actualArea === 0).length;
3282
- if (missing > 0) {
3283
- warnings.push(
3284
- `${missing} ficheros sin "${areaMetric}"; se dibujan con \xE1rea m\xEDnima para no desaparecer`
3285
- );
3286
- }
3287
- if (!options.areaMetric && areaMetric !== "rloc") {
3288
- warnings.push(`"rloc" no est\xE1 presente; se usa "${areaMetric}" como \xE1rea`);
3289
- }
3290
- }
3291
- const risks = riskScores(
3292
- files.map((file) => ({
3293
- complexity: file.complexity,
3294
- churn: file.churn,
3295
- size: file.actualArea
3296
- }))
3297
- );
3298
- for (const [index, file] of files.entries()) {
3299
- file.risk = risks[index] ?? 0;
3300
- }
3301
- const sectionAccumulator = /* @__PURE__ */ new Map();
3302
- for (const file of files) {
3303
- const key = sectionForPath(file.path);
3304
- const current = sectionAccumulator.get(key) ?? { files: 0, area: 0 };
3305
- current.files++;
3306
- current.area += file.actualArea;
3307
- sectionAccumulator.set(key, current);
3308
- }
3309
- const sections = [...sectionAccumulator.entries()].map(([key, value]) => ({
3310
- key,
3311
- label: key,
3312
- files: value.files,
3313
- area: value.area,
3314
- color: PALETTE[hashString(key) % PALETTE.length]
3315
- })).sort((a, b) => b.area - a.area || a.key.localeCompare(b.key));
3316
- const sectionIndex = new Map(sections.map((section, index) => [section.key, index]));
3317
- for (const file of files) {
3318
- file.section = sectionIndex.get(sectionForPath(file.path)) ?? 0;
3319
- }
3320
- const metrics = metricKeys.map((key) => {
3321
- const present = files.map((file) => valueOf(attributes[file.nodeId], key)).filter((value) => value !== void 0);
3322
- const min = present.length > 0 ? Math.min(...present) : 0;
3323
- const actualMax = present.length > 0 ? Math.max(...present) : 0;
3324
- const p95 = percentile2(present, 0.95);
3325
- const skewed = min >= 0 && p95 > 0 && actualMax > p95 * 4;
3326
- const max = skewed ? actualMax : p95 > 0 ? p95 : actualMax;
3327
- return {
3328
- key,
3329
- title: metricTitle(descriptors, key),
3330
- direction: metricDirection(descriptors, key),
3331
- min,
3332
- max,
3333
- actualMax,
3334
- p95,
3335
- mode: skewed ? "log" : "linear",
3336
- coverage: present.length
3337
- };
3338
- });
3339
- for (const file of files) {
3340
- const values = attributes[file.nodeId];
3341
- file.values = metrics.map((metric) => valueOf(values, metric.key) ?? null);
3342
- }
3343
- const roots = buildRoots(cc, singleRoot, fileIndexById, files);
3344
- const folderCount = countFolders(roots);
3345
- const dependency = cc.lenses?.dependency;
3346
- const leafEdges = asEdges(dependency?.leafEdges);
3347
- const rawEdges = leafEdges.length > 0 ? leafEdges : asEdges(dependency?.edges);
3348
- const edgeSet = /* @__PURE__ */ new Set();
3349
- const edges = [];
3350
- const n = files.length;
3351
- for (const edge of rawEdges) {
3352
- const from = fileIndexById.get(edge.fromId);
3353
- const to = fileIndexById.get(edge.toId);
3354
- if (from === void 0 || to === void 0 || from === to) continue;
3355
- const key = from * n + to;
3356
- if (edgeSet.has(key)) continue;
3357
- edgeSet.add(key);
3358
- edges.push({ from, to });
3359
- if (edges.length >= 2e4) {
3360
- warnings.push("se han limitado las dependencias a 20.000 aristas");
3361
- break;
3362
- }
3363
- }
3364
- edges.sort((a, b) => a.from - b.from || a.to - b.to);
3365
- const flaggedEdges = /* @__PURE__ */ new Set();
3366
- for (const edge of rawEdges) {
3367
- if (edge.isCyclic !== true) continue;
3368
- const from = fileIndexById.get(edge.fromId);
3369
- const to = fileIndexById.get(edge.toId);
3370
- if (from === void 0 || to === void 0) continue;
3371
- flaggedEdges.add(from * n + to);
3372
- }
3373
- const cycleSet = /* @__PURE__ */ new Set();
3374
- const cycles = [];
3375
- for (const edge of edges) {
3376
- const key = edge.from * n + edge.to;
3377
- const backward = edgeSet.has(edge.to * n + edge.from);
3378
- if (backward || flaggedEdges.has(key)) {
3379
- if (!cycleSet.has(key)) {
3380
- cycleSet.add(key);
3381
- cycles.push(edge);
3222
+ function serialise(model) {
3223
+ const directoryIndex = /* @__PURE__ */ new Map();
3224
+ const directories = [];
3225
+ const fileRows = model.files.map((file) => {
3226
+ const slash = file.path.lastIndexOf("/");
3227
+ const directory = slash >= 0 ? file.path.slice(0, slash) : "";
3228
+ let dirIndex = -1;
3229
+ if (directory) {
3230
+ const known = directoryIndex.get(directory);
3231
+ if (known === void 0) {
3232
+ dirIndex = directories.length;
3233
+ directoryIndex.set(directory, dirIndex);
3234
+ directories.push(directory);
3235
+ } else {
3236
+ dirIndex = known;
3382
3237
  }
3383
3238
  }
3384
- }
3385
- cycles.sort((a, b) => a.from - b.from || a.to - b.to);
3386
- if (rawEdges.length === 0) {
3387
- warnings.push("este cc.json no trae una lente de dependencias utilizable");
3388
- }
3389
- const totalArea = files.reduce((sum, file) => sum + file.actualArea, 0);
3390
- const hotspotCount = files.filter((file) => file.risk >= 0.66).length;
3391
- let initialLens = "risk";
3392
- let initialMetric = null;
3393
- if (options.color === "structure") {
3394
- initialLens = "structure";
3395
- } else if (options.color && options.color !== "risk") {
3396
- if (!coverage.has(options.color)) {
3397
- throw new RenderError(`la m\xE9trica de color "${options.color}" no existe en este cc.json`);
3398
- }
3399
- initialLens = "metric";
3400
- initialMetric = options.color;
3401
- }
3402
- const defaultSelection = pickDefaultSelection(files, initialLens, initialMetric, metrics);
3403
- const project = cc.meta.projectName || options.title || "Proyecto sin nombre";
3239
+ const name = slash >= 0 ? file.path.slice(slash + 1) : file.path;
3240
+ return [
3241
+ dirIndex,
3242
+ name,
3243
+ file.section,
3244
+ round(file.area, 1),
3245
+ round(file.actualArea, 1),
3246
+ round(file.risk, 4),
3247
+ round(file.complexity, 1),
3248
+ round(file.churn, 1),
3249
+ ...file.values.map((value) => value === null ? null : round(value, 2))
3250
+ ];
3251
+ });
3404
3252
  return {
3253
+ v: 1,
3405
3254
  meta: {
3406
- project,
3407
- title: options.title || project,
3408
- commit: cc.meta.commitHash || "",
3409
- checksum: cc.meta.checksum || "",
3410
- apiVersion: cc.meta.apiVersion,
3411
- rootName
3255
+ project: model.meta.project,
3256
+ commit: model.meta.commit,
3257
+ checksum: model.meta.checksum,
3258
+ api: model.meta.apiVersion,
3259
+ root: model.meta.rootName
3412
3260
  },
3413
- files,
3414
- sections,
3415
- metrics,
3416
- edges,
3417
- cycles,
3418
- roots,
3419
- areaMetric,
3420
- areaTitle: areaMetric ? metricTitle(descriptors, areaMetric) : "\xC1rea",
3421
- totalArea,
3422
- fileCount: files.length,
3423
- folderCount,
3424
- hotspotCount,
3425
- defaultSelection,
3426
- initialLens,
3427
- initialMetric,
3428
- warnings
3429
- };
3430
- }
3431
- function buildRoots(cc, singleRoot, fileIndexById, files) {
3432
- const build = (node, path8) => {
3433
- if (node.type === "File") {
3434
- const index = fileIndexById.get(node.id);
3435
- const file = index === void 0 ? void 0 : files[index];
3436
- return {
3437
- kind: "file",
3438
- key: `f:${index ?? -1}`,
3439
- name: node.name,
3440
- path: path8,
3441
- value: file?.area ?? 1,
3442
- fileCount: 1,
3443
- section: file?.section ?? 0,
3444
- fileIndex: index
3445
- };
3446
- }
3447
- const children = (node.children ?? []).map(
3448
- (child) => build(child, path8 ? `${path8}/${child.name}` : child.name)
3449
- );
3450
- const folder = {
3451
- kind: "folder",
3452
- key: `d:${path8}`,
3453
- name: node.name,
3454
- path: path8,
3455
- value: children.reduce((sum, child) => sum + child.value, 0) || 1,
3456
- fileCount: children.reduce((sum, child) => sum + child.fileCount, 0),
3457
- section: -1,
3458
- children
3459
- };
3460
- while (folder.children?.length === 1 && folder.children[0]?.kind === "folder") {
3461
- const only = folder.children[0];
3462
- folder.name = `${folder.name}/${only.name}`;
3463
- folder.path = only.path;
3464
- folder.key = `d:${only.path}`;
3465
- folder.value = only.value;
3466
- folder.fileCount = only.fileCount;
3467
- folder.children = only.children;
3468
- }
3469
- return folder;
3261
+ area: {
3262
+ key: model.areaMetric,
3263
+ title: model.areaTitle,
3264
+ total: round(model.totalArea, 1)
3265
+ },
3266
+ lens: model.initialLens,
3267
+ metric: model.initialMetric,
3268
+ metrics: model.metrics.map((metric) => ({
3269
+ key: metric.key,
3270
+ title: metric.title,
3271
+ min: round(metric.min, 2),
3272
+ max: round(metric.max, 2),
3273
+ mode: metric.mode,
3274
+ dir: metric.direction,
3275
+ coverage: metric.coverage
3276
+ })),
3277
+ sections: model.sections.map((section) => ({
3278
+ key: section.key,
3279
+ color: section.color,
3280
+ files: section.files,
3281
+ area: round(section.area, 1)
3282
+ })),
3283
+ dirs: directories,
3284
+ files: fileRows,
3285
+ edges: model.edges.flatMap((edge) => [edge.from, edge.to]),
3286
+ cycles: model.cycles.flatMap((edge) => [edge.from, edge.to]),
3287
+ hot: model.hotspotCount,
3288
+ default: model.defaultSelection,
3289
+ fileCount: model.fileCount,
3290
+ folderCount: model.folderCount,
3291
+ totalArea: round(model.totalArea, 1)
3470
3292
  };
3471
- if (singleRoot) {
3472
- return (singleRoot.children ?? []).map((child) => build(child, child.name));
3473
- }
3474
- return cc.files.map((root) => build(root, root.name));
3475
3293
  }
3476
- function countFolders(nodes) {
3477
- let count = 0;
3478
- const stack = [...nodes];
3479
- while (stack.length > 0) {
3480
- const node = stack.pop();
3481
- if (node.kind === "folder") count++;
3482
- if (node.children) stack.push(...node.children);
3483
- }
3484
- return count;
3294
+ function renderLensButtons(model) {
3295
+ const lenses = [
3296
+ { id: "structure", label: "Estructura" },
3297
+ { id: "risk", label: "Riesgo" }
3298
+ ];
3299
+ if (model.metrics.length > 0) lenses.push({ id: "metric", label: "M\xE9trica" });
3300
+ return lenses.map(
3301
+ (lens) => `<button type="button" class="lens" role="tab" data-lens="${lens.id}" aria-selected="${String(
3302
+ model.initialLens === lens.id
3303
+ )}">${lens.label}</button>`
3304
+ ).join("");
3485
3305
  }
3486
- function pickDefaultSelection(files, lens, metric, metrics) {
3487
- if (files.length === 0) return null;
3488
- if (lens === "metric" && metric) {
3489
- const index = metrics.findIndex((entry) => entry.key === metric);
3490
- if (index >= 0) {
3491
- let best = null;
3492
- for (const file of files) {
3493
- const value = file.values[index];
3494
- if (value === null || value === void 0) continue;
3495
- if (!best || value > (best.values[index] ?? -Infinity)) best = file;
3496
- }
3497
- if (best) return best.index;
3498
- }
3306
+ function renderFindings(model) {
3307
+ const chips = [];
3308
+ if (model.hotspotCount > 0) {
3309
+ chips.push(
3310
+ `<button type="button" class="finding" data-focus="hotspot"><i class="dot info"></i><b>${formatInt(
3311
+ model.hotspotCount
3312
+ )}</b> hotspots</button>`
3313
+ );
3499
3314
  }
3500
- if (lens === "structure") {
3501
- return files.reduce(
3502
- (best, file) => file.actualArea > best.actualArea ? file : best
3503
- ).index;
3315
+ if (model.cycles.length > 0) {
3316
+ chips.push(
3317
+ `<button type="button" class="finding" data-focus="cycle"><i class="dot warn"></i><b>${formatInt(
3318
+ model.cycles.length
3319
+ )}</b> aristas c\xEDclicas</button>`
3320
+ );
3504
3321
  }
3505
- return files.reduce((best, file) => file.risk > best.risk ? file : best).index;
3506
- }
3507
-
3508
- // packages/render/src/html.ts
3509
- import { readFileSync } from "node:fs";
3510
-
3511
- // packages/render/src/color.ts
3512
- var RISK_STOPS = [
3513
- [0, "#ebe7df"],
3514
- [0.35, "#e9cd98"],
3515
- [0.65, "#d68a54"],
3516
- [1, "#a42e2c"]
3517
- ];
3518
- var METRIC_STOPS = [
3519
- [0, "#ebe7df"],
3520
- [0.5, "#b9c9cf"],
3521
- [1, "#355b6b"]
3522
- ];
3523
- var RISK_GRADIENT = "linear-gradient(90deg, #ebe7df 0%, #e9cd98 35%, #d68a54 65%, #a42e2c 100%)";
3524
- var METRIC_GRADIENT = "linear-gradient(90deg, #ebe7df 0%, #b9c9cf 50%, #355b6b 100%)";
3525
- function clamp012(value) {
3526
- if (!Number.isFinite(value)) return 0;
3527
- return Math.max(0, Math.min(1, value));
3528
- }
3529
- function hexToRgb(hex) {
3530
- const value = hex.replace("#", "");
3531
- return [
3532
- Number.parseInt(value.slice(0, 2), 16),
3533
- Number.parseInt(value.slice(2, 4), 16),
3534
- Number.parseInt(value.slice(4, 6), 16)
3535
- ];
3536
- }
3537
- function rgbToHex(rgb) {
3538
- return `#${rgb.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
3322
+ chips.push(
3323
+ `<span class="finding static"><i class="dot neutral"></i><b>${formatInt(
3324
+ model.fileCount
3325
+ )}</b> ficheros</span>`
3326
+ );
3327
+ chips.push(
3328
+ `<span class="finding static"><i class="dot neutral"></i><b>${formatInt(
3329
+ model.edges.length
3330
+ )}</b> dependencias</span>`
3331
+ );
3332
+ return `${chips.join("")}<span class="spacer"></span><span id="search-status" class="search-status" aria-live="polite"></span><span class="run-meta">${escapeHtml(
3333
+ model.areaTitle
3334
+ )} \xB7 ${formatInt(model.metrics.length)} m\xE9tricas \xB7 cc.json ${escapeHtml(
3335
+ model.meta.apiVersion
3336
+ )}</span>`;
3539
3337
  }
3540
- function mix(a, b, t) {
3541
- const from = hexToRgb(a);
3542
- const to = hexToRgb(b);
3543
- return rgbToHex([
3544
- Math.round(from[0] + (to[0] - from[0]) * t),
3545
- Math.round(from[1] + (to[1] - from[1]) * t),
3546
- Math.round(from[2] + (to[2] - from[2]) * t)
3547
- ]);
3338
+ function metricByKey(model, key) {
3339
+ return key ? model.metrics.find((metric) => metric.key === key) : void 0;
3548
3340
  }
3549
- function ramp(stops, t) {
3550
- const value = clamp012(t);
3551
- for (let index = 0; index < stops.length - 1; index++) {
3552
- const from = stops[index];
3553
- const to = stops[index + 1];
3554
- if (value <= to[0]) {
3555
- const span2 = to[0] - from[0] || 1;
3556
- return mix(from[1], to[1], (value - from[0]) / span2);
3341
+ function renderLegend(model) {
3342
+ if (model.initialLens === "structure") {
3343
+ const items = model.sections.slice(0, 6).map(
3344
+ (section) => `<span><i style="background:${section.color}"></i>${escapeHtml(section.key)}</span>`
3345
+ ).join("");
3346
+ return `<span>Carpeta</span><span class="sws">${items}</span>`;
3347
+ }
3348
+ if (model.initialLens === "metric" && model.initialMetric) {
3349
+ const metric = metricByKey(model, model.initialMetric);
3350
+ if (metric) {
3351
+ const direction = metric.direction === -1 ? " \xB7 menos es mejor" : metric.direction === 1 ? " \xB7 m\xE1s es mejor" : "";
3352
+ return `<span>${escapeHtml(metric.title)}${direction}</span><span class="mono">${escapeHtml(
3353
+ formatCompact(metric.min)
3354
+ )}</span><span class="bar" style="background:${METRIC_GRADIENT}"></span><span class="mono">${escapeHtml(
3355
+ formatCompact(metric.max)
3356
+ )}</span>`;
3557
3357
  }
3558
3358
  }
3559
- return stops[stops.length - 1][1];
3359
+ return `<span>Riesgo (0\u2013100)</span><span class="mono">0</span><span class="bar" style="background:${RISK_GRADIENT}"></span><span class="mono">100</span>`;
3560
3360
  }
3561
- function riskColor(risk) {
3562
- return ramp(RISK_STOPS, risk);
3361
+ function renderMetricOptions(model) {
3362
+ return model.metrics.map(
3363
+ (metric) => `<option value="${escapeAttr(metric.key)}"${metric.key === model.initialMetric ? " selected" : ""}>${escapeHtml(metric.title)}</option>`
3364
+ ).join("");
3563
3365
  }
3564
- function metricColor(value, metric) {
3565
- if (value === null) return METRIC_STOPS[0][1];
3566
- let t;
3567
- if (metric.mode === "log") {
3568
- const span2 = Math.max(1e-9, metric.max - metric.min);
3569
- t = Math.log1p(Math.max(0, value - metric.min)) / Math.log1p(span2);
3570
- } else {
3571
- const span2 = metric.max - metric.min;
3572
- t = span2 > 0 ? (value - metric.min) / span2 : 0;
3366
+ function renderInspectorSummary(model) {
3367
+ const hotspots = [...model.files].sort((a, b) => b.risk - a.risk || a.path.localeCompare(b.path)).slice(0, 5);
3368
+ return `<div class="summary">
3369
+ <div class="sub">Proyecto</div>
3370
+ <div class="big">${formatInt(model.fileCount)}</div>
3371
+ <div class="sub">ficheros \xB7 ${formatInt(model.folderCount)} carpetas</div>
3372
+ <p>${formatInt(model.totalArea)} de ${escapeHtml(
3373
+ model.areaTitle.toLowerCase()
3374
+ )} \xB7 ${formatInt(model.metrics.length)} m\xE9tricas \xB7 ${formatInt(
3375
+ model.hotspotCount
3376
+ )} hotspots.</p>
3377
+ <h4>Mayor riesgo</h4>
3378
+ <ul class="deps">${hotspots.map(
3379
+ (file) => `<li data-file="${file.index}"><i class="sw" style="background:${riskColor(
3380
+ file.risk
3381
+ )}"></i><span>${escapeHtml(file.path)}</span><em>${Math.round(
3382
+ file.risk * 100
3383
+ )}</em></li>`
3384
+ ).join("")}</ul>
3385
+ <p>Selecciona un bloque para ver sus m\xE9tricas y sus dependencias. Clic en la cabecera de una carpeta para enfocarla.</p>
3386
+ </div>`;
3387
+ }
3388
+ function renderHtml(model) {
3389
+ const project = escapeHtml(model.meta.project);
3390
+ const title = escapeHtml(model.meta.title);
3391
+ const commit = model.meta.commit ? model.meta.commit.slice(0, 7) : "";
3392
+ const checksum = model.meta.checksum ? model.meta.checksum.slice(0, 8) : "";
3393
+ const badge = commit || checksum || `cc.json ${model.meta.apiVersion}`;
3394
+ const payload = jsonForScript(serialise(model));
3395
+ const metricPicker = model.metrics.length > 0 ? `<label class="metric-pick" id="metric-pick"${model.initialLens === "metric" ? "" : " hidden"}><span>M\xE9trica</span><select id="metric-select">${renderMetricOptions(
3396
+ model
3397
+ )}</select></label>` : "";
3398
+ const crumbs = model.meta.rootName ? `${project} <span class="sep">/</span> ${escapeHtml(model.meta.rootName)}` : project;
3399
+ return `<!doctype html>
3400
+ <html lang="es">
3401
+ <head>
3402
+ <meta charset="utf-8">
3403
+ <meta name="viewport" content="width=device-width, initial-scale=1">
3404
+ <meta name="color-scheme" content="light">
3405
+ <meta name="generator" content="CodArch">
3406
+ <title>CodArch \xB7 ${title}</title>
3407
+ <style>${STYLES}</style>
3408
+ </head>
3409
+ <body>
3410
+ <div class="app">
3411
+ <header>
3412
+ <div class="brand">CodArch</div>
3413
+ <div class="repo">
3414
+ <b>${title}</b>
3415
+ ${model.meta.rootName && model.meta.rootName !== model.meta.project ? `<span>${escapeHtml(model.meta.rootName)}</span>` : ""}
3416
+ <span class="commit">${escapeHtml(badge)}</span>
3417
+ </div>
3418
+ <div class="spacer"></div>
3419
+ <nav class="lenses" role="tablist" aria-label="Lentes">${renderLensButtons(model)}</nav>
3420
+ ${metricPicker}
3421
+ <label class="search">
3422
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true">
3423
+ <circle cx="7" cy="7" r="4.5" stroke="#8d877d" stroke-width="1.4"/>
3424
+ <path d="M10.5 10.5 14 14" stroke="#8d877d" stroke-width="1.4"/>
3425
+ </svg>
3426
+ <input id="q" type="search" placeholder="Buscar fichero\u2026" autocomplete="off" aria-label="Buscar fichero">
3427
+ </label>
3428
+ </header>
3429
+
3430
+ <div class="findings">${renderFindings(model)}</div>
3431
+
3432
+ <main class="map-wrap">
3433
+ <div class="map" id="map">${renderSvg(model)}</div>
3434
+ <div class="tooltip" id="tooltip"></div>
3435
+ </main>
3436
+
3437
+ <aside id="inspector">${renderInspectorSummary(model)}</aside>
3438
+
3439
+ <footer>
3440
+ <span class="crumbs" id="crumbs">${crumbs}</span>
3441
+ <span class="hint">clic para fijar \xB7 esc para limpiar</span>
3442
+ <span class="legend" id="legend">${renderLegend(model)}</span>
3443
+ </footer>
3444
+ </div>
3445
+ <script id="codarch-data" type="application/json">${payload}</script>
3446
+ <script>${CLIENT}</script>
3447
+ </body>
3448
+ </html>`;
3449
+ }
3450
+
3451
+ // apps/cli/src/io.ts
3452
+ import fs5 from "node:fs";
3453
+ import zlib from "node:zlib";
3454
+ function readJsonFile2(file) {
3455
+ const buffer = fs5.readFileSync(file);
3456
+ const isGzip = buffer.length > 2 && buffer[0] === 31 && buffer[1] === 139;
3457
+ const raw = isGzip ? zlib.gunzipSync(buffer) : buffer;
3458
+ const text = raw.toString("utf8");
3459
+ try {
3460
+ return { json: JSON.parse(text), bytes: buffer.length };
3461
+ } catch (error) {
3462
+ const message = error instanceof Error ? error.message : String(error);
3463
+ throw new Error(`no es JSON v\xE1lido: ${message}`);
3573
3464
  }
3574
- return ramp(METRIC_STOPS, t);
3575
- }
3576
- function inkFor(color) {
3577
- const [r, g, b] = hexToRgb(color);
3578
- const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
3579
- return luminance > 0.52 ? "#1a1a1a" : "#f7f5f1";
3580
3465
  }
3581
- function colorForFile(lens, risk, section, value, metric) {
3582
- if (lens === "structure") return section?.color ?? "#ddd8cf";
3583
- if (lens === "risk" || !metric) return riskColor(risk);
3584
- return metricColor(value, metric);
3466
+ function formatBytes(bytes) {
3467
+ if (bytes < 1024) return `${bytes} B`;
3468
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
3469
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
3585
3470
  }
3586
3471
 
3587
- // packages/layouts/src/squarify.ts
3588
- function squarify(items, rect) {
3589
- if (rect.w <= 0 || rect.h <= 0) return [];
3590
- const usable = items.filter(
3591
- (item) => Number.isFinite(item.value) && item.value > 0
3592
- );
3593
- const total = usable.reduce((sum, item) => sum + item.value, 0);
3594
- if (usable.length === 0 || total <= 0) return [];
3595
- const sorted = usable.map((item, index2) => ({ item, index: index2 })).sort((a, b) => b.item.value - a.item.value || a.index - b.index).map(({ item }) => item);
3596
- const scale = rect.w * rect.h / total;
3597
- const placed = [];
3598
- let x = rect.x;
3599
- let y = rect.y;
3600
- let w = rect.w;
3601
- let h = rect.h;
3602
- let row = [];
3603
- const area = (item) => item.value * scale;
3604
- function worst(candidate, side) {
3605
- let sum = 0;
3606
- let max = -Infinity;
3607
- let min = Infinity;
3608
- for (const item of candidate) {
3609
- const value = area(item);
3610
- sum += value;
3611
- if (value > max) max = value;
3612
- if (value < min) min = value;
3472
+ // apps/cli/src/commands/analyze.ts
3473
+ var numberFormat = new Intl.NumberFormat("es-ES");
3474
+ function num(value) {
3475
+ return numberFormat.format(value);
3476
+ }
3477
+ function field(label, value) {
3478
+ return ` ${label.padEnd(12)} ${value}`;
3479
+ }
3480
+ function runAnalyze(options) {
3481
+ const root = options.root ?? ".";
3482
+ let result;
3483
+ try {
3484
+ result = analyzeRepository(root, {
3485
+ ignore: options.ignore,
3486
+ projectName: options.projectName,
3487
+ git: options.git
3488
+ });
3489
+ } catch (error) {
3490
+ console.error(`no se pudo analizar ${root}: ${error.message}`);
3491
+ return 1;
3492
+ }
3493
+ let cc;
3494
+ try {
3495
+ cc = parseCcJson(result.snapshot);
3496
+ } catch (error) {
3497
+ if (error instanceof CcJsonError) {
3498
+ console.error(`el snapshot generado no es cc.json v\xE1lido: ${error.message}`);
3499
+ return 1;
3613
3500
  }
3614
- if (sum <= 0 || side <= 0) return Infinity;
3615
- return Math.max(
3616
- side * side * max / (sum * sum),
3617
- sum * sum / (side * side * min)
3501
+ throw error;
3502
+ }
3503
+ const outDirectory = path5.resolve(
3504
+ options.out ?? path5.join(path5.resolve(root), ".codarch")
3505
+ );
3506
+ const snapshotPath = path5.join(outDirectory, "snapshot.cc.json");
3507
+ const mapPath = path5.join(outDirectory, "map.html");
3508
+ try {
3509
+ fs6.mkdirSync(outDirectory, { recursive: true });
3510
+ fs6.writeFileSync(
3511
+ snapshotPath,
3512
+ `${JSON.stringify(cc, null, 2)}
3513
+ `,
3514
+ "utf8"
3618
3515
  );
3516
+ } catch (error) {
3517
+ console.error(
3518
+ `no se pudo escribir ${snapshotPath}: ${error.message}`
3519
+ );
3520
+ return 1;
3619
3521
  }
3620
- function place(current, horizontal) {
3621
- if (current.length === 0) return;
3622
- const sum = current.reduce((acc, item) => acc + area(item), 0);
3623
- if (sum <= 0) return;
3624
- if (horizontal) {
3625
- if (w <= 0) return;
3626
- const thickness = sum / w;
3627
- if (!Number.isFinite(thickness) || thickness <= 0) return;
3628
- let position = x;
3629
- for (const item of current) {
3630
- const length = area(item) / thickness;
3631
- placed.push({ item, x: position, y, w: length, h: thickness });
3632
- position += length;
3633
- }
3634
- y += thickness;
3635
- h -= thickness;
3636
- } else {
3637
- if (h <= 0) return;
3638
- const thickness = sum / h;
3639
- if (!Number.isFinite(thickness) || thickness <= 0) return;
3640
- let position = y;
3641
- for (const item of current) {
3642
- const length = area(item) / thickness;
3643
- placed.push({ item, x, y: position, w: thickness, h: length });
3644
- position += length;
3645
- }
3646
- x += thickness;
3647
- w -= thickness;
3522
+ let mapBytes = 0;
3523
+ let mapError = null;
3524
+ if (result.files > 0) {
3525
+ try {
3526
+ const model = buildRenderModel(cc, {
3527
+ areaMetric: "rloc",
3528
+ color: "risk",
3529
+ title: options.title
3530
+ });
3531
+ const html = renderHtml(model);
3532
+ fs6.writeFileSync(mapPath, html, "utf8");
3533
+ mapBytes = Buffer.byteLength(html, "utf8");
3534
+ } catch (error) {
3535
+ mapError = error instanceof RenderError ? error.message : error.message;
3648
3536
  }
3537
+ } else {
3538
+ mapError = "no hay ficheros TypeScript/JavaScript que dibujar";
3649
3539
  }
3650
- let index = 0;
3651
- while (index < sorted.length) {
3652
- const horizontal = w >= h;
3653
- const side = horizontal ? w : h;
3654
- const next = sorted[index];
3655
- if (row.length === 0) {
3656
- row.push(next);
3657
- index++;
3658
- continue;
3659
- }
3660
- if (worst([...row, next], side) <= worst(row, side)) {
3661
- row.push(next);
3662
- index++;
3663
- } else {
3664
- place(row, horizontal);
3665
- row = [];
3666
- }
3540
+ const snapshotBytes = Buffer.byteLength(
3541
+ fs6.readFileSync(snapshotPath),
3542
+ "utf8"
3543
+ );
3544
+ const lines = [];
3545
+ lines.push("");
3546
+ lines.push(" CodArch \xB7 an\xE1lisis");
3547
+ lines.push("");
3548
+ lines.push(field("ra\xEDz", path5.resolve(root)));
3549
+ lines.push(field("snapshot", `${snapshotPath} (${formatBytes(snapshotBytes)})`));
3550
+ if (mapError) {
3551
+ lines.push(field("mapa", `omitido (${mapError})`));
3552
+ } else {
3553
+ lines.push(field("mapa", `${mapPath} (${formatBytes(mapBytes)})`));
3667
3554
  }
3668
- if (row.length > 0) place(row, w >= h);
3669
- return placed;
3670
- }
3671
-
3672
- // packages/render/src/layout.ts
3673
- var VIEW_WIDTH = 1400;
3674
- var VIEW_HEIGHT = 900;
3675
- var VIEW_PADDING = 8;
3676
- var MAX_DEPTH3 = 12;
3677
- function layoutMap(roots) {
3678
- const rect = {
3679
- x: VIEW_PADDING,
3680
- y: VIEW_PADDING,
3681
- w: VIEW_WIDTH - VIEW_PADDING * 2,
3682
- h: VIEW_HEIGHT - VIEW_PADDING * 2
3683
- };
3684
- return layoutChildren(roots, rect, 0);
3685
- }
3686
- function layoutChildren(nodes, rect, depth) {
3687
- if (nodes.length === 0 || rect.w < 2 || rect.h < 2) return [];
3688
- const items = nodes.map((node) => ({
3689
- key: node.key,
3690
- value: Math.max(1, node.value),
3691
- node
3692
- }));
3693
- return squarify(items, rect).map(
3694
- (placed) => layoutNode(placed.item.node, {
3695
- x: placed.x,
3696
- y: placed.y,
3697
- w: placed.w,
3698
- h: placed.h
3699
- }, depth)
3555
+ lines.push(
3556
+ field(
3557
+ "estructura",
3558
+ `${num(result.files)} ficheros \xB7 ${num(result.folders)} carpetas \xB7 ${num(
3559
+ result.edges
3560
+ )} aristas \xB7 ${num(result.cycles.length)} ciclos`
3561
+ )
3700
3562
  );
3701
- }
3702
- function layoutNode(node, rect, depth) {
3703
- if (node.kind === "file" || !node.children || node.children.length === 0) {
3704
- return { node, rect, headerHeight: 0, children: [] };
3563
+ lines.push(
3564
+ field(
3565
+ "imports",
3566
+ `${num(result.externalImports)} externos \xB7 ${num(
3567
+ result.builtinImports
3568
+ )} builtins \xB7 ${num(result.typeOnlyImports)} type-only \xB7 ${num(
3569
+ result.unresolvedImports
3570
+ )} sin resolver`
3571
+ )
3572
+ );
3573
+ const migrationLanguages = Object.entries(result.migration.languages).map(([language, stats]) => `${language} ${num(stats?.rloc ?? 0)}`).join(" \xB7 ");
3574
+ lines.push(
3575
+ field(
3576
+ "migraci\xF3n",
3577
+ `${migrationLanguages || "\u2014"} \xB7 ${num(
3578
+ result.migration.bridgeFiles
3579
+ )} puentes \xB7 ${num(result.migration.ffiEdges)} FFI \xB7 deuda ${num(
3580
+ result.migration.migrationDebt
3581
+ )}`
3582
+ )
3583
+ );
3584
+ lines.push(field("tiempo", `${num(result.durationMs)} ms`));
3585
+ lines.push(
3586
+ field(
3587
+ "historial",
3588
+ result.git.enabled ? `${num(result.git.commits)} commits \xB7 ${num(
3589
+ result.git.authors
3590
+ )} autores${result.git.shallow ? " \xB7 shallow" : ""}` : "sin git; se omite churn"
3591
+ )
3592
+ );
3593
+ for (const warning of result.warnings.slice(0, 10)) {
3594
+ lines.push(field("aviso", warning));
3705
3595
  }
3706
- let headerHeight = 0;
3707
- if (rect.w >= 30 && rect.h >= 32) {
3708
- headerHeight = rect.h >= 62 ? 17 : 13;
3596
+ if (result.warnings.length > 10) {
3597
+ lines.push(field("aviso", `y ${num(result.warnings.length - 10)} m\xE1s`));
3598
+ }
3599
+ for (const issue of result.unresolved.slice(0, 5)) {
3600
+ lines.push(
3601
+ field(
3602
+ "sin resolver",
3603
+ `${issue.file}:${issue.line} ${issue.specifier} (${issue.reason})`
3604
+ )
3605
+ );
3606
+ }
3607
+ if (result.unresolved.length > 5) {
3608
+ lines.push(
3609
+ field("sin resolver", `y ${num(result.unresolved.length - 5)} m\xE1s`)
3610
+ );
3709
3611
  }
3710
- const inner = {
3711
- x: rect.x + 1,
3712
- y: rect.y + headerHeight + 1,
3713
- w: Math.max(0, rect.w - 2),
3714
- h: Math.max(0, rect.h - headerHeight - 2)
3715
- };
3716
- const children = depth < MAX_DEPTH3 && inner.w >= 1 && inner.h >= 1 ? layoutChildren(node.children, inner, depth + 1) : [];
3717
- return { node, rect, headerHeight, children };
3612
+ lines.push("");
3613
+ console.log(lines.join("\n"));
3614
+ return 0;
3718
3615
  }
3719
3616
 
3720
- // packages/render/src/svg.ts
3721
- function escapeHtml(value) {
3722
- return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
3723
- }
3724
- function escapeAttr(value) {
3725
- return escapeHtml(value);
3726
- }
3727
- function formatInt(value) {
3728
- if (!Number.isFinite(value)) return "\u2014";
3729
- return Math.round(value).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ".");
3730
- }
3731
- function formatCompact(value) {
3732
- if (!Number.isFinite(value)) return "\u2014";
3733
- const absolute = Math.abs(value);
3734
- if (absolute >= 1e6) {
3735
- return `${(value / 1e6).toFixed(1).replace(".0", "").replace(".", ",")}M`;
3617
+ // apps/cli/src/commands/diff.ts
3618
+ import fs8 from "node:fs";
3619
+ import path6 from "node:path";
3620
+
3621
+ // packages/metrics/src/config.ts
3622
+ import fs7 from "node:fs";
3623
+ import { parse as parseYaml } from "yaml";
3624
+ var ConfigError = class extends Error {
3625
+ constructor(message) {
3626
+ super(message);
3627
+ this.name = "ConfigError";
3736
3628
  }
3737
- if (absolute >= 1e3) {
3738
- return `${(value / 1e3).toFixed(1).replace(".0", "").replace(".", ",")}k`;
3629
+ };
3630
+ var SEVERITIES = /* @__PURE__ */ new Set(["info", "warn", "error"]);
3631
+ function parseRuleConfig(input) {
3632
+ const root = asObject(input, "config");
3633
+ if (root.version !== 1) {
3634
+ throw new ConfigError("config.version debe ser 1");
3739
3635
  }
3740
- return formatInt(value);
3741
- }
3742
- function coord(value) {
3743
- const rounded = Math.round(value);
3744
- return Object.is(rounded, -0) ? "0" : String(rounded);
3745
- }
3746
- function span(value) {
3747
- return String(Math.max(1, Math.round(value)));
3636
+ const layers = parseLayers(root.layers);
3637
+ const layerNames = new Set(layers.map((layer) => layer.name));
3638
+ const rules = parseRules(root.rules, layerNames);
3639
+ return { version: 1, layers, rules };
3748
3640
  }
3749
- function truncate(value, maxWidth, fontSize) {
3750
- const maxChars = Math.floor(maxWidth / (fontSize * 0.56));
3751
- if (maxChars <= 0) return "";
3752
- if (value.length <= maxChars) return value;
3753
- if (maxChars === 1) return "\u2026";
3754
- return `${value.slice(0, maxChars - 1)}\u2026`;
3641
+ function loadRuleConfig(file) {
3642
+ let text;
3643
+ try {
3644
+ text = fs7.readFileSync(file, "utf8");
3645
+ } catch (error) {
3646
+ throw new ConfigError(
3647
+ `no se pudo leer ${file}: ${error.message}`
3648
+ );
3649
+ }
3650
+ let value;
3651
+ try {
3652
+ value = parseYaml(text);
3653
+ } catch (error) {
3654
+ throw new ConfigError(
3655
+ `YAML inv\xE1lido en ${file}: ${error.message}`
3656
+ );
3657
+ }
3658
+ try {
3659
+ return parseRuleConfig(value);
3660
+ } catch (error) {
3661
+ if (error instanceof ConfigError) {
3662
+ throw new ConfigError(`${file}: ${error.message}`);
3663
+ }
3664
+ throw error;
3665
+ }
3755
3666
  }
3756
- function labelFor(nodeName, rect, out, ink) {
3757
- if (rect.w < 44 || rect.h < 13) return;
3758
- const available = rect.w - 8;
3759
- const dot = nodeName.lastIndexOf(".");
3760
- const twoLines = rect.h >= 27 && rect.w >= 64 && dot > 0 && dot < nodeName.length - 1;
3761
- if (twoLines) {
3762
- const base = truncate(nodeName.slice(0, dot), available, 12.5);
3763
- const extension = truncate(nodeName.slice(dot), available, 10);
3764
- if (base) {
3765
- out.push(
3766
- `<text class="a" x="${coord(rect.x + 4)}" y="${coord(rect.y + 12)}" fill="${ink}">${escapeHtml(base)}</text>`
3667
+ function parseLayers(value) {
3668
+ if (value === void 0) return [];
3669
+ if (!Array.isArray(value)) {
3670
+ throw new ConfigError("config.layers debe ser una lista");
3671
+ }
3672
+ const layers = [];
3673
+ const names = /* @__PURE__ */ new Set();
3674
+ value.forEach((raw, index) => {
3675
+ const layer = asObject(raw, `config.layers[${index}]`);
3676
+ const name = asString(layer.name, `config.layers[${index}].name`);
3677
+ if (names.has(name)) {
3678
+ throw new ConfigError(`capa duplicada: ${name}`);
3679
+ }
3680
+ names.add(name);
3681
+ if (layer.patterns !== void 0 && layer.paths !== void 0) {
3682
+ throw new ConfigError(
3683
+ `config.layers[${index}] no puede tener patterns y paths a la vez`
3767
3684
  );
3768
3685
  }
3769
- if (extension) {
3770
- out.push(
3771
- `<text class="b" x="${coord(rect.x + 4)}" y="${coord(rect.y + 22.5)}" fill="${ink}">${escapeHtml(extension)}</text>`
3686
+ const rawPatterns = layer.patterns ?? layer.paths;
3687
+ if (!Array.isArray(rawPatterns) || rawPatterns.length === 0) {
3688
+ throw new ConfigError(
3689
+ `config.layers[${index}].patterns debe ser una lista no vac\xEDa`
3772
3690
  );
3773
3691
  }
3774
- return;
3775
- }
3776
- const label = truncate(nodeName, available, 12.5);
3777
- if (!label) return;
3778
- out.push(
3779
- `<text class="a" x="${coord(rect.x + 4)}" y="${coord(rect.y + 13)}" fill="${ink}">${escapeHtml(label)}</text>`
3780
- );
3692
+ const patterns = rawPatterns.map((rawPattern, patternIndex) => {
3693
+ const pattern = asString(
3694
+ rawPattern,
3695
+ `config.layers[${index}].patterns[${patternIndex}]`
3696
+ ).replaceAll("\\", "/").replace(/^\.\//, "");
3697
+ if (pattern.startsWith("/") || /^[A-Za-z]:\//.test(pattern)) {
3698
+ throw new ConfigError(
3699
+ `el patr\xF3n "${pattern}" debe ser relativo al root del proyecto`
3700
+ );
3701
+ }
3702
+ return pattern;
3703
+ });
3704
+ layers.push({ name, patterns });
3705
+ });
3706
+ return layers;
3781
3707
  }
3782
- function renderPlaced(placed, out, model, metricIndex, metric) {
3783
- const node = placed.node;
3784
- const rect = placed.rect;
3785
- if (node.kind === "file") {
3786
- const index = node.fileIndex;
3787
- if (index === void 0) return;
3788
- const file = model.files[index];
3789
- if (!file) return;
3790
- const value = metricIndex >= 0 ? file.values[metricIndex] ?? null : null;
3791
- const fill = colorForFile(
3792
- model.initialLens,
3793
- file.risk,
3794
- model.sections[file.section],
3795
- value,
3796
- metric
3708
+ function parseRules(value, layerNames) {
3709
+ if (value === void 0) return [];
3710
+ if (!Array.isArray(value)) {
3711
+ throw new ConfigError("config.rules debe ser una lista");
3712
+ }
3713
+ const rules = [];
3714
+ const names = /* @__PURE__ */ new Set();
3715
+ value.forEach((raw, index) => {
3716
+ const rule = asObject(raw, `config.rules[${index}]`);
3717
+ const name = asString(rule.name, `config.rules[${index}].name`);
3718
+ if (names.has(name)) {
3719
+ throw new ConfigError(`regla duplicada: ${name}`);
3720
+ }
3721
+ names.add(name);
3722
+ const type = asString(rule.type, `config.rules[${index}].type`);
3723
+ const severity = parseSeverity(
3724
+ rule.severity,
3725
+ `config.rules[${index}].severity`
3797
3726
  );
3798
- const ink = inkFor(fill);
3799
- out.push(
3800
- `<rect data-i="${index}" x="${coord(rect.x)}" y="${coord(rect.y)}" width="${span(rect.w)}" height="${span(rect.h)}" fill="${fill}"/>`
3727
+ const message = rule.message === void 0 ? void 0 : asString(rule.message, `config.rules[${index}].message`);
3728
+ if (type === "forbidden") {
3729
+ const from = asString(rule.from, `config.rules[${index}].from`);
3730
+ const to = asString(rule.to, `config.rules[${index}].to`);
3731
+ if (!layerNames.has(from)) {
3732
+ throw new ConfigError(
3733
+ `config.rules[${index}].from apunta a una capa inexistente: ${from}`
3734
+ );
3735
+ }
3736
+ if (!layerNames.has(to)) {
3737
+ throw new ConfigError(
3738
+ `config.rules[${index}].to apunta a una capa inexistente: ${to}`
3739
+ );
3740
+ }
3741
+ rules.push({ type, name, from, to, severity, message });
3742
+ return;
3743
+ }
3744
+ if (type === "threshold") {
3745
+ const metric = asString(
3746
+ rule.metric,
3747
+ `config.rules[${index}].metric`
3748
+ );
3749
+ const min = optionalNumber(rule.min, `config.rules[${index}].min`);
3750
+ const max = optionalNumber(rule.max, `config.rules[${index}].max`);
3751
+ if (min === void 0 && max === void 0) {
3752
+ throw new ConfigError(
3753
+ `config.rules[${index}] necesita min o max`
3754
+ );
3755
+ }
3756
+ if (min !== void 0 && max !== void 0 && min > max) {
3757
+ throw new ConfigError(
3758
+ `config.rules[${index}]: min no puede ser mayor que max`
3759
+ );
3760
+ }
3761
+ rules.push({ type, name, metric, min, max, severity, message });
3762
+ return;
3763
+ }
3764
+ if (type === "cycles") {
3765
+ rules.push({ type, name, severity, message });
3766
+ return;
3767
+ }
3768
+ throw new ConfigError(
3769
+ `config.rules[${index}].type desconocido: ${type}`
3801
3770
  );
3802
- labelFor(file.name, rect, out, ink);
3803
- return;
3771
+ });
3772
+ return rules;
3773
+ }
3774
+ function parseSeverity(value, path9) {
3775
+ if (value === void 0) return "error";
3776
+ if (typeof value !== "string" || !SEVERITIES.has(value)) {
3777
+ throw new ConfigError(`${path9} debe ser info, warn o error`);
3804
3778
  }
3805
- const folder = node;
3806
- out.push(
3807
- `<g class="folder" data-nm="${escapeAttr(folder.name)}" data-n="${folder.fileCount}" data-a="${coord(folder.value)}">`
3779
+ return value;
3780
+ }
3781
+ function optionalNumber(value, path9) {
3782
+ if (value === void 0) return void 0;
3783
+ if (typeof value !== "number" || !Number.isFinite(value)) {
3784
+ throw new ConfigError(`${path9} debe ser un n\xFAmero`);
3785
+ }
3786
+ return value;
3787
+ }
3788
+ function asObject(value, path9) {
3789
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
3790
+ throw new ConfigError(`${path9} debe ser un objeto`);
3791
+ }
3792
+ return value;
3793
+ }
3794
+ function asString(value, path9) {
3795
+ if (typeof value !== "string" || value.trim() === "") {
3796
+ throw new ConfigError(`${path9} debe ser un texto no vac\xEDo`);
3797
+ }
3798
+ return value.trim();
3799
+ }
3800
+
3801
+ // packages/metrics/src/rules.ts
3802
+ import { minimatch } from "minimatch";
3803
+ function emptyRuleReport() {
3804
+ return {
3805
+ configured: false,
3806
+ summary: {
3807
+ layers: 0,
3808
+ rules: 0,
3809
+ introduced: 0,
3810
+ existing: 0,
3811
+ resolved: 0,
3812
+ introducedErrors: 0,
3813
+ introducedWarnings: 0
3814
+ },
3815
+ violations: { introduced: [], existing: [], resolved: [] }
3816
+ };
3817
+ }
3818
+ function evaluateRules(base, head, config) {
3819
+ const baseViolations = evaluateInput(base, config);
3820
+ const headViolations = evaluateInput(head, config);
3821
+ const baseByKey = new Map(
3822
+ baseViolations.map((violation) => [violation.key, violation])
3808
3823
  );
3809
- out.push(
3810
- `<rect class="fr" x="${coord(rect.x)}" y="${coord(rect.y)}" width="${span(rect.w)}" height="${span(rect.h)}"/>`
3824
+ const headByKey = new Map(
3825
+ headViolations.map((violation) => [violation.key, violation])
3811
3826
  );
3812
- if (placed.headerHeight >= 13 && rect.w >= 30) {
3813
- const headerHeight = placed.headerHeight;
3814
- out.push(
3815
- `<rect class="fh" x="${coord(rect.x + 1)}" y="${coord(rect.y + 1)}" width="${span(rect.w - 2)}" height="${span(headerHeight - 1)}"/>`
3816
- );
3817
- const count = formatCompact(folder.value);
3818
- const showCount = rect.w >= 86;
3819
- const countWidth = showCount ? count.length * 5.8 + 12 : 0;
3820
- const name = truncate(folder.name, Math.max(8, rect.w - 12 - countWidth), 11.5);
3821
- if (name) {
3822
- out.push(
3823
- `<text class="ft" x="${coord(rect.x + 6)}" y="${coord(rect.y + headerHeight / 2 + 4)}">${escapeHtml(name)}</text>`
3824
- );
3827
+ const introduced = [];
3828
+ const existing = [];
3829
+ const resolved = [];
3830
+ for (const [key, violation] of headByKey) {
3831
+ if (baseByKey.has(key)) existing.push(violation);
3832
+ else introduced.push(violation);
3833
+ }
3834
+ for (const [key, violation] of baseByKey) {
3835
+ if (!headByKey.has(key)) resolved.push(violation);
3836
+ }
3837
+ introduced.sort(compareViolations);
3838
+ existing.sort(compareViolations);
3839
+ resolved.sort(compareViolations);
3840
+ return {
3841
+ configured: true,
3842
+ summary: {
3843
+ layers: config.layers.length,
3844
+ rules: config.rules.length,
3845
+ introduced: introduced.length,
3846
+ existing: existing.length,
3847
+ resolved: resolved.length,
3848
+ introducedErrors: introduced.filter(
3849
+ (violation) => violation.severity === "error"
3850
+ ).length,
3851
+ introducedWarnings: introduced.filter(
3852
+ (violation) => violation.severity === "warn"
3853
+ ).length
3854
+ },
3855
+ violations: { introduced, existing, resolved }
3856
+ };
3857
+ }
3858
+ function evaluateInput(input, config) {
3859
+ const layers = layerIndex(input.files, config.layers);
3860
+ const violations = [];
3861
+ for (const rule of config.rules) {
3862
+ if (rule.type === "forbidden") {
3863
+ evaluateForbidden(rule, input, layers, violations);
3864
+ } else if (rule.type === "threshold") {
3865
+ evaluateThreshold(rule, input, violations);
3866
+ } else {
3867
+ evaluateCycles(rule, input.cycles, violations);
3825
3868
  }
3826
- if (showCount) {
3827
- out.push(
3828
- `<text class="fn" x="${coord(rect.x + rect.w - 6)}" y="${coord(rect.y + headerHeight / 2 + 3.6)}">${escapeHtml(count)}</text>`
3829
- );
3869
+ }
3870
+ const byKey = /* @__PURE__ */ new Map();
3871
+ for (const violation of violations) byKey.set(violation.key, violation);
3872
+ return [...byKey.values()].sort(compareViolations);
3873
+ }
3874
+ function layerIndex(files, layers) {
3875
+ const index = /* @__PURE__ */ new Map();
3876
+ for (const file of files) {
3877
+ for (const layer of layers) {
3878
+ if (layer.patterns.some(
3879
+ (pattern) => minimatch(file.path, pattern, { dot: true })
3880
+ )) {
3881
+ index.set(file.id, layer.name);
3882
+ break;
3883
+ }
3830
3884
  }
3831
3885
  }
3832
- for (const child of placed.children) {
3833
- renderPlaced(child, out, model, metricIndex, metric);
3886
+ return index;
3887
+ }
3888
+ function evaluateForbidden(rule, input, layers, violations) {
3889
+ for (const edge of input.edges) {
3890
+ if (layers.get(edge.fromId) !== rule.from) continue;
3891
+ if (layers.get(edge.toId) !== rule.to) continue;
3892
+ violations.push({
3893
+ key: `forbidden:${rule.name}:${edge.fromId}->${edge.toId}`,
3894
+ rule: rule.name,
3895
+ type: "forbidden",
3896
+ severity: rule.severity,
3897
+ message: rule.message ?? `${edge.from} no puede depender de ${edge.to}`,
3898
+ files: [edge.from, edge.to],
3899
+ edge: {
3900
+ fromId: edge.fromId,
3901
+ toId: edge.toId,
3902
+ from: edge.from,
3903
+ to: edge.to
3904
+ }
3905
+ });
3834
3906
  }
3835
- out.push("</g>");
3836
3907
  }
3837
- function renderSvg(model) {
3838
- const metricIndex = model.initialMetric ? model.metrics.findIndex((metric2) => metric2.key === model.initialMetric) : -1;
3839
- const metric = metricIndex >= 0 ? model.metrics[metricIndex] : void 0;
3840
- const out = [];
3841
- out.push(
3842
- `<svg id="map" viewBox="0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="Mapa de arquitectura de ${escapeAttr(model.meta.project)}">`
3843
- );
3844
- out.push(
3845
- `<rect class="canvas" x="0" y="0" width="${VIEW_WIDTH}" height="${VIEW_HEIGHT}"/>`
3846
- );
3847
- out.push(`<g id="cells">`);
3848
- for (const placed of layoutMap(model.roots)) {
3849
- renderPlaced(placed, out, model, metricIndex, metric);
3908
+ function evaluateThreshold(rule, input, violations) {
3909
+ for (const file of input.files) {
3910
+ const value = file.metrics[rule.metric];
3911
+ if (typeof value !== "number" || !Number.isFinite(value)) continue;
3912
+ const tooHigh = rule.max !== void 0 && value > rule.max;
3913
+ const tooLow = rule.min !== void 0 && value < rule.min;
3914
+ if (!tooHigh && !tooLow) continue;
3915
+ const limit = tooHigh && rule.max !== void 0 ? `m\xE1ximo ${rule.max}` : `m\xEDnimo ${rule.min}`;
3916
+ violations.push({
3917
+ key: `threshold:${rule.name}:${file.id}`,
3918
+ rule: rule.name,
3919
+ type: "threshold",
3920
+ severity: rule.severity,
3921
+ message: rule.message ?? `${file.path}: ${rule.metric} = ${value} (${limit})`,
3922
+ files: [file.path],
3923
+ metric: {
3924
+ name: rule.metric,
3925
+ value,
3926
+ ...rule.min !== void 0 ? { min: rule.min } : {},
3927
+ ...rule.max !== void 0 ? { max: rule.max } : {}
3928
+ }
3929
+ });
3850
3930
  }
3851
- out.push(`</g><g id="edges"></g></svg>`);
3852
- return out.join("");
3853
3931
  }
3854
-
3855
- // packages/render/src/html.ts
3856
- var STYLES = readFileSync(new URL("./styles.css", import.meta.url), "utf8");
3857
- var CLIENT = readFileSync(new URL("./client.js", import.meta.url), "utf8").replace(
3858
- /<\/script/gi,
3859
- "<\\/script"
3860
- );
3861
- function round(value, digits = 2) {
3862
- const factor = 10 ** digits;
3863
- return Math.round(value * factor) / factor;
3932
+ function evaluateCycles(rule, cycles, violations) {
3933
+ for (const cycle of cycles) {
3934
+ const paths = cycle.nodes.map((node) => node.path);
3935
+ violations.push({
3936
+ key: `cycles:${rule.name}:${cycle.nodes.map((node) => node.id).sort().join("|")}`,
3937
+ rule: rule.name,
3938
+ type: "cycles",
3939
+ severity: rule.severity,
3940
+ message: rule.message ?? cycleMessage(paths),
3941
+ files: paths,
3942
+ cycle: {
3943
+ size: cycle.size,
3944
+ nodes: cycle.nodes.map((node) => ({ ...node }))
3945
+ }
3946
+ });
3947
+ }
3864
3948
  }
3865
- function jsonForScript(value) {
3866
- return JSON.stringify(value).replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
3949
+ function cycleMessage(paths) {
3950
+ const visible = paths.slice(0, 6);
3951
+ const suffix = paths.length > visible.length ? " \u2192 \u2026" : "";
3952
+ return `Ciclo de ${paths.length} ficheros: ${visible.join(" \u2192 ")}${suffix}`;
3867
3953
  }
3868
- function serialise(model) {
3869
- const directoryIndex = /* @__PURE__ */ new Map();
3870
- const directories = [];
3871
- const fileRows = model.files.map((file) => {
3872
- const slash = file.path.lastIndexOf("/");
3873
- const directory = slash >= 0 ? file.path.slice(0, slash) : "";
3874
- let dirIndex = -1;
3875
- if (directory) {
3876
- const known = directoryIndex.get(directory);
3877
- if (known === void 0) {
3878
- dirIndex = directories.length;
3879
- directoryIndex.set(directory, dirIndex);
3880
- directories.push(directory);
3881
- } else {
3882
- dirIndex = known;
3883
- }
3884
- }
3885
- const name = slash >= 0 ? file.path.slice(slash + 1) : file.path;
3886
- return [
3887
- dirIndex,
3888
- name,
3889
- file.section,
3890
- round(file.area, 1),
3891
- round(file.actualArea, 1),
3892
- round(file.risk, 4),
3893
- round(file.complexity, 1),
3894
- round(file.churn, 1),
3895
- ...file.values.map((value) => value === null ? null : round(value, 2))
3896
- ];
3897
- });
3898
- return {
3899
- v: 1,
3900
- meta: {
3901
- project: model.meta.project,
3902
- commit: model.meta.commit,
3903
- checksum: model.meta.checksum,
3904
- api: model.meta.apiVersion,
3905
- root: model.meta.rootName
3906
- },
3907
- area: {
3908
- key: model.areaMetric,
3909
- title: model.areaTitle,
3910
- total: round(model.totalArea, 1)
3911
- },
3912
- lens: model.initialLens,
3913
- metric: model.initialMetric,
3914
- metrics: model.metrics.map((metric) => ({
3915
- key: metric.key,
3916
- title: metric.title,
3917
- min: round(metric.min, 2),
3918
- max: round(metric.max, 2),
3919
- mode: metric.mode,
3920
- dir: metric.direction,
3921
- coverage: metric.coverage
3922
- })),
3923
- sections: model.sections.map((section) => ({
3924
- key: section.key,
3925
- color: section.color,
3926
- files: section.files,
3927
- area: round(section.area, 1)
3928
- })),
3929
- dirs: directories,
3930
- files: fileRows,
3931
- edges: model.edges.flatMap((edge) => [edge.from, edge.to]),
3932
- cycles: model.cycles.flatMap((edge) => [edge.from, edge.to]),
3933
- hot: model.hotspotCount,
3934
- default: model.defaultSelection,
3935
- fileCount: model.fileCount,
3936
- folderCount: model.folderCount,
3937
- totalArea: round(model.totalArea, 1)
3938
- };
3954
+ function compareViolations(left, right) {
3955
+ return compareStrings4(left.key, right.key);
3939
3956
  }
3940
- function renderLensButtons(model) {
3941
- const lenses = [
3942
- { id: "structure", label: "Estructura" },
3943
- { id: "risk", label: "Riesgo" }
3944
- ];
3945
- if (model.metrics.length > 0) lenses.push({ id: "metric", label: "M\xE9trica" });
3946
- return lenses.map(
3947
- (lens) => `<button type="button" class="lens" role="tab" data-lens="${lens.id}" aria-selected="${String(
3948
- model.initialLens === lens.id
3949
- )}">${lens.label}</button>`
3950
- ).join("");
3957
+ function compareStrings4(left, right) {
3958
+ return left < right ? -1 : left > right ? 1 : 0;
3951
3959
  }
3952
- function renderFindings(model) {
3953
- const chips = [];
3954
- if (model.hotspotCount > 0) {
3955
- chips.push(
3956
- `<button type="button" class="finding" data-focus="hotspot"><i class="dot info"></i><b>${formatInt(
3957
- model.hotspotCount
3958
- )}</b> hotspots</button>`
3959
- );
3960
- }
3961
- if (model.cycles.length > 0) {
3962
- chips.push(
3963
- `<button type="button" class="finding" data-focus="cycle"><i class="dot warn"></i><b>${formatInt(
3964
- model.cycles.length
3965
- )}</b> aristas c\xEDclicas</button>`
3966
- );
3967
- }
3968
- chips.push(
3969
- `<span class="finding static"><i class="dot neutral"></i><b>${formatInt(
3970
- model.fileCount
3971
- )}</b> ficheros</span>`
3972
- );
3973
- chips.push(
3974
- `<span class="finding static"><i class="dot neutral"></i><b>${formatInt(
3975
- model.edges.length
3976
- )}</b> dependencias</span>`
3960
+
3961
+ // packages/metrics/src/diff.ts
3962
+ function diffSnapshots(base, head, options = {}) {
3963
+ const top = Math.max(1, Math.floor(options.top ?? 10));
3964
+ const baseIndex = indexSnapshot(base);
3965
+ const headIndex = indexSnapshot(head);
3966
+ const added = difference(headIndex, baseIndex);
3967
+ const removed = difference(baseIndex, headIndex);
3968
+ const changed = changedFiles(baseIndex, headIndex);
3969
+ const edgeDiff = diffEdges(baseIndex, headIndex);
3970
+ const baseCycles = cycleGroups(baseIndex);
3971
+ const headCycles = cycleGroups(headIndex);
3972
+ const introducedCycles = cycleDifference(headCycles, baseCycles);
3973
+ const fixedCycles = cycleDifference(baseCycles, headCycles);
3974
+ const hotspotMetric = chooseHotspotMetric(
3975
+ baseIndex,
3976
+ headIndex,
3977
+ options.hotspotMetric
3977
3978
  );
3978
- return `${chips.join("")}<span class="spacer"></span><span id="search-status" class="search-status" aria-live="polite"></span><span class="run-meta">${escapeHtml(
3979
- model.areaTitle
3980
- )} \xB7 ${formatInt(model.metrics.length)} m\xE9tricas \xB7 cc.json ${escapeHtml(
3981
- model.meta.apiVersion
3982
- )}</span>`;
3983
- }
3984
- function metricByKey(model, key) {
3985
- return key ? model.metrics.find((metric) => metric.key === key) : void 0;
3979
+ const hotspots = hotspotMetric ? buildHotspots(baseIndex, headIndex, added, changed, hotspotMetric, top) : [];
3980
+ const rules = options.rules ? evaluateRules(
3981
+ ruleInput(baseIndex, [...baseCycles.values()].map((group) => group.component)),
3982
+ ruleInput(headIndex, [...headCycles.values()].map((group) => group.component)),
3983
+ options.rules
3984
+ ) : emptyRuleReport();
3985
+ return {
3986
+ schemaVersion: "1.0",
3987
+ base: summarize(baseIndex),
3988
+ head: summarize(headIndex),
3989
+ summary: {
3990
+ addedFiles: added.length,
3991
+ removedFiles: removed.length,
3992
+ changedFiles: changed.length,
3993
+ addedEdges: edgeDiff.added.length,
3994
+ removedEdges: edgeDiff.removed.length,
3995
+ cyclicEdgesAdded: edgeDiff.cyclicAdded.length,
3996
+ cyclicEdgesRemoved: edgeDiff.cyclicRemoved.length,
3997
+ introducedCycles: introducedCycles.length,
3998
+ fixedCycles: fixedCycles.length,
3999
+ hotspots: hotspots.length
4000
+ },
4001
+ hotspotMetric,
4002
+ rules,
4003
+ files: { added, removed, changed },
4004
+ edges: edgeDiff,
4005
+ cycles: { introduced: introducedCycles, fixed: fixedCycles },
4006
+ hotspots
4007
+ };
3986
4008
  }
3987
- function renderLegend(model) {
3988
- if (model.initialLens === "structure") {
3989
- const items = model.sections.slice(0, 6).map(
3990
- (section) => `<span><i style="background:${section.color}"></i>${escapeHtml(section.key)}</span>`
3991
- ).join("");
3992
- return `<span>Carpeta</span><span class="sws">${items}</span>`;
4009
+ function indexSnapshot(cc) {
4010
+ const paths = /* @__PURE__ */ new Map();
4011
+ for (const root of cc.files) {
4012
+ walkFiles([root], (entry) => {
4013
+ paths.set(entry.node.id, relativePath(root, entry.path, entry.node));
4014
+ });
3993
4015
  }
3994
- if (model.initialLens === "metric" && model.initialMetric) {
3995
- const metric = metricByKey(model, model.initialMetric);
3996
- if (metric) {
3997
- const direction = metric.direction === -1 ? " \xB7 menos es mejor" : metric.direction === 1 ? " \xB7 m\xE1s es mejor" : "";
3998
- return `<span>${escapeHtml(metric.title)}${direction}</span><span class="mono">${escapeHtml(
3999
- formatCompact(metric.min)
4000
- )}</span><span class="bar" style="background:${METRIC_GRADIENT}"></span><span class="mono">${escapeHtml(
4001
- formatCompact(metric.max)
4002
- )}</span>`;
4016
+ const nodes = [...paths.keys()].sort(
4017
+ (left, right) => compareStrings5(paths.get(left) ?? left, paths.get(right) ?? right)
4018
+ );
4019
+ const metrics = cc.lenses?.metrics?.attributes ?? {};
4020
+ const edges = /* @__PURE__ */ new Map();
4021
+ for (const edge of cc.lenses?.dependency?.edges ?? []) {
4022
+ const key = edgeKey(edge.fromId, edge.toId);
4023
+ const weight = finiteNumber(edge.attributes?.weight) ?? 1;
4024
+ const typeOnly = finiteNumber(edge.attributes?.typeOnly) ?? 0;
4025
+ const existing = edges.get(key);
4026
+ if (existing) {
4027
+ existing.weight += weight;
4028
+ existing.typeOnly += typeOnly;
4029
+ existing.isCyclic = existing.isCyclic || edge.isCyclic === true;
4030
+ } else {
4031
+ edges.set(key, {
4032
+ fromId: edge.fromId,
4033
+ toId: edge.toId,
4034
+ weight,
4035
+ typeOnly,
4036
+ isCyclic: edge.isCyclic === true
4037
+ });
4003
4038
  }
4004
4039
  }
4005
- return `<span>Riesgo (0\u2013100)</span><span class="mono">0</span><span class="bar" style="background:${RISK_GRADIENT}"></span><span class="mono">100</span>`;
4006
- }
4007
- function renderMetricOptions(model) {
4008
- return model.metrics.map(
4009
- (metric) => `<option value="${escapeAttr(metric.key)}"${metric.key === model.initialMetric ? " selected" : ""}>${escapeHtml(metric.title)}</option>`
4010
- ).join("");
4011
- }
4012
- function renderInspectorSummary(model) {
4013
- const hotspots = [...model.files].sort((a, b) => b.risk - a.risk || a.path.localeCompare(b.path)).slice(0, 5);
4014
- return `<div class="summary">
4015
- <div class="sub">Proyecto</div>
4016
- <div class="big">${formatInt(model.fileCount)}</div>
4017
- <div class="sub">ficheros \xB7 ${formatInt(model.folderCount)} carpetas</div>
4018
- <p>${formatInt(model.totalArea)} de ${escapeHtml(
4019
- model.areaTitle.toLowerCase()
4020
- )} \xB7 ${formatInt(model.metrics.length)} m\xE9tricas \xB7 ${formatInt(
4021
- model.hotspotCount
4022
- )} hotspots.</p>
4023
- <h4>Mayor riesgo</h4>
4024
- <ul class="deps">${hotspots.map(
4025
- (file) => `<li data-file="${file.index}"><i class="sw" style="background:${riskColor(
4026
- file.risk
4027
- )}"></i><span>${escapeHtml(file.path)}</span><em>${Math.round(
4028
- file.risk * 100
4029
- )}</em></li>`
4030
- ).join("")}</ul>
4031
- <p>Selecciona un bloque para ver sus m\xE9tricas y sus dependencias. Clic en la cabecera de una carpeta para enfocarla.</p>
4032
- </div>`;
4040
+ return { cc, paths, nodes, metrics, edges };
4033
4041
  }
4034
- function renderHtml(model) {
4035
- const project = escapeHtml(model.meta.project);
4036
- const title = escapeHtml(model.meta.title);
4037
- const commit = model.meta.commit ? model.meta.commit.slice(0, 7) : "";
4038
- const checksum = model.meta.checksum ? model.meta.checksum.slice(0, 8) : "";
4039
- const badge = commit || checksum || `cc.json ${model.meta.apiVersion}`;
4040
- const payload = jsonForScript(serialise(model));
4041
- const metricPicker = model.metrics.length > 0 ? `<label class="metric-pick" id="metric-pick"${model.initialLens === "metric" ? "" : " hidden"}><span>M\xE9trica</span><select id="metric-select">${renderMetricOptions(
4042
- model
4043
- )}</select></label>` : "";
4044
- const crumbs = model.meta.rootName ? `${project} <span class="sep">/</span> ${escapeHtml(model.meta.rootName)}` : project;
4045
- return `<!doctype html>
4046
- <html lang="es">
4047
- <head>
4048
- <meta charset="utf-8">
4049
- <meta name="viewport" content="width=device-width, initial-scale=1">
4050
- <meta name="color-scheme" content="light">
4051
- <meta name="generator" content="CodArch">
4052
- <title>CodArch \xB7 ${title}</title>
4053
- <style>${STYLES}</style>
4054
- </head>
4055
- <body>
4056
- <div class="app">
4057
- <header>
4058
- <div class="brand">CodArch</div>
4059
- <div class="repo">
4060
- <b>${title}</b>
4061
- ${model.meta.rootName && model.meta.rootName !== model.meta.project ? `<span>${escapeHtml(model.meta.rootName)}</span>` : ""}
4062
- <span class="commit">${escapeHtml(badge)}</span>
4063
- </div>
4064
- <div class="spacer"></div>
4065
- <nav class="lenses" role="tablist" aria-label="Lentes">${renderLensButtons(model)}</nav>
4066
- ${metricPicker}
4067
- <label class="search">
4068
- <svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true">
4069
- <circle cx="7" cy="7" r="4.5" stroke="#8d877d" stroke-width="1.4"/>
4070
- <path d="M10.5 10.5 14 14" stroke="#8d877d" stroke-width="1.4"/>
4071
- </svg>
4072
- <input id="q" type="search" placeholder="Buscar fichero\u2026" autocomplete="off" aria-label="Buscar fichero">
4073
- </label>
4074
- </header>
4075
-
4076
- <div class="findings">${renderFindings(model)}</div>
4077
-
4078
- <main class="map-wrap">
4079
- <div class="map" id="map">${renderSvg(model)}</div>
4080
- <div class="tooltip" id="tooltip"></div>
4081
- </main>
4082
-
4083
- <aside id="inspector">${renderInspectorSummary(model)}</aside>
4084
-
4085
- <footer>
4086
- <span class="crumbs" id="crumbs">${crumbs}</span>
4087
- <span class="hint">clic para fijar \xB7 esc para limpiar</span>
4088
- <span class="legend" id="legend">${renderLegend(model)}</span>
4089
- </footer>
4090
- </div>
4091
- <script id="codarch-data" type="application/json">${payload}</script>
4092
- <script>${CLIENT}</script>
4093
- </body>
4094
- </html>`;
4042
+ function relativePath(root, fullPath, leaf) {
4043
+ if (fullPath === root.name) return leaf.name;
4044
+ const prefix = `${root.name}/`;
4045
+ return fullPath.startsWith(prefix) ? fullPath.slice(prefix.length) : fullPath;
4095
4046
  }
4096
-
4097
- // apps/cli/src/io.ts
4098
- import fs6 from "node:fs";
4099
- import zlib from "node:zlib";
4100
- function readJsonFile2(file) {
4101
- const buffer = fs6.readFileSync(file);
4102
- const isGzip = buffer.length > 2 && buffer[0] === 31 && buffer[1] === 139;
4103
- const raw = isGzip ? zlib.gunzipSync(buffer) : buffer;
4104
- const text = raw.toString("utf8");
4105
- try {
4106
- return { json: JSON.parse(text), bytes: buffer.length };
4107
- } catch (error) {
4108
- const message = error instanceof Error ? error.message : String(error);
4109
- throw new Error(`no es JSON v\xE1lido: ${message}`);
4047
+ function summarize(index) {
4048
+ let folders = 0;
4049
+ walkTree(index.cc.files, (entry) => {
4050
+ if (entry.node.type === "Folder" && entry.depth > 0) folders++;
4051
+ });
4052
+ return {
4053
+ projectName: index.cc.meta.projectName ?? "",
4054
+ checksum: index.cc.meta.checksum ?? "",
4055
+ files: index.paths.size,
4056
+ folders,
4057
+ edges: index.edges.size
4058
+ };
4059
+ }
4060
+ function difference(present, absent) {
4061
+ const files = [];
4062
+ for (const [id, path9] of present.paths) {
4063
+ if (absent.paths.has(id)) continue;
4064
+ files.push({
4065
+ id,
4066
+ path: path9,
4067
+ metrics: sortedMetrics(present.metrics[id])
4068
+ });
4110
4069
  }
4070
+ return files.sort(compareFiles);
4111
4071
  }
4112
- function formatBytes(bytes) {
4113
- if (bytes < 1024) return `${bytes} B`;
4114
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
4115
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
4072
+ function changedFiles(base, head) {
4073
+ const changed = [];
4074
+ for (const [id, path9] of head.paths) {
4075
+ if (!base.paths.has(id)) continue;
4076
+ const baseValues = base.metrics[id] ?? {};
4077
+ const headValues = head.metrics[id] ?? {};
4078
+ const keys = /* @__PURE__ */ new Set([...Object.keys(baseValues), ...Object.keys(headValues)]);
4079
+ const changes = [];
4080
+ for (const metric of [...keys].sort(compareStrings5)) {
4081
+ const baseValue = finiteNumber(baseValues[metric]);
4082
+ const headValue = finiteNumber(headValues[metric]);
4083
+ if (baseValue === null && headValue === null) continue;
4084
+ if (baseValue !== null && headValue !== null && baseValue === headValue) {
4085
+ continue;
4086
+ }
4087
+ changes.push({
4088
+ metric,
4089
+ base: baseValue,
4090
+ head: headValue,
4091
+ delta: baseValue !== null && headValue !== null ? headValue - baseValue : null
4092
+ });
4093
+ }
4094
+ if (changes.length > 0) changed.push({ id, path: path9, changes });
4095
+ }
4096
+ return changed.sort(compareChangedFiles);
4116
4097
  }
4117
-
4118
- // apps/cli/src/commands/analyze.ts
4119
- var numberFormat = new Intl.NumberFormat("es-ES");
4120
- function num(value) {
4121
- return numberFormat.format(value);
4098
+ function diffEdges(base, head) {
4099
+ const added = [];
4100
+ const removed = [];
4101
+ for (const [key, edge] of head.edges) {
4102
+ if (!base.edges.has(key)) added.push(edgeChange(head, edge));
4103
+ }
4104
+ for (const [key, edge] of base.edges) {
4105
+ if (!head.edges.has(key)) removed.push(edgeChange(base, edge));
4106
+ }
4107
+ added.sort(compareEdgeChanges);
4108
+ removed.sort(compareEdgeChanges);
4109
+ return {
4110
+ added,
4111
+ removed,
4112
+ cyclicAdded: added.filter((edge) => edge.isCyclic),
4113
+ cyclicRemoved: removed.filter((edge) => edge.isCyclic)
4114
+ };
4122
4115
  }
4123
- function field(label, value) {
4124
- return ` ${label.padEnd(12)} ${value}`;
4116
+ function edgeChange(index, edge) {
4117
+ return {
4118
+ fromId: edge.fromId,
4119
+ toId: edge.toId,
4120
+ from: index.paths.get(edge.fromId) ?? edge.fromId,
4121
+ to: index.paths.get(edge.toId) ?? edge.toId,
4122
+ weight: edge.weight,
4123
+ typeOnly: edge.typeOnly,
4124
+ isCyclic: edge.isCyclic
4125
+ };
4125
4126
  }
4126
- function runAnalyze(options) {
4127
- const root = options.root ?? ".";
4128
- let result;
4129
- try {
4130
- result = analyzeRepository(root, {
4131
- ignore: options.ignore,
4132
- projectName: options.projectName,
4133
- git: options.git
4134
- });
4135
- } catch (error) {
4136
- console.error(`no se pudo analizar ${root}: ${error.message}`);
4137
- return 1;
4127
+ function cycleGroups(index) {
4128
+ const adjacency = /* @__PURE__ */ new Map();
4129
+ const reverse = /* @__PURE__ */ new Map();
4130
+ for (const node of index.nodes) {
4131
+ adjacency.set(node, []);
4132
+ reverse.set(node, []);
4138
4133
  }
4139
- let cc;
4140
- try {
4141
- cc = parseCcJson(result.snapshot);
4142
- } catch (error) {
4143
- if (error instanceof CcJsonError) {
4144
- console.error(`el snapshot generado no es cc.json v\xE1lido: ${error.message}`);
4145
- return 1;
4146
- }
4147
- throw error;
4134
+ for (const edge of index.edges.values()) {
4135
+ if (!index.paths.has(edge.fromId) || !index.paths.has(edge.toId)) continue;
4136
+ adjacency.get(edge.fromId)?.push(edge.toId);
4137
+ reverse.get(edge.toId)?.push(edge.fromId);
4148
4138
  }
4149
- const outDirectory = path5.resolve(
4150
- options.out ?? path5.join(path5.resolve(root), ".codarch")
4151
- );
4152
- const snapshotPath = path5.join(outDirectory, "snapshot.cc.json");
4153
- const mapPath = path5.join(outDirectory, "map.html");
4154
- try {
4155
- fs7.mkdirSync(outDirectory, { recursive: true });
4156
- fs7.writeFileSync(
4157
- snapshotPath,
4158
- `${JSON.stringify(cc, null, 2)}
4159
- `,
4160
- "utf8"
4161
- );
4162
- } catch (error) {
4163
- console.error(
4164
- `no se pudo escribir ${snapshotPath}: ${error.message}`
4165
- );
4166
- return 1;
4139
+ for (const [node, neighbors] of adjacency) {
4140
+ adjacency.set(node, [...new Set(neighbors)].sort(compareStrings5));
4167
4141
  }
4168
- let mapBytes = 0;
4169
- let mapError = null;
4170
- if (result.files > 0) {
4171
- try {
4172
- const model = buildRenderModel(cc, {
4173
- areaMetric: "rloc",
4174
- color: "risk",
4175
- title: options.title
4176
- });
4177
- const html = renderHtml(model);
4178
- fs7.writeFileSync(mapPath, html, "utf8");
4179
- mapBytes = Buffer.byteLength(html, "utf8");
4180
- } catch (error) {
4181
- mapError = error instanceof RenderError ? error.message : error.message;
4142
+ for (const [node, neighbors] of reverse) {
4143
+ reverse.set(node, [...new Set(neighbors)].sort(compareStrings5));
4144
+ }
4145
+ const visited = /* @__PURE__ */ new Set();
4146
+ const order = [];
4147
+ for (const start of index.nodes) {
4148
+ if (visited.has(start)) continue;
4149
+ visited.add(start);
4150
+ const stack = [
4151
+ { node: start, neighbors: adjacency.get(start) ?? [], next: 0 }
4152
+ ];
4153
+ while (stack.length > 0) {
4154
+ const frame = stack[stack.length - 1];
4155
+ if (frame.next < frame.neighbors.length) {
4156
+ const next = frame.neighbors[frame.next];
4157
+ frame.next++;
4158
+ if (!visited.has(next)) {
4159
+ visited.add(next);
4160
+ stack.push({
4161
+ node: next,
4162
+ neighbors: adjacency.get(next) ?? [],
4163
+ next: 0
4164
+ });
4165
+ }
4166
+ } else {
4167
+ order.push(frame.node);
4168
+ stack.pop();
4169
+ }
4182
4170
  }
4183
- } else {
4184
- mapError = "no hay ficheros TypeScript/JavaScript que dibujar";
4185
4171
  }
4186
- const snapshotBytes = Buffer.byteLength(
4187
- fs7.readFileSync(snapshotPath),
4188
- "utf8"
4189
- );
4190
- const lines = [];
4191
- lines.push("");
4192
- lines.push(" CodArch \xB7 an\xE1lisis");
4193
- lines.push("");
4194
- lines.push(field("ra\xEDz", path5.resolve(root)));
4195
- lines.push(field("snapshot", `${snapshotPath} (${formatBytes(snapshotBytes)})`));
4196
- if (mapError) {
4197
- lines.push(field("mapa", `omitido (${mapError})`));
4198
- } else {
4199
- lines.push(field("mapa", `${mapPath} (${formatBytes(mapBytes)})`));
4172
+ const assigned = /* @__PURE__ */ new Set();
4173
+ const groups = /* @__PURE__ */ new Map();
4174
+ for (let position = order.length - 1; position >= 0; position--) {
4175
+ const start = order[position];
4176
+ if (assigned.has(start)) continue;
4177
+ assigned.add(start);
4178
+ const component = [];
4179
+ const stack = [start];
4180
+ while (stack.length > 0) {
4181
+ const node = stack.pop();
4182
+ component.push(node);
4183
+ for (const next of reverse.get(node) ?? []) {
4184
+ if (assigned.has(next)) continue;
4185
+ assigned.add(next);
4186
+ stack.push(next);
4187
+ }
4188
+ }
4189
+ const hasSelfLoop = component.some(
4190
+ (node) => (adjacency.get(node) ?? []).includes(node)
4191
+ );
4192
+ if (component.length < 2 && !hasSelfLoop) continue;
4193
+ const nodes = component.map((id) => ({
4194
+ id,
4195
+ path: index.paths.get(id) ?? id
4196
+ })).sort((left, right) => {
4197
+ return compareStrings5(left.path, right.path) || compareStrings5(left.id, right.id);
4198
+ });
4199
+ const key = [...component].sort(compareStrings5).join("\0");
4200
+ groups.set(key, { key, component: { size: nodes.length, nodes } });
4200
4201
  }
4201
- lines.push(
4202
- field(
4203
- "estructura",
4204
- `${num(result.files)} ficheros \xB7 ${num(result.folders)} carpetas \xB7 ${num(
4205
- result.edges
4206
- )} aristas \xB7 ${num(result.cycles.length)} ciclos`
4207
- )
4208
- );
4209
- lines.push(
4210
- field(
4211
- "imports",
4212
- `${num(result.externalImports)} externos \xB7 ${num(
4213
- result.builtinImports
4214
- )} builtins \xB7 ${num(result.typeOnlyImports)} type-only \xB7 ${num(
4215
- result.unresolvedImports
4216
- )} sin resolver`
4217
- )
4218
- );
4219
- const migrationLanguages = Object.entries(result.migration.languages).map(([language, stats]) => `${language} ${num(stats?.rloc ?? 0)}`).join(" \xB7 ");
4220
- lines.push(
4221
- field(
4222
- "migraci\xF3n",
4223
- `${migrationLanguages || "\u2014"} \xB7 ${num(
4224
- result.migration.bridgeFiles
4225
- )} puentes \xB7 ${num(result.migration.ffiEdges)} FFI \xB7 deuda ${num(
4226
- result.migration.migrationDebt
4227
- )}`
4228
- )
4229
- );
4230
- lines.push(field("tiempo", `${num(result.durationMs)} ms`));
4231
- lines.push(
4232
- field(
4233
- "historial",
4234
- result.git.enabled ? `${num(result.git.commits)} commits \xB7 ${num(
4235
- result.git.authors
4236
- )} autores${result.git.shallow ? " \xB7 shallow" : ""}` : "sin git; se omite churn"
4237
- )
4238
- );
4239
- for (const warning of result.warnings.slice(0, 10)) {
4240
- lines.push(field("aviso", warning));
4202
+ return groups;
4203
+ }
4204
+ function cycleDifference(present, absent) {
4205
+ const cycles = [];
4206
+ for (const [key, group] of present) {
4207
+ if (!absent.has(key)) cycles.push(group.component);
4208
+ }
4209
+ return cycles.sort(compareCycles);
4210
+ }
4211
+ function ruleInput(index, cycles) {
4212
+ const files = index.nodes.map((id) => ({
4213
+ id,
4214
+ path: index.paths.get(id) ?? id,
4215
+ metrics: sortedMetrics(index.metrics[id])
4216
+ }));
4217
+ const edges = [];
4218
+ for (const edge of index.edges.values()) {
4219
+ if (!index.paths.has(edge.fromId) || !index.paths.has(edge.toId)) {
4220
+ continue;
4221
+ }
4222
+ edges.push({
4223
+ fromId: edge.fromId,
4224
+ toId: edge.toId,
4225
+ from: index.paths.get(edge.fromId) ?? edge.fromId,
4226
+ to: index.paths.get(edge.toId) ?? edge.toId
4227
+ });
4241
4228
  }
4242
- if (result.warnings.length > 10) {
4243
- lines.push(field("aviso", `y ${num(result.warnings.length - 10)} m\xE1s`));
4229
+ return { files, edges, cycles };
4230
+ }
4231
+ function chooseHotspotMetric(base, head, requested) {
4232
+ if (requested) return requested;
4233
+ for (const metric of ["maxComplexity", "rloc"]) {
4234
+ if (hasMetric(base, metric) || hasMetric(head, metric)) return metric;
4244
4235
  }
4245
- for (const issue of result.unresolved.slice(0, 5)) {
4246
- lines.push(
4247
- field(
4248
- "sin resolver",
4249
- `${issue.file}:${issue.line} ${issue.specifier} (${issue.reason})`
4250
- )
4251
- );
4236
+ return null;
4237
+ }
4238
+ function hasMetric(index, metric) {
4239
+ for (const values of Object.values(index.metrics)) {
4240
+ if (finiteNumber(values[metric]) !== null) return true;
4252
4241
  }
4253
- if (result.unresolved.length > 5) {
4254
- lines.push(
4255
- field("sin resolver", `y ${num(result.unresolved.length - 5)} m\xE1s`)
4256
- );
4242
+ return false;
4243
+ }
4244
+ function buildHotspots(base, head, added, changed, metric, top) {
4245
+ const addedIds = new Set(added.map((file) => file.id));
4246
+ const candidates = [...added, ...changed];
4247
+ const hotspots = [];
4248
+ for (const file of candidates) {
4249
+ const baseValue = finiteNumber(base.metrics[file.id]?.[metric]);
4250
+ const headValue = finiteNumber(head.metrics[file.id]?.[metric]);
4251
+ if (headValue === null) continue;
4252
+ const before = baseValue ?? 0;
4253
+ const delta = headValue - before;
4254
+ if (delta <= 0) continue;
4255
+ hotspots.push({
4256
+ id: file.id,
4257
+ path: file.path,
4258
+ metric,
4259
+ base: before,
4260
+ head: headValue,
4261
+ delta,
4262
+ change: addedIds.has(file.id) ? "added" : "changed"
4263
+ });
4257
4264
  }
4258
- lines.push("");
4259
- console.log(lines.join("\n"));
4260
- return 0;
4265
+ return hotspots.sort(
4266
+ (left, right) => right.delta - left.delta || right.head - left.head || compareStrings5(left.path, right.path)
4267
+ ).slice(0, top);
4268
+ }
4269
+ function sortedMetrics(values) {
4270
+ const sorted = {};
4271
+ if (!values) return sorted;
4272
+ for (const key of Object.keys(values).sort(compareStrings5)) {
4273
+ const value = finiteNumber(values[key]);
4274
+ if (value !== null) sorted[key] = value;
4275
+ }
4276
+ return sorted;
4277
+ }
4278
+ function finiteNumber(value) {
4279
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
4280
+ }
4281
+ function edgeKey(fromId, toId) {
4282
+ return `${fromId}\0${toId}`;
4283
+ }
4284
+ function compareFiles(left, right) {
4285
+ return compareStrings5(left.path, right.path) || compareStrings5(left.id, right.id);
4286
+ }
4287
+ function compareChangedFiles(left, right) {
4288
+ return compareStrings5(left.path, right.path) || compareStrings5(left.id, right.id);
4289
+ }
4290
+ function compareEdgeChanges(left, right) {
4291
+ return compareStrings5(left.from, right.from) || compareStrings5(left.to, right.to) || compareStrings5(left.fromId, right.fromId) || compareStrings5(left.toId, right.toId);
4292
+ }
4293
+ function compareCycles(left, right) {
4294
+ return compareStrings5(
4295
+ left.nodes[0]?.path ?? "",
4296
+ right.nodes[0]?.path ?? ""
4297
+ ) || left.size - right.size;
4298
+ }
4299
+ function compareStrings5(left, right) {
4300
+ return left < right ? -1 : left > right ? 1 : 0;
4261
4301
  }
4262
4302
 
4263
4303
  // apps/cli/src/commands/diff.ts
4264
- import fs8 from "node:fs";
4265
- import path6 from "node:path";
4266
4304
  var numberFormat2 = new Intl.NumberFormat("es-ES");
4267
4305
  function num2(value) {
4268
4306
  return numberFormat2.format(value);
@@ -4504,7 +4542,7 @@ function runInspect(options) {
4504
4542
  const metric = options.metric ?? defaultMetric(stats);
4505
4543
  const top = metric ? topByMetric(cc, metric, options.top) : [];
4506
4544
  const rootPrefix = cc.files.length === 1 ? `${cc.files[0]?.name}/` : "";
4507
- const displayPath = (path8) => rootPrefix && path8.startsWith(rootPrefix) ? path8.slice(rootPrefix.length) : path8;
4545
+ const displayPath = (path9) => rootPrefix && path9.startsWith(rootPrefix) ? path9.slice(rootPrefix.length) : path9;
4508
4546
  if (options.metric && !stats.metricCoverage.some((entry) => entry.name === options.metric)) {
4509
4547
  console.error(`la m\xE9trica "${options.metric}" no existe en este fichero`);
4510
4548
  return 1;
@@ -4702,8 +4740,440 @@ function runRender(options) {
4702
4740
  return 0;
4703
4741
  }
4704
4742
 
4743
+ // apps/cli/src/commands/serve.ts
4744
+ import fs10 from "node:fs";
4745
+ import http from "node:http";
4746
+ import path8 from "node:path";
4747
+ import { fileURLToPath } from "node:url";
4748
+ var MIME = {
4749
+ ".html": "text/html; charset=utf-8",
4750
+ ".js": "text/javascript; charset=utf-8",
4751
+ ".css": "text/css; charset=utf-8",
4752
+ ".json": "application/json; charset=utf-8",
4753
+ ".svg": "image/svg+xml",
4754
+ ".png": "image/png",
4755
+ ".ico": "image/x-icon",
4756
+ ".woff": "font/woff",
4757
+ ".woff2": "font/woff2",
4758
+ ".wasm": "application/wasm"
4759
+ };
4760
+ var IGNORED_WATCH = /(^|\/)(node_modules|\.git|\.codarch|dist|coverage|\.next|out|target)(\/|$)/;
4761
+ var IGNORED_BROWSE = /* @__PURE__ */ new Set([
4762
+ ".git",
4763
+ "node_modules",
4764
+ ".codarch",
4765
+ "dist",
4766
+ "coverage",
4767
+ ".next",
4768
+ "out",
4769
+ "target"
4770
+ ]);
4771
+ function runServe(options) {
4772
+ const root = path8.resolve(options.root ?? ".");
4773
+ const webRoot = options.web === false ? null : findWebRoot();
4774
+ if (options.web !== false && !webRoot) {
4775
+ console.error(
4776
+ "no se encontr\xF3 el build web. Ejecuta `npm run web:build` o usa el paquete publicado."
4777
+ );
4778
+ return 1;
4779
+ }
4780
+ let state = analyzeState(root, options.watch);
4781
+ let watcher = null;
4782
+ let timer;
4783
+ const clients = /* @__PURE__ */ new Set();
4784
+ const startWatcher = () => {
4785
+ if (watcher) return null;
4786
+ try {
4787
+ watcher = fs10.watch(state.root, { recursive: true }, (_event, filename) => {
4788
+ if (!filename) return;
4789
+ const relative = filename.toString().replace(/\\/g, "/");
4790
+ if (IGNORED_WATCH.test(relative)) return;
4791
+ scheduleRefresh();
4792
+ });
4793
+ return null;
4794
+ } catch (error) {
4795
+ watcher = null;
4796
+ return error.message;
4797
+ }
4798
+ };
4799
+ const stopWatcher = () => {
4800
+ if (timer) {
4801
+ clearTimeout(timer);
4802
+ timer = void 0;
4803
+ }
4804
+ watcher?.close();
4805
+ watcher = null;
4806
+ };
4807
+ const closeClients = () => {
4808
+ for (const client of clients) client.end();
4809
+ clients.clear();
4810
+ };
4811
+ const refresh = () => {
4812
+ if (!state.watch) return;
4813
+ try {
4814
+ const next = analyzeState(state.root, state.watch);
4815
+ state = next;
4816
+ broadcast(clients, "update", {
4817
+ checksum: state.checksum,
4818
+ files: state.files
4819
+ });
4820
+ console.log(
4821
+ ` \u21BB ${state.files} ficheros \xB7 checksum ${state.checksum.slice(0, 8)}`
4822
+ );
4823
+ } catch (error) {
4824
+ const message = error.message;
4825
+ console.error(` no se pudo reanalizar: ${message}`);
4826
+ broadcast(clients, "error", { message });
4827
+ }
4828
+ };
4829
+ const scheduleRefresh = () => {
4830
+ if (timer) clearTimeout(timer);
4831
+ timer = setTimeout(() => {
4832
+ timer = void 0;
4833
+ refresh();
4834
+ }, 350);
4835
+ };
4836
+ const setWatch = (enabled) => {
4837
+ if (enabled === state.watch) return;
4838
+ if (enabled) {
4839
+ const error = startWatcher();
4840
+ if (error) throw new Error(`watch no disponible: ${error}`);
4841
+ state = { ...state, watch: true };
4842
+ } else {
4843
+ stopWatcher();
4844
+ closeClients();
4845
+ state = { ...state, watch: false };
4846
+ }
4847
+ broadcast(clients, "watch", { watch: state.watch });
4848
+ };
4849
+ const switchRoot = (nextRoot) => {
4850
+ const resolved = path8.resolve(nextRoot);
4851
+ const stat = fs10.statSync(resolved);
4852
+ if (!stat.isDirectory()) throw new Error("la ruta no es un directorio");
4853
+ const next = analyzeState(resolved, state.watch);
4854
+ stopWatcher();
4855
+ state = next;
4856
+ if (state.watch) {
4857
+ const error = startWatcher();
4858
+ if (error) {
4859
+ state = { ...state, watch: false };
4860
+ console.error(`watch no disponible en ${resolved}: ${error}`);
4861
+ }
4862
+ }
4863
+ broadcast(clients, "update", {
4864
+ checksum: state.checksum,
4865
+ files: state.files
4866
+ });
4867
+ console.log(
4868
+ ` \u2192 ${state.root} \xB7 ${state.files} ficheros \xB7 ${state.watch ? "live" : "servido"}`
4869
+ );
4870
+ return state;
4871
+ };
4872
+ const meta = () => ({
4873
+ root: path8.basename(state.root),
4874
+ rootPath: state.root,
4875
+ watch: state.watch,
4876
+ files: state.files,
4877
+ checksum: state.checksum,
4878
+ warnings: state.warnings,
4879
+ updatedAt: state.updatedAt
4880
+ });
4881
+ const server = http.createServer(async (request, response) => {
4882
+ try {
4883
+ const host = request.headers.host ?? `${options.host}:${options.port}`;
4884
+ const url = new URL(request.url ?? "/", `http://${host}`);
4885
+ const pathname = url.pathname;
4886
+ if (request.method === "POST") {
4887
+ if (!originAllowed(request, options)) {
4888
+ sendError(response, 403, "origen no permitido");
4889
+ return;
4890
+ }
4891
+ if (pathname === "/api/root") {
4892
+ const body = await readBody(request);
4893
+ const nextRoot = typeof body === "object" && body !== null && typeof body.path === "string" ? body.path : "";
4894
+ if (!nextRoot.trim()) {
4895
+ sendError(response, 400, "falta la ruta de la carpeta");
4896
+ return;
4897
+ }
4898
+ const next = switchRoot(nextRoot);
4899
+ sendJson(response, {
4900
+ ...meta(),
4901
+ rootPath: next.root,
4902
+ watch: next.watch
4903
+ });
4904
+ return;
4905
+ }
4906
+ if (pathname === "/api/watch") {
4907
+ const body = await readBody(request);
4908
+ const enabled = typeof body === "object" && body !== null && body.enabled === true;
4909
+ try {
4910
+ setWatch(enabled);
4911
+ } catch (error) {
4912
+ sendError(response, 400, error.message);
4913
+ return;
4914
+ }
4915
+ sendJson(response, meta());
4916
+ return;
4917
+ }
4918
+ sendError(response, 404, "endpoint desconocido");
4919
+ return;
4920
+ }
4921
+ if (request.method !== "GET" && request.method !== "HEAD") {
4922
+ response.writeHead(405, { Allow: "GET, HEAD, POST" });
4923
+ response.end();
4924
+ return;
4925
+ }
4926
+ if (pathname === "/api/browse") {
4927
+ try {
4928
+ sendJson(response, browseDirectories(state.root, url.searchParams.get("path")));
4929
+ } catch (error) {
4930
+ sendError(response, 400, error.message);
4931
+ }
4932
+ return;
4933
+ }
4934
+ if (pathname === "/data/snapshot.cc.json") {
4935
+ sendJson(response, state.snapshot);
4936
+ return;
4937
+ }
4938
+ if (pathname === "/data/meta.json") {
4939
+ sendJson(response, meta());
4940
+ return;
4941
+ }
4942
+ if (pathname === "/data/report.json") {
4943
+ const report = readReport(state.root);
4944
+ if (report === null) {
4945
+ sendError(response, 404, "no hay .codarch/report.json");
4946
+ return;
4947
+ }
4948
+ sendJson(response, report);
4949
+ return;
4950
+ }
4951
+ if (pathname === "/events") {
4952
+ if (!state.watch) {
4953
+ response.writeHead(204);
4954
+ response.end();
4955
+ return;
4956
+ }
4957
+ response.writeHead(200, {
4958
+ "Content-Type": "text/event-stream",
4959
+ "Cache-Control": "no-store",
4960
+ Connection: "keep-alive",
4961
+ "X-Accel-Buffering": "no"
4962
+ });
4963
+ response.write(
4964
+ `event: ready
4965
+ data: ${JSON.stringify({
4966
+ checksum: state.checksum,
4967
+ files: state.files
4968
+ })}
4969
+
4970
+ `
4971
+ );
4972
+ clients.add(response);
4973
+ request.on("close", () => clients.delete(response));
4974
+ return;
4975
+ }
4976
+ if (!webRoot) {
4977
+ sendError(response, 404, "servidor en modo API: la web la sirve otro proceso");
4978
+ return;
4979
+ }
4980
+ serveStatic(webRoot, pathname, request.method === "HEAD", response);
4981
+ } catch (error) {
4982
+ if (!response.headersSent) {
4983
+ sendError(response, 500, error.message);
4984
+ } else {
4985
+ response.end();
4986
+ }
4987
+ }
4988
+ });
4989
+ if (state.watch) {
4990
+ const error = startWatcher();
4991
+ if (error) {
4992
+ state = { ...state, watch: false };
4993
+ console.error(`watch no disponible; se sirve el an\xE1lisis inicial: ${error}`);
4994
+ }
4995
+ }
4996
+ server.on("error", (error) => {
4997
+ console.error(`no se pudo escuchar en ${options.host}:${options.port}: ${error.message}`);
4998
+ process.exitCode = 1;
4999
+ });
5000
+ server.listen(options.port, options.host, () => {
5001
+ const url = `http://${options.host}:${options.port}`;
5002
+ const lines = [
5003
+ "",
5004
+ " CodArch \xB7 serve",
5005
+ "",
5006
+ ` ra\xEDz ${state.root}`,
5007
+ ` web ${webRoot ?? "modo API (la sirve Vite)"}`,
5008
+ ` URL ${url}`,
5009
+ ` snapshot ${state.files} ficheros \xB7 checksum ${state.checksum.slice(0, 8)}`,
5010
+ ` modo ${state.watch ? "watch activo (SSE)" : "solo an\xE1lisis inicial"}`,
5011
+ ""
5012
+ ];
5013
+ console.log(lines.join("\n"));
5014
+ });
5015
+ const shutdown = () => {
5016
+ stopWatcher();
5017
+ closeClients();
5018
+ server.close(() => process.exit(0));
5019
+ };
5020
+ process.on("SIGINT", shutdown);
5021
+ process.on("SIGTERM", shutdown);
5022
+ return 0;
5023
+ }
5024
+ function analyzeState(root, watch) {
5025
+ const result = analyzeRepository(root, { git: true });
5026
+ return {
5027
+ root,
5028
+ snapshot: result.snapshot,
5029
+ files: result.files,
5030
+ checksum: result.snapshot.meta.checksum ?? "",
5031
+ warnings: result.warnings.slice(0, 5),
5032
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
5033
+ watch
5034
+ };
5035
+ }
5036
+ function browseDirectories(root, requested) {
5037
+ const target = requested?.trim() ? path8.resolve(requested) : root;
5038
+ const stat = fs10.statSync(target);
5039
+ if (!stat.isDirectory()) throw new Error("la ruta no es un directorio");
5040
+ const entries = fs10.readdirSync(target, { withFileTypes: true }).filter((entry) => entry.isDirectory() && !IGNORED_BROWSE.has(entry.name)).sort((left, right) => left.name.localeCompare(right.name)).slice(0, 500).map((entry) => ({
5041
+ name: entry.name,
5042
+ path: path8.join(target, entry.name),
5043
+ kind: "directory"
5044
+ }));
5045
+ const parent = path8.dirname(target);
5046
+ return {
5047
+ path: target,
5048
+ parent: parent === target ? null : parent,
5049
+ entries
5050
+ };
5051
+ }
5052
+ async function readBody(request) {
5053
+ const chunks = [];
5054
+ let size = 0;
5055
+ for await (const chunk of request) {
5056
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
5057
+ size += buffer.length;
5058
+ if (size > 64 * 1024) throw new Error("cuerpo demasiado grande");
5059
+ chunks.push(buffer);
5060
+ }
5061
+ const text = Buffer.concat(chunks).toString("utf8");
5062
+ return text ? JSON.parse(text) : {};
5063
+ }
5064
+ function originAllowed(request, options) {
5065
+ const origin = request.headers.origin;
5066
+ if (!origin) return true;
5067
+ try {
5068
+ const parsed = new URL(origin);
5069
+ if (parsed.protocol !== "http:") return false;
5070
+ if (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1") {
5071
+ return true;
5072
+ }
5073
+ return origin === `http://${options.host}:${options.port}`;
5074
+ } catch {
5075
+ return false;
5076
+ }
5077
+ }
5078
+ function findWebRoot() {
5079
+ const candidates = [
5080
+ fileURLToPath(new URL("./web", import.meta.url)),
5081
+ path8.resolve(process.cwd(), "apps", "web", "dist"),
5082
+ path8.resolve(
5083
+ path8.dirname(fileURLToPath(import.meta.url)),
5084
+ "..",
5085
+ "..",
5086
+ "..",
5087
+ "web",
5088
+ "dist"
5089
+ )
5090
+ ];
5091
+ for (const candidate of candidates) {
5092
+ if (fs10.existsSync(path8.join(candidate, "index.html"))) return candidate;
5093
+ }
5094
+ return null;
5095
+ }
5096
+ function readReport(root) {
5097
+ const file = path8.join(root, ".codarch", "report.json");
5098
+ try {
5099
+ return JSON.parse(fs10.readFileSync(file, "utf8"));
5100
+ } catch (error) {
5101
+ if (error.code !== "ENOENT") {
5102
+ console.error(`no se pudo leer ${file}: ${error.message}`);
5103
+ }
5104
+ return null;
5105
+ }
5106
+ }
5107
+ function serveStatic(webRoot, pathname, headOnly, response) {
5108
+ let decoded;
5109
+ try {
5110
+ decoded = decodeURIComponent(pathname);
5111
+ } catch {
5112
+ response.writeHead(400);
5113
+ response.end();
5114
+ return;
5115
+ }
5116
+ const relative = decoded === "/" ? "index.html" : decoded.replace(/^\/+/, "");
5117
+ let file = path8.resolve(webRoot, relative);
5118
+ if (!isWithin2(webRoot, file) || !fs10.existsSync(file) || !fs10.statSync(file).isFile()) {
5119
+ file = path8.join(webRoot, "index.html");
5120
+ }
5121
+ try {
5122
+ const body = fs10.readFileSync(file);
5123
+ response.writeHead(200, {
5124
+ "Content-Type": MIME[path8.extname(file).toLowerCase()] ?? "application/octet-stream",
5125
+ "Content-Length": body.length,
5126
+ "Cache-Control": file.includes(`${path8.sep}assets${path8.sep}`) ? "public, max-age=31536000, immutable" : "no-store",
5127
+ "X-Content-Type-Options": "nosniff"
5128
+ });
5129
+ if (headOnly) response.end();
5130
+ else response.end(body);
5131
+ } catch (error) {
5132
+ response.writeHead(500);
5133
+ response.end(error.message);
5134
+ }
5135
+ }
5136
+ function isWithin2(root, candidate) {
5137
+ const relative = path8.relative(root, candidate);
5138
+ return relative === "" || !relative.startsWith("..") && !path8.isAbsolute(relative);
5139
+ }
5140
+ function sendJson(response, value) {
5141
+ const body = `${JSON.stringify(value)}
5142
+ `;
5143
+ response.writeHead(200, {
5144
+ "Content-Type": "application/json; charset=utf-8",
5145
+ "Content-Length": Buffer.byteLength(body),
5146
+ "Cache-Control": "no-store",
5147
+ "X-Content-Type-Options": "nosniff"
5148
+ });
5149
+ response.end(body);
5150
+ }
5151
+ function sendError(response, status, message) {
5152
+ const body = `${JSON.stringify({ error: message })}
5153
+ `;
5154
+ response.writeHead(status, {
5155
+ "Content-Type": "application/json; charset=utf-8",
5156
+ "Content-Length": Buffer.byteLength(body),
5157
+ "Cache-Control": "no-store"
5158
+ });
5159
+ response.end(body);
5160
+ }
5161
+ function broadcast(clients, event, data) {
5162
+ const payload = `event: ${event}
5163
+ data: ${JSON.stringify(data)}
5164
+
5165
+ `;
5166
+ for (const client of clients) {
5167
+ try {
5168
+ client.write(payload);
5169
+ } catch {
5170
+ clients.delete(client);
5171
+ }
5172
+ }
5173
+ }
5174
+
4705
5175
  // apps/cli/src/index.ts
4706
- var VERSION = true ? "0.1.1" : "0.0.0";
5176
+ var VERSION = true ? "0.1.3" : "0.0.0";
4707
5177
  var USAGE = `
4708
5178
  CodArch \xB7 mapa vivo de la arquitectura de tu software
4709
5179
 
@@ -4712,6 +5182,7 @@ Uso:
4712
5182
  codarch inspect <fichero.cc.json[.gz]> [opciones]
4713
5183
  codarch render <fichero.cc.json[.gz]> [opciones]
4714
5184
  codarch diff <base.cc.json[.gz]> <head.cc.json[.gz]> [opciones]
5185
+ codarch serve [ruta] [opciones]
4715
5186
 
4716
5187
  Opciones de analyze:
4717
5188
  -o, --out <dir> directorio de salida (por defecto <ruta>/.codarch)
@@ -4745,6 +5216,14 @@ Opciones de diff:
4745
5216
  --fail-on <modo> none | cycles | violations | hotspots | any (por defecto none)
4746
5217
  -h, --help esta ayuda
4747
5218
 
5219
+ Opciones de serve:
5220
+ -p, --port <n> puerto local (por defecto 4173)
5221
+ --host <host> interfaz (por defecto 127.0.0.1)
5222
+ --watch reanaliza al cambiar ficheros y refresca por SSE (por defecto)
5223
+ --no-watch solo analiza al arrancar
5224
+ --no-web modo API sin servir la app (para Vite u otro frontend)
5225
+ -h, --help esta ayuda
5226
+
4748
5227
  Ejemplos:
4749
5228
  codarch analyze .
4750
5229
  codarch analyze . -o .codarch --title "Mi proyecto"
@@ -4752,6 +5231,8 @@ Ejemplos:
4752
5231
  codarch render samples/codecharta_visualization.cc.json.gz
4753
5232
  codarch render mapa.cc.json -o mapa.html --area rloc --color maxComplexity
4754
5233
  codarch diff base.cc.json head.cc.json -o report.json --fail-on cycles
5234
+ codarch serve .
5235
+ codarch serve . --no-watch
4755
5236
  `;
4756
5237
  function parseAnalyze(args) {
4757
5238
  const options = { help: false, ignore: [] };
@@ -5015,6 +5496,63 @@ function parseDiff(args) {
5015
5496
  function isFailOn(value) {
5016
5497
  return value === "none" || value === "cycles" || value === "violations" || value === "hotspots" || value === "any";
5017
5498
  }
5499
+ function parseServe(args) {
5500
+ const options = {
5501
+ port: 4173,
5502
+ host: "127.0.0.1",
5503
+ watch: true,
5504
+ web: true,
5505
+ help: false
5506
+ };
5507
+ const positional = [];
5508
+ for (let index = 0; index < args.length; index++) {
5509
+ const arg = args[index];
5510
+ if (arg === "--port" || arg === "-p") {
5511
+ const value = args[++index];
5512
+ const port = Number(value);
5513
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
5514
+ console.error("--port necesita un n\xFAmero entre 1 y 65535");
5515
+ return 1;
5516
+ }
5517
+ options.port = port;
5518
+ } else if (arg.startsWith("--port=")) {
5519
+ const port = Number(arg.slice("--port=".length));
5520
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
5521
+ console.error("--port necesita un n\xFAmero entre 1 y 65535");
5522
+ return 1;
5523
+ }
5524
+ options.port = port;
5525
+ } else if (arg === "--host") {
5526
+ const value = args[++index];
5527
+ if (!value) {
5528
+ console.error("--host necesita un valor");
5529
+ return 1;
5530
+ }
5531
+ options.host = value;
5532
+ } else if (arg.startsWith("--host=")) {
5533
+ options.host = arg.slice("--host=".length);
5534
+ } else if (arg === "--watch") {
5535
+ options.watch = true;
5536
+ } else if (arg === "--no-watch") {
5537
+ options.watch = false;
5538
+ } else if (arg === "--no-web") {
5539
+ options.web = false;
5540
+ } else if (arg === "--help" || arg === "-h") {
5541
+ options.help = true;
5542
+ } else if (arg.startsWith("-")) {
5543
+ console.error(`opci\xF3n desconocida en serve: ${arg}`);
5544
+ return 1;
5545
+ } else {
5546
+ positional.push(arg);
5547
+ }
5548
+ }
5549
+ if (positional.length > 1) {
5550
+ console.error("serve acepta como m\xE1ximo una ruta");
5551
+ return 1;
5552
+ }
5553
+ options.root = positional[0];
5554
+ return options;
5555
+ }
5018
5556
  function main(argv) {
5019
5557
  const [command, ...rest] = argv;
5020
5558
  if (!command || command === "--help" || command === "-h") {
@@ -5045,6 +5583,15 @@ function main(argv) {
5045
5583
  if (typeof parsed === "number") return parsed;
5046
5584
  return runDiff(parsed);
5047
5585
  }
5586
+ if (command === "serve") {
5587
+ const parsed = parseServe(rest);
5588
+ if (typeof parsed === "number") return parsed;
5589
+ if (parsed.help) {
5590
+ console.log(USAGE);
5591
+ return 0;
5592
+ }
5593
+ return runServe(parsed);
5594
+ }
5048
5595
  console.error(`comando desconocido: ${command}
5049
5596
  ${USAGE}`);
5050
5597
  return 1;