@titan-design/code-graph 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -189,6 +189,9 @@ function readMetricAggregates(stmts, snapshotId, name) {
189
189
 
190
190
  // src/store.ts
191
191
  var NODE_COLS = "id, kind, name, parent_id, language, role, attrs";
192
+ function isStructuralLayer(edge) {
193
+ return edge.kind !== "references" && edge.kind !== "calls";
194
+ }
192
195
  var CodeGraphStore = class {
193
196
  constructor(db) {
194
197
  this.db = db;
@@ -307,10 +310,10 @@ var CodeGraphStore = class {
307
310
  const nodes = this.statements.listNodes.all(snapshotId).map(rowToNode);
308
311
  return opts?.includeSymbols ? nodes : nodes.filter((n) => n.kind !== "symbol");
309
312
  }
310
- /** See {@link listNodes}: `references` edges are the symbol layer, excluded by default. */
313
+ /** See {@link listNodes}: `references` and `calls` edges are the symbol layer, excluded by default. */
311
314
  listEdges(snapshotId, opts) {
312
315
  const edges = this.statements.listEdges.all(snapshotId).map(rowToEdge);
313
- return opts?.includeReferences ? edges : edges.filter((e) => e.kind !== "references");
316
+ return opts?.includeReferences ? edges : edges.filter(isStructuralLayer);
314
317
  }
315
318
  listMetrics(snapshotId) {
316
319
  return this.statements.listMetrics.all(snapshotId).map(rowToMetric);
@@ -327,10 +330,10 @@ var CodeGraphStore = class {
327
330
  topByMetric(opts) {
328
331
  return readTopByMetric(this.targeted, opts);
329
332
  }
330
- /** Edges into or out of one node; `references` edges are excluded unless asked, as in {@link listEdges}. */
333
+ /** Edges into or out of one node; symbol-layer edges are excluded unless asked, as in {@link listEdges}. */
331
334
  listEdgesTouching(snapshotId, nodeId, opts) {
332
335
  const edges = readEdgesTouching(this.targeted, snapshotId, nodeId);
333
- return opts?.includeReferences ? edges : edges.filter((e) => e.kind !== "references");
336
+ return opts?.includeReferences ? edges : edges.filter(isStructuralLayer);
334
337
  }
335
338
  /** Count, sum, min, and max per metric name and node kind; pass `name` for one metric. */
336
339
  aggregateMetrics(snapshotId, opts) {
@@ -447,12 +450,10 @@ function runPrune(store, options = {}) {
447
450
 
448
451
  // src/indexer.ts
449
452
  import { realpathSync } from "fs";
450
- import * as path9 from "path";
453
+ import * as path8 from "path";
451
454
  import { parseFile } from "@titan-design/code-parser";
452
455
 
453
456
  // src/extractors/ts-morph-extractor.ts
454
- import * as path4 from "path";
455
- import { existsSync } from "fs";
456
457
  import {
457
458
  Project,
458
459
  ScriptTarget,
@@ -664,20 +665,24 @@ var TS_DECL_TYPES = /* @__PURE__ */ new Set([
664
665
  ]);
665
666
  var PY_DECL_TYPES = /* @__PURE__ */ new Set(["function_definition", "class_definition"]);
666
667
  function collectDeclarations(file) {
667
- const python = file.language === "python";
668
- const declTypes = python ? PY_DECL_TYPES : TS_DECL_TYPES;
669
668
  const out = [];
670
- walkScopes(file.tree.rootNode, python, (node, scope) => {
671
- const named = declaredNodeAt(node, declTypes);
672
- if (!named) return;
669
+ forEachDeclaration(file, (name, qualifiedName, node) => {
673
670
  out.push({
674
- name: named.name,
675
- qualifiedName: qualify(scope, named.name),
676
- span: { startLine: named.node.startPosition.row + 1, endLine: named.node.endPosition.row + 1 }
671
+ name,
672
+ qualifiedName,
673
+ span: { startLine: node.startPosition.row + 1, endLine: node.endPosition.row + 1 }
677
674
  });
678
675
  });
679
676
  return out;
680
677
  }
678
+ function forEachDeclaration(file, visit) {
679
+ const python = file.language === "python";
680
+ const declTypes = python ? PY_DECL_TYPES : TS_DECL_TYPES;
681
+ walkScopes(file.tree.rootNode, python, (node, scope) => {
682
+ const named = declaredNodeAt(node, declTypes);
683
+ if (named) visit(named.name, qualify(scope, named.name), named.node);
684
+ });
685
+ }
681
686
  function collectDeclaredSpans(file) {
682
687
  const spans = /* @__PURE__ */ new Map();
683
688
  for (const d of collectDeclarations(file)) spans.set(d.qualifiedName, d.span);
@@ -698,6 +703,80 @@ function declaredNodeAt(node, declTypes) {
698
703
  return null;
699
704
  }
700
705
 
706
+ // src/extractors/call-sites.ts
707
+ function addCallSite(agg, srcId, dstId, site) {
708
+ const key = JSON.stringify([srcId, dstId]);
709
+ const existing = agg.get(key);
710
+ if (existing) existing.attrs.sites.push(site);
711
+ else agg.set(key, { srcId, dstId, kind: "calls", attrs: { sites: [site] } });
712
+ }
713
+ function paramAttrs(shape) {
714
+ if (!shape) return {};
715
+ return shape.positional === shape.params.length ? { params: shape.params } : { params: shape.params, positionalParams: shape.positional };
716
+ }
717
+ function readParamShape(attrs) {
718
+ const params = attrs?.params;
719
+ if (!Array.isArray(params)) return void 0;
720
+ const positional = typeof attrs?.positionalParams === "number" ? attrs.positionalParams : params.length;
721
+ return { params, positional };
722
+ }
723
+
724
+ // src/extractors/callable-params.ts
725
+ var TS_PARAM_TYPES = /* @__PURE__ */ new Set(["required_parameter", "optional_parameter"]);
726
+ function collectParamShapes(file) {
727
+ const out = /* @__PURE__ */ new Map();
728
+ forEachDeclaration(file, (_name, qualifiedName, node) => {
729
+ const shape = file.language === "python" ? pythonShape(node) : tsShape(node);
730
+ if (shape) out.set(qualifiedName, shape);
731
+ });
732
+ return out;
733
+ }
734
+ function tsShape(fn) {
735
+ if (fn.type === "class_declaration") return null;
736
+ const single = fn.childForFieldName("parameter");
737
+ if (single) return { params: [single.text], positional: 1 };
738
+ const params = [];
739
+ for (const param of fn.childForFieldName("parameters")?.namedChildren ?? []) {
740
+ if (!param || !TS_PARAM_TYPES.has(param.type)) continue;
741
+ const pattern = param.childForFieldName("pattern");
742
+ if (!pattern || pattern.type === "this") continue;
743
+ if (pattern.type === "rest_pattern") break;
744
+ params.push(pattern.type === "identifier" ? pattern.text : `$${params.length}`);
745
+ }
746
+ return { params, positional: params.length };
747
+ }
748
+ function pythonShape(fn) {
749
+ if (fn.type !== "function_definition") return null;
750
+ const names = [];
751
+ let positionalEnd = null;
752
+ for (const param of fn.childForFieldName("parameters")?.namedChildren ?? []) {
753
+ if (!param) continue;
754
+ if (param.type === "keyword_separator" || param.type === "list_splat_pattern") positionalEnd ??= names.length;
755
+ const name = pythonParamName(param);
756
+ if (name) names.push(name);
757
+ }
758
+ const receiver = hasReceiver(fn) && names.length > 0 ? 1 : 0;
759
+ return { params: names.slice(receiver), positional: Math.max((positionalEnd ?? names.length) - receiver, 0) };
760
+ }
761
+ function pythonParamName(param) {
762
+ if (param.type === "identifier") return param.text;
763
+ if (param.type === "default_parameter" || param.type === "typed_default_parameter") {
764
+ return param.childForFieldName("name")?.text ?? null;
765
+ }
766
+ if (param.type === "typed_parameter") {
767
+ const first = param.namedChildren[0];
768
+ return first?.type === "identifier" ? first.text : null;
769
+ }
770
+ return null;
771
+ }
772
+ function hasReceiver(fn) {
773
+ const decorated = fn.parent?.type === "decorated_definition" ? fn.parent : null;
774
+ const block = (decorated ?? fn).parent;
775
+ if (block?.type !== "block" || block.parent?.type !== "class_definition") return false;
776
+ const decorators = decorated?.namedChildren.filter((c) => c?.type === "decorator") ?? [];
777
+ return !decorators.some((d) => d?.text === "@staticmethod");
778
+ }
779
+
701
780
  // src/extractors/symbol-signature.ts
702
781
  import {
703
782
  Node as Node2
@@ -776,8 +855,8 @@ function clamp(s, max = MAX_SIGNATURE) {
776
855
  }
777
856
 
778
857
  // src/extractors/symbol-nodes.ts
779
- function makeSymbolNode(fileId2, name, exported, span, text) {
780
- const attrs = { exported };
858
+ function makeSymbolNode(fileId2, name, exported, span, text, shape) {
859
+ const attrs = { exported, ...paramAttrs(shape) };
781
860
  if (span) {
782
861
  attrs.startLine = span.startLine;
783
862
  attrs.endLine = span.endLine;
@@ -796,18 +875,19 @@ function makeSymbolNode(fileId2, name, exported, span, text) {
796
875
  function buildSymbolNodes(repoRoot, sourceFile, file) {
797
876
  const fId = fileId(repoRoot, sourceFile.getFilePath());
798
877
  const spans = collectDeclaredSpans(file);
878
+ const shapes = collectParamShapes(file);
799
879
  const out = [];
800
880
  const exported = /* @__PURE__ */ new Set();
801
881
  for (const [name, decls] of sourceFile.getExportedDeclarations()) {
802
882
  const own = decls.find((d) => d.getSourceFile() === sourceFile);
803
883
  if (!own) continue;
804
884
  exported.add(name);
805
- out.push(makeSymbolNode(fId, name, true, spans.get(name), declarationText(own)));
885
+ out.push(makeSymbolNode(fId, name, true, spans.get(name), declarationText(own), shapes.get(name)));
806
886
  }
807
887
  for (const [name, span] of spans) {
808
888
  if (exported.has(name)) continue;
809
889
  const decl = lookupDeclaration(sourceFile, name);
810
- out.push(makeSymbolNode(fId, name, false, span, decl ? declarationText(decl) : {}));
890
+ out.push(makeSymbolNode(fId, name, false, span, decl ? declarationText(decl) : {}, shapes.get(name)));
811
891
  }
812
892
  return out;
813
893
  }
@@ -879,6 +959,7 @@ function recordDynamicSymbolRefEdges(sourceFile, srcFileId, agg, resolveSpecifie
879
959
 
880
960
  // src/extractors/module-resolution.ts
881
961
  import * as path3 from "path";
962
+ import { existsSync } from "fs";
882
963
  var RESOLVABLE_EXTS = [
883
964
  ".ts",
884
965
  ".tsx",
@@ -916,6 +997,17 @@ function inRepoFileId(repoRoot, abs) {
916
997
  if (relative4.split(path3.sep).includes("node_modules")) return null;
917
998
  return fileId(repoRoot, abs);
918
999
  }
1000
+ function remapDistToSrc(abs) {
1001
+ const m = /^(.*)[\\/]dist[\\/](.+)\.d\.ts$/.exec(abs);
1002
+ if (!m) return abs;
1003
+ const base = m[1];
1004
+ const sub = m[2];
1005
+ for (const ext of [".ts", ".tsx"]) {
1006
+ const candidate = path3.join(base, "src", sub + ext);
1007
+ if (existsSync(candidate)) return candidate;
1008
+ }
1009
+ return abs;
1010
+ }
919
1011
 
920
1012
  // src/extractors/reexport-resolve.ts
921
1013
  function resolveReExportOrigin(ctx, importerAbs, specifier, name) {
@@ -962,6 +1054,125 @@ function reExportInwardName(ed, name) {
962
1054
  return name;
963
1055
  }
964
1056
 
1057
+ // src/extractors/ts-calls.ts
1058
+ import { Node as Node5, SyntaxKind as SyntaxKind3 } from "ts-morph";
1059
+
1060
+ // src/extractors/ts-scope.ts
1061
+ import { Node as Node4 } from "ts-morph";
1062
+ function bindingName2(node) {
1063
+ const parent = node.getParent();
1064
+ if (Node4.isVariableDeclaration(parent) || Node4.isPropertyDeclaration(parent)) return parent.getName();
1065
+ if (Node4.isPropertyAssignment(parent)) return parent.getNameNode().getText();
1066
+ if (Node4.isExportAssignment(parent)) return "default";
1067
+ return null;
1068
+ }
1069
+ function declaredName(node) {
1070
+ const name = node.getName();
1071
+ if (name) return name;
1072
+ return Node4.isExportable(node) && node.isDefaultExport() ? "default" : null;
1073
+ }
1074
+ function scopeSegment2(node) {
1075
+ if (Node4.isClassDeclaration(node) || Node4.isFunctionDeclaration(node)) return declaredName(node);
1076
+ if (Node4.isMethodDeclaration(node) || Node4.isGetAccessorDeclaration(node) || Node4.isSetAccessorDeclaration(node)) {
1077
+ return node.getName();
1078
+ }
1079
+ if (Node4.isConstructorDeclaration(node)) return "constructor";
1080
+ if (Node4.isModuleDeclaration(node)) return node.hasNamespaceKeyword() ? node.getName() : null;
1081
+ if (Node4.isClassExpression(node) && node.getName()) return node.getName() ?? null;
1082
+ const bound = Node4.isArrowFunction(node) || Node4.isFunctionExpression(node) || Node4.isClassExpression(node) || Node4.isObjectLiteralExpression(node);
1083
+ return bound ? bindingName2(node) ?? ANONYMOUS_SCOPE : null;
1084
+ }
1085
+ function scopeOf(node) {
1086
+ const segments = node.getAncestors().map(scopeSegment2).filter((s) => s !== null).reverse();
1087
+ let scope = "";
1088
+ for (const segment of segments) {
1089
+ const repeatsAnonymous = segment === ANONYMOUS_SCOPE && (scope === ANONYMOUS_SCOPE || scope.endsWith(`.${ANONYMOUS_SCOPE}`));
1090
+ if (!repeatsAnonymous) scope = qualify(scope, segment);
1091
+ }
1092
+ return scope;
1093
+ }
1094
+ function declarationQualifiedName(decl) {
1095
+ if (Node4.isVariableDeclaration(decl)) {
1096
+ const init = decl.getInitializer();
1097
+ const callable = Node4.isArrowFunction(init) || Node4.isFunctionExpression(init);
1098
+ return callable ? qualify(scopeOf(decl), decl.getName()) : null;
1099
+ }
1100
+ if (Node4.isArrowFunction(decl) || Node4.isFunctionExpression(decl)) {
1101
+ const parent = decl.getParent();
1102
+ return Node4.isVariableDeclaration(parent) ? qualify(scopeOf(parent), parent.getName()) : null;
1103
+ }
1104
+ const isDeclaration = Node4.isFunctionDeclaration(decl) || Node4.isClassDeclaration(decl) || Node4.isMethodDeclaration(decl) || Node4.isGetAccessorDeclaration(decl) || Node4.isSetAccessorDeclaration(decl) || Node4.isConstructorDeclaration(decl);
1105
+ const own = isDeclaration ? scopeSegment2(decl) : null;
1106
+ return own ? qualify(scopeOf(decl), own) : null;
1107
+ }
1108
+
1109
+ // src/extractors/ts-calls.ts
1110
+ function collectTsCallEdges(sourceFile, ctx) {
1111
+ const agg = /* @__PURE__ */ new Map();
1112
+ sourceFile.forEachDescendant((node) => {
1113
+ if (!Node5.isCallExpression(node) && !Node5.isNewExpression(node)) return;
1114
+ const dstId = resolveCallee(node, ctx);
1115
+ if (dstId) addCallSite(agg, callerId(node, ctx), dstId, tsCallSite(node));
1116
+ });
1117
+ return [...agg.values()];
1118
+ }
1119
+ function resolveCallee(call, ctx) {
1120
+ const callee = call.getExpression();
1121
+ if (callee.getKind() === SyntaxKind3.SuperKeyword) return null;
1122
+ const symbol = callee.getSymbol();
1123
+ if (!symbol) return null;
1124
+ const target = symbol.isAlias() ? symbol.getAliasedSymbol() : symbol;
1125
+ const ids = /* @__PURE__ */ new Set();
1126
+ for (const decl of target?.getDeclarations() ?? []) {
1127
+ const id = declarationId(decl, ctx);
1128
+ if (id === null) return null;
1129
+ ids.add(id);
1130
+ }
1131
+ return ids.size === 1 ? [...ids][0] : null;
1132
+ }
1133
+ function declarationId(decl, ctx) {
1134
+ const sf = decl.getSourceFile();
1135
+ const abs = remapDistToSrc(sf.getFilePath());
1136
+ if (sf.isDeclarationFile() && abs === sf.getFilePath()) return null;
1137
+ const fileId2 = inRepoFileId(ctx.repoRoot, abs);
1138
+ const name = fileId2 ? declarationQualifiedName(decl) : null;
1139
+ return fileId2 && name ? symbolId(fileId2, name) : null;
1140
+ }
1141
+ function callerId(call, ctx) {
1142
+ for (const ancestor of call.getAncestors()) {
1143
+ const name = declarationQualifiedName(ancestor);
1144
+ if (name && ctx.localSymbols.has(name)) return symbolId(ctx.srcFileId, name);
1145
+ }
1146
+ return ctx.srcFileId;
1147
+ }
1148
+ function tsCallSite(call) {
1149
+ const site = { args: [] };
1150
+ for (const arg of call.getArguments()) {
1151
+ if (Node5.isSpreadElement(arg)) {
1152
+ site.spreadFrom ??= site.args.length;
1153
+ break;
1154
+ }
1155
+ site.args.push(literalText(arg));
1156
+ }
1157
+ return site;
1158
+ }
1159
+ var LITERAL_KINDS = /* @__PURE__ */ new Set([
1160
+ SyntaxKind3.NumericLiteral,
1161
+ SyntaxKind3.StringLiteral,
1162
+ SyntaxKind3.NoSubstitutionTemplateLiteral,
1163
+ SyntaxKind3.TrueKeyword,
1164
+ SyntaxKind3.FalseKeyword,
1165
+ SyntaxKind3.NullKeyword
1166
+ ]);
1167
+ function literalText(arg) {
1168
+ if (LITERAL_KINDS.has(arg.getKind())) return arg.getText();
1169
+ if (Node5.isIdentifier(arg) && arg.getText() === "undefined") return "undefined";
1170
+ if (Node5.isPrefixUnaryExpression(arg) && arg.getOperatorToken() === SyntaxKind3.MinusToken) {
1171
+ return Node5.isNumericLiteral(arg.getOperand()) ? arg.getText() : null;
1172
+ }
1173
+ return null;
1174
+ }
1175
+
965
1176
  // src/extractors/ts-morph-extractor.ts
966
1177
  var TsMorphGraphExtractor = class {
967
1178
  name = "ts-morph-graph";
@@ -980,7 +1191,12 @@ var TsMorphGraphExtractor = class {
980
1191
  const nodes = this.buildFileAndModuleNodes(sourceFile);
981
1192
  const symbolNodes2 = buildSymbolNodes(this.repoRoot, sourceFile, file);
982
1193
  const { edges, externalNodes } = this.collectEdges(sourceFile);
983
- return [{ nodes: [...nodes, ...symbolNodes2, ...externalNodes], edges }];
1194
+ const calls = collectTsCallEdges(sourceFile, {
1195
+ repoRoot: this.repoRoot,
1196
+ srcFileId: fileId(this.repoRoot, sourceFile.getFilePath()),
1197
+ localSymbols: new Set(symbolNodes2.map((n) => n.name))
1198
+ });
1199
+ return [{ nodes: [...nodes, ...symbolNodes2, ...externalNodes], edges: [...edges, ...calls] }];
984
1200
  }
985
1201
  ensureProject() {
986
1202
  if (this.project) return this.project;
@@ -1146,27 +1362,129 @@ var TsMorphGraphExtractor = class {
1146
1362
  return inRepoFileId(this.repoRoot, abs);
1147
1363
  }
1148
1364
  };
1149
- function remapDistToSrc(abs) {
1150
- const m = /^(.*)[\\/]dist[\\/](.+)\.d\.ts$/.exec(abs);
1151
- if (!m) return abs;
1152
- const base = m[1];
1153
- const sub = m[2];
1154
- for (const ext of [".ts", ".tsx"]) {
1155
- const candidate = path4.join(base, "src", sub + ext);
1156
- if (existsSync(candidate)) return candidate;
1157
- }
1158
- return abs;
1159
- }
1160
1365
  function isTypeScriptFile(file) {
1161
1366
  return file.language === "typescript" || file.language === "tsx";
1162
1367
  }
1163
1368
 
1164
1369
  // src/extractors/python-extractor.ts
1165
- import * as path5 from "path";
1370
+ import * as path4 from "path";
1166
1371
  import { existsSync as existsSync2 } from "fs";
1372
+
1373
+ // src/extractors/python-calls.ts
1374
+ function collectPythonCallEdges(file, fileId2, resolve4) {
1375
+ const declarations = /* @__PURE__ */ new Map();
1376
+ forEachDeclaration(file, (_name, qualifiedName, node) => declarations.set(qualifiedName, node));
1377
+ const ctx = { fileId: fileId2, declarations, imports: fromImports(file.tree.rootNode, resolve4) };
1378
+ const agg = /* @__PURE__ */ new Map();
1379
+ walkScopes(file.tree.rootNode, true, (node, scope) => {
1380
+ if (node.type !== "call") return;
1381
+ const dst = resolveCallee2(node.childForFieldName("function"), scope, ctx);
1382
+ const args = node.childForFieldName("arguments");
1383
+ if (!dst || args?.type !== "argument_list") return;
1384
+ const srcId = declarations.has(scope) ? symbolId(fileId2, scope) : fileId2;
1385
+ addCallSite(agg, srcId, dst, pythonCallSite(args));
1386
+ });
1387
+ return [...agg.values()];
1388
+ }
1389
+ function resolveCallee2(callee, scope, ctx) {
1390
+ if (callee?.type === "identifier") return resolveBareName(callee.text, scope, ctx);
1391
+ if (callee?.type === "attribute") return resolveSelfMethod(callee, scope, ctx);
1392
+ return null;
1393
+ }
1394
+ function resolveBareName(name, scope, ctx) {
1395
+ if (isShadowed(name, scope, ctx)) return null;
1396
+ const local = ctx.declarations.has(name);
1397
+ const imported = ctx.imports.get(name);
1398
+ if (local && imported) return null;
1399
+ if (local) return symbolId(ctx.fileId, name);
1400
+ return imported ? symbolId(imported.fileId, imported.name) : null;
1401
+ }
1402
+ function isShadowed(name, scope, ctx) {
1403
+ const segments = scope ? scope.split(".") : [];
1404
+ for (let depth = segments.length; depth > 0; depth--) {
1405
+ const enclosing = segments.slice(0, depth).join(".");
1406
+ if (ctx.declarations.has(`${enclosing}.${name}`)) return true;
1407
+ if (parameterNames(ctx.declarations.get(enclosing)).includes(name)) return true;
1408
+ }
1409
+ return false;
1410
+ }
1411
+ function resolveSelfMethod(callee, scope, ctx) {
1412
+ const receiver = callee.childForFieldName("object");
1413
+ const attr = callee.childForFieldName("attribute")?.text;
1414
+ const method = ctx.declarations.get(scope);
1415
+ if (receiver?.type !== "identifier" || !attr || !method || !hasReceiver(method)) return null;
1416
+ if (parameterNames(method)[0] !== receiver.text) return null;
1417
+ const owner = findMethodOwner(scope.slice(0, scope.lastIndexOf(".")), attr, ctx, /* @__PURE__ */ new Set());
1418
+ return owner ? symbolId(ctx.fileId, `${owner}.${attr}`) : null;
1419
+ }
1420
+ function findMethodOwner(className, attr, ctx, seen) {
1421
+ const cls = ctx.declarations.get(className);
1422
+ if (cls?.type !== "class_definition" || seen.has(className)) return null;
1423
+ seen.add(className);
1424
+ if (ctx.declarations.get(`${className}.${attr}`)?.type === "function_definition") return className;
1425
+ for (const base of cls.childForFieldName("superclasses")?.namedChildren ?? []) {
1426
+ if (base?.type !== "identifier" || !ctx.declarations.has(base.text)) return null;
1427
+ const owner = findMethodOwner(base.text, attr, ctx, seen);
1428
+ if (owner) return owner;
1429
+ }
1430
+ return null;
1431
+ }
1432
+ function parameterNames(def) {
1433
+ if (def?.type !== "function_definition") return [];
1434
+ const out = [];
1435
+ for (const param of def.childForFieldName("parameters")?.namedChildren ?? []) {
1436
+ const id = param?.type === "identifier" ? param : param?.descendantsOfType("identifier")[0];
1437
+ if (id) out.push(id.text);
1438
+ }
1439
+ return out;
1440
+ }
1441
+ function fromImports(root, resolve4) {
1442
+ const out = /* @__PURE__ */ new Map();
1443
+ for (const stmt of root.descendantsOfType("import_from_statement")) {
1444
+ const moduleName = stmt?.childForFieldName("module_name")?.text;
1445
+ const moduleFile = moduleName ? resolve4(moduleName) : null;
1446
+ if (!stmt || !moduleFile) continue;
1447
+ for (const imported of stmt.childrenForFieldName("name")) {
1448
+ const alias = imported?.type === "aliased_import" ? imported.childForFieldName("alias")?.text : void 0;
1449
+ const name = imported?.type === "aliased_import" ? imported.childForFieldName("name")?.text : imported?.text;
1450
+ if (name && !name.includes(".")) out.set(alias ?? name, { fileId: moduleFile, name });
1451
+ }
1452
+ }
1453
+ return out;
1454
+ }
1455
+ var LITERAL_TYPES = /* @__PURE__ */ new Set(["integer", "float", "true", "false", "none"]);
1456
+ function literalText2(arg) {
1457
+ if (LITERAL_TYPES.has(arg.type)) return arg.text;
1458
+ if (arg.type === "string") return arg.namedChildren.some((c) => c?.type === "interpolation") ? null : arg.text;
1459
+ if (arg.type === "unary_operator" && arg.text.startsWith("-")) {
1460
+ const operand = arg.childForFieldName("argument");
1461
+ return operand && (operand.type === "integer" || operand.type === "float") ? arg.text : null;
1462
+ }
1463
+ return null;
1464
+ }
1465
+ function pythonCallSite(argumentList) {
1466
+ const site = { args: [] };
1467
+ for (const arg of argumentList.namedChildren) {
1468
+ if (!arg || arg.type === "comment") continue;
1469
+ if (arg.type === "list_splat") site.spreadFrom ??= site.args.length;
1470
+ else if (arg.type === "dictionary_splat") site.kwSplat = true;
1471
+ else if (arg.type === "keyword_argument") addKeyword(site, arg);
1472
+ else if (site.spreadFrom === void 0) site.args.push(literalText2(arg));
1473
+ }
1474
+ return site;
1475
+ }
1476
+ function addKeyword(site, arg) {
1477
+ const name = arg.childForFieldName("name")?.text;
1478
+ const value = arg.childForFieldName("value");
1479
+ if (!name || !value) return;
1480
+ site.kwargs ??= {};
1481
+ site.kwargs[name] = literalText2(value);
1482
+ }
1483
+
1484
+ // src/extractors/python-extractor.ts
1167
1485
  function* candidates(base) {
1168
1486
  yield `${base}.py`;
1169
- yield path5.join(base, "__init__.py");
1487
+ yield path4.join(base, "__init__.py");
1170
1488
  }
1171
1489
  var PythonGraphExtractor = class {
1172
1490
  constructor(repoRoot) {
@@ -1180,7 +1498,8 @@ var PythonGraphExtractor = class {
1180
1498
  const nodes = buildFileModuleNodes(this.repoRoot, file.filePath);
1181
1499
  nodes.push(...symbolNodes(fId, file));
1182
1500
  const { edges, externals } = this.collectImports(fId, file);
1183
- return [{ nodes: [...nodes, ...externals], edges }];
1501
+ const calls = collectPythonCallEdges(file, fId, (specifier) => this.resolveDotted(file.filePath, specifier));
1502
+ return [{ nodes: [...nodes, ...externals], edges: [...edges, ...calls] }];
1184
1503
  }
1185
1504
  collectImports(srcId, file) {
1186
1505
  const edges = /* @__PURE__ */ new Map();
@@ -1203,10 +1522,10 @@ var PythonGraphExtractor = class {
1203
1522
  resolveDotted(fromAbs, specifier) {
1204
1523
  const leadingDots = /^\.*/.exec(specifier)?.[0].length ?? 0;
1205
1524
  const parts = specifier.slice(leadingDots).split(".").filter(Boolean);
1206
- const relativeBase = path5.resolve(path5.dirname(fromAbs), "../".repeat(Math.max(leadingDots - 1, 0)));
1207
- const roots = leadingDots > 0 ? [relativeBase] : [path5.dirname(fromAbs), this.repoRoot];
1525
+ const relativeBase = path4.resolve(path4.dirname(fromAbs), "../".repeat(Math.max(leadingDots - 1, 0)));
1526
+ const roots = leadingDots > 0 ? [relativeBase] : [path4.dirname(fromAbs), this.repoRoot];
1208
1527
  for (const root of roots) {
1209
- for (const candidate of candidates(path5.join(root, ...parts))) {
1528
+ for (const candidate of candidates(path4.join(root, ...parts))) {
1210
1529
  const id = inRepoFileId(this.repoRoot, candidate);
1211
1530
  if (id !== null && existsSync2(candidate)) return id;
1212
1531
  }
@@ -1218,13 +1537,19 @@ function isPublicName(qualifiedName) {
1218
1537
  return !qualifiedName.slice(qualifiedName.lastIndexOf(".") + 1).startsWith("_");
1219
1538
  }
1220
1539
  function symbolNodes(fId, file) {
1540
+ const shapes = collectParamShapes(file);
1221
1541
  return [...collectDeclaredSpans(file)].map(([name, span]) => ({
1222
1542
  id: symbolId(fId, name),
1223
1543
  kind: "symbol",
1224
1544
  name,
1225
1545
  parentId: fId,
1226
1546
  language: "python",
1227
- attrs: { exported: isPublicName(name), startLine: span.startLine, endLine: span.endLine }
1547
+ attrs: {
1548
+ exported: isPublicName(name),
1549
+ startLine: span.startLine,
1550
+ endLine: span.endLine,
1551
+ ...paramAttrs(shapes.get(name))
1552
+ }
1228
1553
  }));
1229
1554
  }
1230
1555
  function importSpecifiers(root) {
@@ -1257,7 +1582,7 @@ var LanguageExtractor = class {
1257
1582
  };
1258
1583
 
1259
1584
  // src/git-renames.ts
1260
- import * as path6 from "path";
1585
+ import * as path5 from "path";
1261
1586
  function detectGitHead(repoRoot) {
1262
1587
  return runGit(repoRoot, ["rev-parse", "HEAD"]);
1263
1588
  }
@@ -1298,12 +1623,12 @@ function parseRenameOutput(text) {
1298
1623
  return out;
1299
1624
  }
1300
1625
  function classifyRename(oldPath, newPath) {
1301
- return path6.posix.dirname(oldPath) === path6.posix.dirname(newPath) ? "rename" : "move";
1626
+ return path5.posix.dirname(oldPath) === path5.posix.dirname(newPath) ? "rename" : "move";
1302
1627
  }
1303
1628
  function renameToAliases(repoRoot, pair) {
1304
1629
  const reason = classifyRename(pair.oldPath, pair.newPath);
1305
- const oldAbs = path6.join(repoRoot, pair.oldPath);
1306
- const newAbs = path6.join(repoRoot, pair.newPath);
1630
+ const oldAbs = path5.join(repoRoot, pair.oldPath);
1631
+ const newAbs = path5.join(repoRoot, pair.newPath);
1307
1632
  return [
1308
1633
  { oldId: fileId(repoRoot, oldAbs), newId: fileId(repoRoot, newAbs), reason },
1309
1634
  { oldId: moduleId(repoRoot, oldAbs), newId: moduleId(repoRoot, newAbs), reason }
@@ -1396,8 +1721,8 @@ function walk(requestedId, maps) {
1396
1721
  }
1397
1722
  function chainSteps(input) {
1398
1723
  if (input.from === null) return { steps: rootPath(input.lineage, input.to, input.maxHops), connected: true };
1399
- const path12 = lineagePath(input.lineage, input.from, input.to, input.maxHops);
1400
- if (path12) return { steps: path12, connected: true };
1724
+ const path11 = lineagePath(input.lineage, input.from, input.to, input.maxHops);
1725
+ if (path11) return { steps: path11, connected: true };
1401
1726
  return { steps: [{ snapshotId: input.to, direction: "forward" }], connected: false };
1402
1727
  }
1403
1728
  function createAliasChain(input) {
@@ -1478,7 +1803,7 @@ function computeAliasBridge(store, input) {
1478
1803
 
1479
1804
  // src/generated.ts
1480
1805
  import { readFileSync } from "fs";
1481
- import * as path7 from "path";
1806
+ import * as path6 from "path";
1482
1807
  var HEURISTIC_FILE_RE = /(?:^|\/)[^/]*\.(?:gen|generated)\.[^/]*$/;
1483
1808
  var HEURISTIC_DIR_RE = /(?:^|\/)generated\//;
1484
1809
  function isGeneratedByHeuristic(id) {
@@ -1490,7 +1815,7 @@ function isGeneratedFile(id, patterns) {
1490
1815
  }
1491
1816
  function loadGeneratedPatterns(rootDir) {
1492
1817
  try {
1493
- const content = readFileSync(path7.join(rootDir, ".gitattributes"), "utf-8");
1818
+ const content = readFileSync(path6.join(rootDir, ".gitattributes"), "utf-8");
1494
1819
  return parseGeneratedPatterns(content);
1495
1820
  } catch {
1496
1821
  return [];
@@ -1593,7 +1918,7 @@ function annotateRoles(nodes, options) {
1593
1918
 
1594
1919
  // src/file-walk.ts
1595
1920
  import * as fs from "fs/promises";
1596
- import * as path8 from "path";
1921
+ import * as path7 from "path";
1597
1922
  import { isExcludedDir, shouldIncludeFile } from "@titan-design/code-parser";
1598
1923
  async function walkSourceFiles(rootDirs, languages) {
1599
1924
  const seen = /* @__PURE__ */ new Set();
@@ -1606,12 +1931,12 @@ async function walkSourceFiles(rootDirs, languages) {
1606
1931
  async function collectFiles(dir, rootDir, languages, seen, out) {
1607
1932
  const entries = await fs.readdir(dir, { withFileTypes: true });
1608
1933
  for (const entry of entries) {
1609
- const full = path8.join(dir, entry.name);
1934
+ const full = path7.join(dir, entry.name);
1610
1935
  if (entry.isDirectory()) {
1611
1936
  if (isExcludedDir(entry.name)) continue;
1612
1937
  await collectFiles(full, rootDir, languages, seen, out);
1613
1938
  } else if (entry.isFile() && !seen.has(full)) {
1614
- const relative4 = path8.relative(rootDir, full);
1939
+ const relative4 = path7.relative(rootDir, full);
1615
1940
  if (shouldIncludeFile(relative4, [...languages])) {
1616
1941
  seen.add(full);
1617
1942
  out.push(full);
@@ -1627,7 +1952,8 @@ function edgeWeight(e) {
1627
1952
  }
1628
1953
  function pruneDanglingReferences(nodes, edges) {
1629
1954
  for (const [key, edge] of edges) {
1630
- if (edge.kind === "references" && !nodes.has(edge.dstId)) edges.delete(key);
1955
+ const symbolLayer = edge.kind === "references" || edge.kind === "calls";
1956
+ if (symbolLayer && (!nodes.has(edge.dstId) || !nodes.has(edge.srcId))) edges.delete(key);
1631
1957
  }
1632
1958
  }
1633
1959
  function resolveBarrelEdges(nodes, edges) {
@@ -1685,6 +2011,234 @@ import { createHash } from "crypto";
1685
2011
  import * as fs2 from "fs/promises";
1686
2012
  import { getLanguageFromPath as getLanguageFromPath2 } from "@titan-design/code-parser";
1687
2013
 
2014
+ // src/analysis/comment-lines.ts
2015
+ function commentLineStats(fn, body, lines) {
2016
+ const docstring = pythonDocstring(body);
2017
+ const comments = functionComments(fn, body);
2018
+ const commentLines = rowsOf(comments).size;
2019
+ const bodyLines = codeRows(body, docstring, comments, lines);
2020
+ return {
2021
+ commentLines,
2022
+ docstringLines: docstring ? rowSpan(docstring) : jsDocLines(fn),
2023
+ bodyLines,
2024
+ commentRatio: commentLines / Math.max(bodyLines, 1)
2025
+ };
2026
+ }
2027
+ function pythonDocstring(body) {
2028
+ if (body.type !== "block") return null;
2029
+ const first = body.namedChildren.find((c) => c !== null && c.type !== "comment");
2030
+ if (first?.type !== "expression_statement" || first.namedChildCount !== 1) return null;
2031
+ return first.namedChildren[0]?.type === "string" ? first : null;
2032
+ }
2033
+ function bodyStatements(body, docstring = pythonDocstring(body)) {
2034
+ if (body.type !== "block" && body.type !== "statement_block") return [body];
2035
+ return body.namedChildren.filter(
2036
+ (c) => c !== null && c.type !== "comment" && c.id !== docstring?.id
2037
+ );
2038
+ }
2039
+ function functionComments(fn, body) {
2040
+ if (body.type !== "block") return descendantsOfType(body, "comment");
2041
+ const signatureEnd = fn.childForFieldName("parameters")?.endIndex ?? body.startIndex;
2042
+ return descendantsOfType(fn, "comment").filter((c) => c.startIndex >= signatureEnd);
2043
+ }
2044
+ function descendantsOfType(root, type) {
2045
+ const out = [];
2046
+ const visit = (node) => {
2047
+ if (node.type === type) out.push(node);
2048
+ for (const child of node.namedChildren) if (child) visit(child);
2049
+ };
2050
+ visit(root);
2051
+ return out;
2052
+ }
2053
+ function rowSpan(node) {
2054
+ return node.endPosition.row - node.startPosition.row + 1;
2055
+ }
2056
+ function rowsOf(nodes) {
2057
+ const rows = /* @__PURE__ */ new Set();
2058
+ for (const n of nodes) {
2059
+ for (let r = n.startPosition.row; r <= n.endPosition.row; r++) rows.add(r);
2060
+ }
2061
+ return rows;
2062
+ }
2063
+ function codeRows(body, docstring, comments, lines) {
2064
+ let count = 0;
2065
+ for (const row of rowsOf(bodyStatements(body, docstring))) {
2066
+ if (codeOnRow(row, lines[row] ?? "", comments).trim() !== "") count++;
2067
+ }
2068
+ return count;
2069
+ }
2070
+ function codeOnRow(row, text, comments) {
2071
+ let out = text;
2072
+ for (const c of comments) {
2073
+ if (row < c.startPosition.row || row > c.endPosition.row) continue;
2074
+ const from = row === c.startPosition.row ? c.startPosition.column : 0;
2075
+ const to = row === c.endPosition.row ? c.endPosition.column : out.length;
2076
+ out = out.slice(0, from) + " ".repeat(Math.max(to - from, 0)) + out.slice(to);
2077
+ }
2078
+ return out;
2079
+ }
2080
+ function jsDocLines(fn) {
2081
+ const anchor = declarationAnchor(fn);
2082
+ const prev = anchor.previousNamedSibling;
2083
+ if (prev?.type !== "comment" || !prev.text.startsWith("/**")) return 0;
2084
+ const gap = anchor.startPosition.row - prev.endPosition.row;
2085
+ return gap === 0 || gap === 1 ? rowSpan(prev) : 0;
2086
+ }
2087
+ function declarationAnchor(fn) {
2088
+ let anchor = fn;
2089
+ if (fn.parent?.type === "variable_declarator") anchor = fn.parent.parent ?? fn;
2090
+ while (anchor.parent?.type === "export_statement") anchor = anchor.parent;
2091
+ return anchor;
2092
+ }
2093
+
2094
+ // src/analysis/narrating-comments.ts
2095
+ var MIN_SHARED = 2;
2096
+ var MIN_SHARE_OF_COMMENT = 0.5;
2097
+ var STOPWORDS = /* @__PURE__ */ new Set([
2098
+ "the",
2099
+ "and",
2100
+ "for",
2101
+ "this",
2102
+ "that",
2103
+ "with",
2104
+ "from",
2105
+ "into",
2106
+ "then",
2107
+ "else",
2108
+ "when",
2109
+ "not",
2110
+ "are",
2111
+ "was",
2112
+ "has",
2113
+ "have",
2114
+ "its",
2115
+ "our",
2116
+ "all",
2117
+ "any",
2118
+ "can",
2119
+ "will",
2120
+ "just",
2121
+ "now",
2122
+ "return",
2123
+ "const",
2124
+ "let",
2125
+ "var",
2126
+ "def",
2127
+ "self",
2128
+ "none",
2129
+ "true",
2130
+ "false",
2131
+ "null",
2132
+ "undefined",
2133
+ "new",
2134
+ "await",
2135
+ "async",
2136
+ "function"
2137
+ ]);
2138
+ var IDENTIFIER_TYPES = /* @__PURE__ */ new Set([
2139
+ "identifier",
2140
+ "property_identifier",
2141
+ "shorthand_property_identifier",
2142
+ "type_identifier"
2143
+ ]);
2144
+ function countNarratingComments(fn, body) {
2145
+ let count = 0;
2146
+ for (const comment of functionComments(fn, body)) {
2147
+ const target = trailedStatement(comment) ?? nextStatement(comment);
2148
+ if (target && narrates(comment.text, target)) count++;
2149
+ }
2150
+ return count;
2151
+ }
2152
+ function trailedStatement(comment) {
2153
+ const prev = comment.previousNamedSibling;
2154
+ if (!prev || prev.type === "comment") return null;
2155
+ return prev.endPosition.row === comment.startPosition.row ? prev : null;
2156
+ }
2157
+ function identifierTokens(text) {
2158
+ const words = text.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^A-Za-z0-9]+/);
2159
+ const out = /* @__PURE__ */ new Set();
2160
+ for (const w of words) {
2161
+ const t = w.toLowerCase();
2162
+ if (t.length >= 3 && !STOPWORDS.has(t)) out.add(t);
2163
+ }
2164
+ return out;
2165
+ }
2166
+ function nextStatement(comment) {
2167
+ let next = comment.nextNamedSibling;
2168
+ while (next?.type === "comment") next = next.nextNamedSibling;
2169
+ if (next?.type !== "block") return next;
2170
+ return next.namedChildren.find((c) => c !== null && c.type !== "comment") ?? null;
2171
+ }
2172
+ function narrates(commentText, statement) {
2173
+ const said = identifierTokens(commentText);
2174
+ if (said.size === 0) return false;
2175
+ const code = identifierTokens(statementIdentifiers(statement).join(" "));
2176
+ let shared = 0;
2177
+ for (const t of said) if (code.has(t)) shared++;
2178
+ return shared >= MIN_SHARED || shared / said.size >= MIN_SHARE_OF_COMMENT;
2179
+ }
2180
+ function statementIdentifiers(statement) {
2181
+ const out = [];
2182
+ const visit = (node) => {
2183
+ if (IDENTIFIER_TYPES.has(node.type)) out.push(node.text);
2184
+ for (const child of node.namedChildren) if (child) visit(child);
2185
+ };
2186
+ visit(statement);
2187
+ return out;
2188
+ }
2189
+
2190
+ // src/analysis/exception-handling.ts
2191
+ var EXCEPTION_METRIC_NAMES = ["except_count", "except_density", "swallowed_except"];
2192
+ var HANDLER_TYPES = ["except_clause", "except_group_clause", "catch_clause"];
2193
+ var SILENT_STATEMENTS = /* @__PURE__ */ new Set(["pass_statement", "continue_statement", "empty_statement"]);
2194
+ var EMPTY_VALUES = /* @__PURE__ */ new Set(["None", "null", "undefined"]);
2195
+ var LOGGING_TOKENS = /* @__PURE__ */ new Set(["log", "logger", "logging", "warn", "warning", "warnings", "print", "console"]);
2196
+ var EXCEPT_CLAUSE_TYPES = /* @__PURE__ */ new Set(["except_clause", "except_group_clause"]);
2197
+ var OPTIONAL_IMPORT_EXCEPT_TYPES = /* @__PURE__ */ new Set(["ImportError", "ModuleNotFoundError"]);
2198
+ function exceptionMetrics(nodeId, root, loc) {
2199
+ const handlers = HANDLER_TYPES.flatMap((type) => descendantsOfType(root, type));
2200
+ const swallowed = handlers.filter((h) => isSwallowed(h)).length;
2201
+ return [
2202
+ { nodeId, name: "except_count", value: handlers.length, unit: "count" },
2203
+ { nodeId, name: "except_density", value: loc === 0 ? 0 : handlers.length * 100 / loc, unit: "per100loc" },
2204
+ { nodeId, name: "swallowed_except", value: swallowed, unit: "count" }
2205
+ ];
2206
+ }
2207
+ function isSwallowed(handler) {
2208
+ if (isOptionalImportExcept(handler)) return false;
2209
+ const body = handler.childForFieldName("body") ?? handler.namedChildren.find((c) => c?.type === "block");
2210
+ if (!body) return false;
2211
+ const statements = bodyStatements(body, null);
2212
+ if (statements.length === 0) return true;
2213
+ return statements.length === 1 && isSilent(statements[0]);
2214
+ }
2215
+ function isOptionalImportExcept(handler) {
2216
+ if (!EXCEPT_CLAUSE_TYPES.has(handler.type)) return false;
2217
+ const value = handler.childForFieldName("value");
2218
+ if (!value) return false;
2219
+ const typeExpr = value.type === "as_pattern" ? value.namedChild(0) : value;
2220
+ if (!typeExpr) return false;
2221
+ if (typeExpr.type === "identifier") return OPTIONAL_IMPORT_EXCEPT_TYPES.has(typeExpr.text);
2222
+ if (typeExpr.type !== "tuple") return false;
2223
+ const names = typeExpr.namedChildren.filter((c) => c !== null);
2224
+ return names.length > 0 && names.every((n) => n.type === "identifier" && OPTIONAL_IMPORT_EXCEPT_TYPES.has(n.text));
2225
+ }
2226
+ function isSilent(statement) {
2227
+ if (SILENT_STATEMENTS.has(statement.type)) return true;
2228
+ const value = statement.namedChildren[0];
2229
+ if (statement.type === "return_statement") return !value || EMPTY_VALUES.has(value.text);
2230
+ if (statement.type !== "expression_statement" || !value) return false;
2231
+ if (value.type === "ellipsis") return true;
2232
+ return isLoggingCall(value);
2233
+ }
2234
+ function isLoggingCall(node) {
2235
+ if (node.type !== "call" && node.type !== "call_expression") return false;
2236
+ const callee = node.childForFieldName("function");
2237
+ if (!callee) return false;
2238
+ for (const token of identifierTokens(callee.text)) if (LOGGING_TOKENS.has(token)) return true;
2239
+ return false;
2240
+ }
2241
+
1688
2242
  // src/cognitive-complexity.ts
1689
2243
  function cognitiveComplexityOf(body, language) {
1690
2244
  const handler = language === "python" ? PY_HANDLER : TS_HANDLER;
@@ -1937,12 +2491,97 @@ function areLinked(a, b) {
1937
2491
  return false;
1938
2492
  }
1939
2493
 
2494
+ // src/analysis/pass-through.ts
2495
+ var RECEIVERS = /* @__PURE__ */ new Set(["self", "cls"]);
2496
+ var CALL_TYPES = /* @__PURE__ */ new Set(["call", "call_expression"]);
2497
+ var WRAPPER_TYPES = /* @__PURE__ */ new Set(["return_statement", "expression_statement"]);
2498
+ var PY_NAMED_PARAMS = /* @__PURE__ */ new Set(["default_parameter", "typed_default_parameter"]);
2499
+ var PY_SEPARATORS = /* @__PURE__ */ new Set(["keyword_separator", "positional_separator"]);
2500
+ function isPassThrough(fn, body) {
2501
+ const call = soleCall(body);
2502
+ if (!call) return false;
2503
+ const params = parameterShapes(fn);
2504
+ if (!params || params.length === 0) return false;
2505
+ const args = argumentShapes(call);
2506
+ return args !== null && args.length === params.length && args.every((a, i) => a === params[i]);
2507
+ }
2508
+ function soleCall(body) {
2509
+ const statements = bodyStatements(body);
2510
+ if (statements.length !== 1) return null;
2511
+ const only = statements[0];
2512
+ if (CALL_TYPES.has(only.type)) return only;
2513
+ if (!WRAPPER_TYPES.has(only.type) || only.namedChildCount !== 1) return null;
2514
+ const value = only.namedChildren[0];
2515
+ return CALL_TYPES.has(value.type) ? value : null;
2516
+ }
2517
+ function parameterShapes(fn) {
2518
+ const bare = fn.childForFieldName("parameter");
2519
+ if (bare) return [bare.text];
2520
+ const list = fn.childForFieldName("parameters");
2521
+ if (!list) return null;
2522
+ const shapes = [];
2523
+ for (const p of list.namedChildren) {
2524
+ if (!p || p.type === "comment" || PY_SEPARATORS.has(p.type)) continue;
2525
+ const shape = parameterShape(p);
2526
+ if (shape === null) return null;
2527
+ shapes.push(shape);
2528
+ }
2529
+ return RECEIVERS.has(shapes[0] ?? "") ? shapes.slice(1) : shapes;
2530
+ }
2531
+ function parameterShape(p) {
2532
+ if (p.type === "identifier") return p.text;
2533
+ if (p.type === "list_splat_pattern" || p.type === "dictionary_splat_pattern") return p.text;
2534
+ if (PY_NAMED_PARAMS.has(p.type)) return p.childForFieldName("name")?.text ?? null;
2535
+ if (p.type === "typed_parameter") return parameterShape(p.namedChildren[0]);
2536
+ if (p.type === "required_parameter" || p.type === "optional_parameter") {
2537
+ const pattern = p.childForFieldName("pattern");
2538
+ return pattern ? tsPatternShape(pattern) : null;
2539
+ }
2540
+ return tsPatternShape(p);
2541
+ }
2542
+ function tsPatternShape(pattern) {
2543
+ if (pattern.type === "identifier") return pattern.text;
2544
+ if (pattern.type === "rest_pattern") return `...${pattern.namedChildren[0]?.text ?? ""}`;
2545
+ if (pattern.type === "assignment_pattern") {
2546
+ const left = pattern.childForFieldName("left");
2547
+ return left ? tsPatternShape(left) : null;
2548
+ }
2549
+ return null;
2550
+ }
2551
+ function argumentShapes(call) {
2552
+ const list = call.childForFieldName("arguments");
2553
+ if (!list || list.type !== "argument_list" && list.type !== "arguments") return null;
2554
+ const shapes = [];
2555
+ for (const a of list.namedChildren) {
2556
+ if (!a || a.type === "comment") continue;
2557
+ const shape = argumentShape(a);
2558
+ if (shape === null) return null;
2559
+ shapes.push(shape);
2560
+ }
2561
+ return shapes;
2562
+ }
2563
+ function argumentShape(a) {
2564
+ if (a.type === "identifier") return a.text;
2565
+ const inner = a.namedChildren[0];
2566
+ if (inner?.type !== "identifier") return null;
2567
+ if (a.type === "list_splat") return `*${inner.text}`;
2568
+ if (a.type === "dictionary_splat") return `**${inner.text}`;
2569
+ if (a.type === "spread_element") return `...${inner.text}`;
2570
+ return null;
2571
+ }
2572
+
1940
2573
  // src/symbol-metrics.ts
1941
2574
  var SYMBOL_METRICS = [
1942
2575
  { name: "symbol_cognitive", stat: "cognitive", unit: "count" },
1943
2576
  { name: "symbol_cyclomatic", stat: "cyclomatic", unit: "count" },
1944
2577
  { name: "symbol_loc", stat: "loc", unit: "lines" },
1945
- { name: "symbol_max_nesting", stat: "nestingDepth", unit: "count" }
2578
+ { name: "symbol_max_nesting", stat: "nestingDepth", unit: "count" },
2579
+ { name: "symbol_comment_lines", stat: "commentLines", unit: "lines" },
2580
+ { name: "symbol_docstring_lines", stat: "docstringLines", unit: "lines" },
2581
+ { name: "symbol_body_lines", stat: "bodyLines", unit: "lines" },
2582
+ { name: "symbol_comment_ratio", stat: "commentRatio", unit: "ratio" },
2583
+ { name: "symbol_narrating_comments", stat: "narratingComments", unit: "count" },
2584
+ { name: "symbol_pass_through", stat: "passThrough", unit: "count" }
1946
2585
  ];
1947
2586
  var SYMBOL_METRIC_NAMES = SYMBOL_METRICS.map((m) => m.name);
1948
2587
  function symbolMetrics(fileId2, stats, symbolNames) {
@@ -1966,6 +2605,13 @@ function symbolMetrics(fileId2, stats, symbolNames) {
1966
2605
  }
1967
2606
  return out;
1968
2607
  }
2608
+ function functionShapeStats(fn, body, lines) {
2609
+ return {
2610
+ ...commentLineStats(fn, body, lines),
2611
+ narratingComments: countNarratingComments(fn, body),
2612
+ passThrough: isPassThrough(fn, body) ? 1 : 0
2613
+ };
2614
+ }
1969
2615
 
1970
2616
  // src/source-metrics.ts
1971
2617
  var TS_FUNCTION_TYPES = /* @__PURE__ */ new Set([
@@ -2016,7 +2662,8 @@ var SOURCE_METRIC_NAMES = /* @__PURE__ */ new Set([
2016
2662
  "max_nesting_depth",
2017
2663
  "class_count",
2018
2664
  "lcom4_max",
2019
- // Per-symbol complexity, loc and nesting (C-58, C-64, TP-317), keyed
2665
+ ...EXCEPTION_METRIC_NAMES,
2666
+ // Per-symbol complexity, size, comments and shape (C-58, C-64, TP-317, TP-322), keyed
2020
2667
  // to `symbol` node ids. Source-local like the file-level metrics above, so an
2021
2668
  // unchanged file carries them forward — but their nodeId is `<fileId>#<name>`,
2022
2669
  // so the reuse basis buckets them under the symbol's parent file (incremental.ts).
@@ -2076,6 +2723,7 @@ function metricsForFile(nodeId, file, symbolNames) {
2076
2723
  }
2077
2724
  out.push(...symbolMetrics(nodeId, stats, symbolNames));
2078
2725
  out.push(...computeLcomMetrics(file, nodeId));
2726
+ out.push(...exceptionMetrics(nodeId, file.tree.rootNode, loc));
2079
2727
  return out;
2080
2728
  }
2081
2729
  function countLoc(content) {
@@ -2084,6 +2732,7 @@ function countLoc(content) {
2084
2732
  function analyzeFunctions(file) {
2085
2733
  const stats = [];
2086
2734
  const fnTypes = file.language === "python" ? PY_FUNCTION_TYPES : TS_FUNCTION_TYPES;
2735
+ const lines = file.content.split("\n");
2087
2736
  walkScopes(file.tree.rootNode, file.language === "python", (node, scope) => {
2088
2737
  const fn = functionAt(node, fnTypes);
2089
2738
  if (!fn) return;
@@ -2092,7 +2741,8 @@ function analyzeFunctions(file) {
2092
2741
  cyclomatic: cyclomaticOf(fn.body, file.language),
2093
2742
  cognitive: cognitiveComplexityOf(fn.body, file.language),
2094
2743
  nestingDepth: nestingDepthOf(fn.body, file.language, 0),
2095
- loc: fn.node.endPosition.row - fn.node.startPosition.row + 1
2744
+ loc: fn.node.endPosition.row - fn.node.startPosition.row + 1,
2745
+ ...functionShapeStats(fn.node, fn.body, lines)
2096
2746
  });
2097
2747
  });
2098
2748
  return stats;
@@ -2149,7 +2799,7 @@ var TERMINALS = /* @__PURE__ */ new Set([
2149
2799
  ]);
2150
2800
  var SCOPE_TYPES = /* @__PURE__ */ new Set(["function_definition", "lambda", "class_definition"]);
2151
2801
  var SPLAT_TYPES = /* @__PURE__ */ new Set(["list_splat_pattern", "dictionary_splat_pattern"]);
2152
- var RECEIVERS = /* @__PURE__ */ new Set(["self", "cls"]);
2802
+ var RECEIVERS2 = /* @__PURE__ */ new Set(["self", "cls"]);
2153
2803
  function pythonDeadCode(root) {
2154
2804
  const out = { unreachable: 0, locals: 0, params: 0 };
2155
2805
  const visit = (node) => {
@@ -2211,7 +2861,7 @@ function ownParamNames(fn) {
2211
2861
  for (const p of params.namedChildren) {
2212
2862
  if (!p || p.type === "keyword_separator" || p.type === "positional_separator") continue;
2213
2863
  const id = paramIdentifier(p);
2214
- out.push(id && !RECEIVERS.has(id.text) ? id.text : null);
2864
+ out.push(id && !RECEIVERS2.has(id.text) ? id.text : null);
2215
2865
  }
2216
2866
  return out;
2217
2867
  }
@@ -2680,9 +3330,10 @@ function loadReuseBasis(db, indexVersion) {
2680
3330
  }
2681
3331
  const edgesBySrc = /* @__PURE__ */ new Map();
2682
3332
  for (const e of db.listEdges(snap.id, { includeReferences: true })) {
2683
- const bucket = edgesBySrc.get(e.srcId);
3333
+ const fileKey = parseSymbolId(e.srcId)?.fileId ?? e.srcId;
3334
+ const bucket = edgesBySrc.get(fileKey);
2684
3335
  if (bucket) bucket.push(e);
2685
- else edgesBySrc.set(e.srcId, [e]);
3336
+ else edgesBySrc.set(fileKey, [e]);
2686
3337
  }
2687
3338
  const sourceMetricsByFile = /* @__PURE__ */ new Map();
2688
3339
  for (const m of db.listMetrics(snap.id)) {
@@ -2801,6 +3452,7 @@ function weightedInbound(nodes, edges) {
2801
3452
  const util = /* @__PURE__ */ new Map();
2802
3453
  for (const n of nodes) util.set(n.id, 0);
2803
3454
  for (const e of resolveBarrelEdges(nodes, edges)) {
3455
+ if (e.kind === "calls") continue;
2804
3456
  if (util.has(e.dstId)) util.set(e.dstId, util.get(e.dstId) + edgeWeight(e));
2805
3457
  }
2806
3458
  return util;
@@ -2831,6 +3483,58 @@ function computeMetrics(nodes, edges) {
2831
3483
  return out;
2832
3484
  }
2833
3485
 
3486
+ // src/analysis/call-metrics.ts
3487
+ var ABSENT = "<absent>";
3488
+ function computeCallMetrics(nodes, edges) {
3489
+ const inbound = collectInbound(edges);
3490
+ const out = [];
3491
+ for (const node of nodes) {
3492
+ if (node.kind !== "symbol" || node.attrs?.startLine === void 0) continue;
3493
+ const { callers, sites } = inbound.get(node.id) ?? { callers: /* @__PURE__ */ new Set(), sites: [] };
3494
+ out.push({ nodeId: node.id, name: "symbol_caller_count", value: callers.size, unit: "count" });
3495
+ const single = isPrivateHelper(node) && callers.size === 1 && isSymbolCaller(callers);
3496
+ out.push({ nodeId: node.id, name: "symbol_single_caller_helper", value: single ? 1 : 0, unit: "count" });
3497
+ const shape = readParamShape(node.attrs);
3498
+ if (shape && sites.length >= 2) {
3499
+ out.push({ nodeId: node.id, name: "symbol_constant_params", value: constantParams(shape, sites), unit: "count" });
3500
+ }
3501
+ }
3502
+ return out;
3503
+ }
3504
+ function collectInbound(edges) {
3505
+ const inbound = /* @__PURE__ */ new Map();
3506
+ for (const edge of edges) {
3507
+ if (edge.kind !== "calls") continue;
3508
+ let entry = inbound.get(edge.dstId);
3509
+ if (!entry) inbound.set(edge.dstId, entry = { callers: /* @__PURE__ */ new Set(), sites: [] });
3510
+ if (edge.srcId !== edge.dstId) entry.callers.add(edge.srcId);
3511
+ entry.sites.push(...edge.attrs?.sites ?? []);
3512
+ }
3513
+ return inbound;
3514
+ }
3515
+ function isPrivateHelper(node) {
3516
+ const own = node.name.slice(node.name.lastIndexOf(".") + 1);
3517
+ return node.attrs?.exported === false && !(own.startsWith("__") && own.endsWith("__"));
3518
+ }
3519
+ function isSymbolCaller(callers) {
3520
+ return [...callers].every((id) => parseSymbolId(id) !== null);
3521
+ }
3522
+ function constantParams(shape, sites) {
3523
+ let count = 0;
3524
+ shape.params.forEach((name, index) => {
3525
+ const values = sites.map((site) => argumentAt(site, name, index < shape.positional ? index : -1));
3526
+ if (values.every((v) => v !== null && v === values[0])) count++;
3527
+ });
3528
+ return count;
3529
+ }
3530
+ function argumentAt(site, name, position) {
3531
+ if (position >= 0 && position < site.args.length) return site.args[position] ?? null;
3532
+ if (site.kwargs && Object.hasOwn(site.kwargs, name)) return site.kwargs[name] ?? null;
3533
+ if (site.kwSplat) return null;
3534
+ if (position >= 0 && site.spreadFrom !== void 0 && position >= site.spreadFrom) return null;
3535
+ return ABSENT;
3536
+ }
3537
+
2834
3538
  // src/analysis/test-linker.ts
2835
3539
  var DEFAULT_MIN_CO_EDIT_COUNT = 2;
2836
3540
  var TEST_INFIX_RE = /\.(?:test|spec)(\.[^./]+)$/;
@@ -3040,6 +3744,7 @@ function buildIndexerMetrics(input) {
3040
3744
  const history = input.history ? loadHistoryMetrics(nodeList, input.idRoot, input.history) : null;
3041
3745
  return [
3042
3746
  ...computeMetrics(nodeList, [...input.edges.values()]),
3747
+ ...computeCallMetrics(nodeList, input.edges.values()),
3043
3748
  ...computeSourceMetrics(
3044
3749
  input.parsedFiles,
3045
3750
  (p) => fileId(input.idRoot, p),
@@ -3118,7 +3823,7 @@ function qualifiedByName(file) {
3118
3823
  }
3119
3824
 
3120
3825
  // src/indexer.ts
3121
- var INDEX_VERSION = "0.16.0";
3826
+ var INDEX_VERSION = "0.18.0";
3122
3827
  var LANGUAGES = ["typescript", "python"];
3123
3828
  function canonicalizePath(p) {
3124
3829
  try {
@@ -3129,7 +3834,7 @@ function canonicalizePath(p) {
3129
3834
  }
3130
3835
  function normalizePaths(paths) {
3131
3836
  if (paths.length === 0) throw new Error("indexPaths requires at least one path");
3132
- return [...new Set(paths.map((p) => path9.resolve(p)))];
3837
+ return [...new Set(paths.map((p) => path8.resolve(p)))];
3133
3838
  }
3134
3839
  function countByKind(items) {
3135
3840
  const out = {};
@@ -3233,9 +3938,9 @@ import { getLanguageFromPath as getLanguageFromPath3, getSupportedLanguages, par
3233
3938
 
3234
3939
  // src/extractors/deep-ast.ts
3235
3940
  import { readFileSync as readFileSync2 } from "fs";
3236
- import * as path10 from "path";
3941
+ import * as path9 from "path";
3237
3942
  import {
3238
- Node as Node4,
3943
+ Node as Node6,
3239
3944
  Project as Project2,
3240
3945
  ScriptTarget as ScriptTarget2
3241
3946
  } from "ts-morph";
@@ -3252,7 +3957,7 @@ function loadSourceFile(absPath) {
3252
3957
  useInMemoryFileSystem: true,
3253
3958
  compilerOptions: { target: ScriptTarget2.Latest, allowJs: true }
3254
3959
  });
3255
- return project.createSourceFile(`f${path10.extname(absPath) || ".ts"}`, text);
3960
+ return project.createSourceFile(`f${path9.extname(absPath) || ".ts"}`, text);
3256
3961
  } catch {
3257
3962
  return null;
3258
3963
  }
@@ -3294,10 +3999,10 @@ function empty(target, kind) {
3294
3999
  };
3295
4000
  }
3296
4001
  function callableOf(decl) {
3297
- if (Node4.isFunctionDeclaration(decl) || Node4.isMethodDeclaration(decl)) return decl;
3298
- if (Node4.isVariableDeclaration(decl)) {
4002
+ if (Node6.isFunctionDeclaration(decl) || Node6.isMethodDeclaration(decl)) return decl;
4003
+ if (Node6.isVariableDeclaration(decl)) {
3299
4004
  const init = decl.getInitializer();
3300
- if (init && (Node4.isArrowFunction(init) || Node4.isFunctionExpression(init))) return init;
4005
+ if (init && (Node6.isArrowFunction(init) || Node6.isFunctionExpression(init))) return init;
3301
4006
  }
3302
4007
  return null;
3303
4008
  }
@@ -3313,8 +4018,8 @@ function returnTypeOf(decl) {
3313
4018
  return callableOf(decl)?.getReturnTypeNode()?.getText() ?? null;
3314
4019
  }
3315
4020
  function membersOf(decl) {
3316
- if (Node4.isClassDeclaration(decl)) return classMembers(decl);
3317
- if (Node4.isInterfaceDeclaration(decl)) return interfaceMembers(decl);
4021
+ if (Node6.isClassDeclaration(decl)) return classMembers(decl);
4022
+ if (Node6.isInterfaceDeclaration(decl)) return interfaceMembers(decl);
3318
4023
  return [];
3319
4024
  }
3320
4025
  function classMembers(cls) {
@@ -3322,7 +4027,7 @@ function classMembers(cls) {
3322
4027
  name: memberName(m),
3323
4028
  memberKind: m.getKindName(),
3324
4029
  signature: memberSignature(m),
3325
- isStatic: Node4.isStaticable(m) ? m.isStatic() : false
4030
+ isStatic: Node6.isStaticable(m) ? m.isStatic() : false
3326
4031
  }));
3327
4032
  }
3328
4033
  function interfaceMembers(iface) {
@@ -3348,17 +4053,17 @@ function exportedDeclarations(sf) {
3348
4053
  return out;
3349
4054
  }
3350
4055
  function memberName(m) {
3351
- if (Node4.isConstructorDeclaration(m)) return "constructor";
3352
- return Node4.hasName(m) ? m.getName() : "";
4056
+ if (Node6.isConstructorDeclaration(m)) return "constructor";
4057
+ return Node6.hasName(m) ? m.getName() : "";
3353
4058
  }
3354
4059
  function memberSignature(m) {
3355
- if (Node4.isMethodDeclaration(m) || Node4.isMethodSignature(m) || Node4.isConstructorDeclaration(m)) {
4060
+ if (Node6.isMethodDeclaration(m) || Node6.isMethodSignature(m) || Node6.isConstructorDeclaration(m)) {
3356
4061
  const params = m.getParameters().map((p) => oneLine2(p.getText())).join(", ");
3357
4062
  const ret = m.getReturnTypeNode()?.getText();
3358
- const name = Node4.isConstructorDeclaration(m) ? "constructor" : memberName(m);
4063
+ const name = Node6.isConstructorDeclaration(m) ? "constructor" : memberName(m);
3359
4064
  return oneLine2(`${name}(${params})${ret ? `: ${ret}` : ""}`);
3360
4065
  }
3361
- if (Node4.isPropertyDeclaration(m) || Node4.isPropertySignature(m)) {
4066
+ if (Node6.isPropertyDeclaration(m) || Node6.isPropertySignature(m)) {
3362
4067
  const type = m.getTypeNode()?.getText();
3363
4068
  return oneLine2(`${memberName(m)}${type ? `: ${type}` : ""}`);
3364
4069
  }
@@ -4106,6 +4811,126 @@ var SOURCE = [
4106
4811
  source: "source-metrics",
4107
4812
  description: "Deepest block nesting inside the function a symbol names; the max when several share the name."
4108
4813
  },
4814
+ {
4815
+ name: "symbol_comment_lines",
4816
+ unit: "lines",
4817
+ appliesTo: SYMBOL,
4818
+ rollup: "max",
4819
+ direction: "neutral",
4820
+ absent: "exclude",
4821
+ source: "source-metrics",
4822
+ description: "Rows holding a comment inside the function a symbol names, the docstring excluded; the max when several share the name."
4823
+ },
4824
+ {
4825
+ name: "symbol_docstring_lines",
4826
+ unit: "lines",
4827
+ appliesTo: SYMBOL,
4828
+ rollup: "max",
4829
+ direction: "neutral",
4830
+ absent: "exclude",
4831
+ source: "source-metrics",
4832
+ description: "Rows of the function's docstring: the Python docstring, or the JSDoc block attached to a TypeScript declaration."
4833
+ },
4834
+ {
4835
+ name: "symbol_body_lines",
4836
+ unit: "lines",
4837
+ appliesTo: SYMBOL,
4838
+ rollup: "max",
4839
+ direction: "higher-worse",
4840
+ absent: "exclude",
4841
+ source: "source-metrics",
4842
+ description: "Non-blank rows of the function's body that hold code, comments and the docstring excluded."
4843
+ },
4844
+ {
4845
+ name: "symbol_comment_ratio",
4846
+ unit: "ratio",
4847
+ appliesTo: SYMBOL,
4848
+ rollup: "max",
4849
+ direction: "neutral",
4850
+ absent: "exclude",
4851
+ source: "source-metrics",
4852
+ description: "symbol_comment_lines / max(symbol_body_lines, 1). High values suggest narration; zero is not a defect."
4853
+ },
4854
+ {
4855
+ name: "symbol_narrating_comments",
4856
+ unit: "count",
4857
+ appliesTo: SYMBOL,
4858
+ rollup: "max",
4859
+ direction: "higher-worse",
4860
+ absent: "exclude",
4861
+ source: "source-metrics",
4862
+ description: "Comments in the body whose words overlap the next statement's identifiers by 2 tokens or half the comment. A candidate signal."
4863
+ },
4864
+ {
4865
+ name: "symbol_pass_through",
4866
+ unit: "count",
4867
+ appliesTo: SYMBOL,
4868
+ rollup: "max",
4869
+ direction: "higher-worse",
4870
+ absent: "exclude",
4871
+ source: "source-metrics",
4872
+ description: "1 when the function's body is one call forwarding every parameter, in order, as a bare argument; else 0."
4873
+ },
4874
+ {
4875
+ name: "symbol_caller_count",
4876
+ unit: "count",
4877
+ appliesTo: SYMBOL,
4878
+ rollup: "max",
4879
+ direction: "neutral",
4880
+ absent: "exclude",
4881
+ source: "call-graph",
4882
+ description: "Distinct symbols, or files at module level, with a resolved call to this function, method or class. Unresolved calls are not counted."
4883
+ },
4884
+ {
4885
+ name: "symbol_single_caller_helper",
4886
+ unit: "count",
4887
+ appliesTo: SYMBOL,
4888
+ rollup: "sum",
4889
+ direction: "higher-worse",
4890
+ absent: "exclude",
4891
+ source: "call-graph",
4892
+ description: "1 when a non-exported symbol has exactly one resolved caller and that caller is a symbol; else 0."
4893
+ },
4894
+ {
4895
+ name: "symbol_constant_params",
4896
+ unit: "count",
4897
+ appliesTo: SYMBOL,
4898
+ rollup: "max",
4899
+ direction: "higher-worse",
4900
+ absent: "exclude",
4901
+ source: "call-graph",
4902
+ description: "Parameters that every one of at least 2 resolved call sites passes the same literal, or never passes. Rest and keyword-splat parameters are ignored."
4903
+ },
4904
+ {
4905
+ name: "except_count",
4906
+ unit: "count",
4907
+ appliesTo: FILE,
4908
+ rollup: "sum",
4909
+ direction: "neutral",
4910
+ absent: "zero",
4911
+ source: "exception-handling",
4912
+ description: "Python except clauses and TypeScript catch clauses in the file."
4913
+ },
4914
+ {
4915
+ name: "except_density",
4916
+ unit: "per100loc",
4917
+ appliesTo: FILE,
4918
+ rollup: "none",
4919
+ direction: "higher-worse",
4920
+ absent: "zero",
4921
+ source: "exception-handling",
4922
+ description: "except_count per 100 non-blank lines. Recompute from the sums for a group."
4923
+ },
4924
+ {
4925
+ name: "swallowed_except",
4926
+ unit: "count",
4927
+ appliesTo: FILE,
4928
+ rollup: "sum",
4929
+ direction: "higher-worse",
4930
+ absent: "zero",
4931
+ source: "exception-handling",
4932
+ description: "Handlers whose body is empty, pass, ..., continue, a bare or empty return, or a single logging call. Exempts Python's `except ImportError`/`ModuleNotFoundError` (alone or paired), the standard optional-dependency idiom."
4933
+ },
4109
4934
  {
4110
4935
  name: "class_count",
4111
4936
  unit: "count",
@@ -4529,6 +5354,45 @@ function locateNode(node) {
4529
5354
  return location;
4530
5355
  }
4531
5356
 
5357
+ // src/check/outlier-rule.ts
5358
+ var DEFAULT_OUTLIER_MIN_SAMPLE = 20;
5359
+ function runMetricOutlierRule(rule, ctx) {
5360
+ const pool = rule.kind === "symbol" ? ctx.symbolNodes : ctx.nodes;
5361
+ const carriers = [];
5362
+ for (const node of pool) {
5363
+ if (node.kind !== rule.kind) continue;
5364
+ const value = ctx.metricsByNode.get(node.id)?.get(rule.metric);
5365
+ if (value !== void 0) carriers.push({ node, value });
5366
+ }
5367
+ const ranked = rule.rankNonZero ? carriers.filter((c) => c.value > 0) : carriers;
5368
+ if (ranked.length < (rule.minSample ?? DEFAULT_OUTLIER_MIN_SAMPLE)) return [];
5369
+ const threshold = percentileOf(ranked.map((c) => c.value), rule.percentile);
5370
+ return ranked.filter((c) => c.value > threshold && (rule.floor === void 0 || c.value > rule.floor)).map((c) => toViolation(rule, c.node, c.value, threshold));
5371
+ }
5372
+ function percentileOf(values, percentile) {
5373
+ const sorted = [...values].sort((a, b) => a - b);
5374
+ const rank = percentile / 100 * (sorted.length - 1);
5375
+ const lower = Math.floor(rank);
5376
+ const upper = Math.min(lower + 1, sorted.length - 1);
5377
+ return sorted[lower] + (sorted[upper] - sorted[lower]) * (rank - lower);
5378
+ }
5379
+ function toViolation(rule, node, value, threshold) {
5380
+ const floorSuffix = rule.floor === void 0 ? "" : `, floor ${formatNumber(rule.floor)}`;
5381
+ const bound = `p${formatNumber(rule.percentile)} ${formatNumber(threshold)}${floorSuffix}`;
5382
+ return {
5383
+ ruleId: rule.id,
5384
+ severity: severityOf(rule),
5385
+ nodeId: node.id,
5386
+ metric: rule.metric,
5387
+ value,
5388
+ threshold,
5389
+ message: `${rule.metric}=${formatNumber(value)} > ${bound}`,
5390
+ ...locateNode(node),
5391
+ evidence: `${rule.metric}=${formatNumber(value)} (${bound})`,
5392
+ tool: CODE_GRAPH_TOOL
5393
+ };
5394
+ }
5395
+
4532
5396
  // src/check/metric-rules.ts
4533
5397
  function nodeFilter(rule) {
4534
5398
  return {
@@ -4548,7 +5412,7 @@ function candidateNodes(rule, ctx) {
4548
5412
  const pool = rule.kind === "symbol" ? ctx.symbolNodes : ctx.nodes;
4549
5413
  return pool.filter((node) => passesFilter(node, filter));
4550
5414
  }
4551
- function toViolation(rule, breach) {
5415
+ function toViolation2(rule, breach) {
4552
5416
  return {
4553
5417
  ruleId: rule.id,
4554
5418
  severity: severityOf(rule),
@@ -4578,7 +5442,7 @@ function runMetricMaxRule(rule, ctx) {
4578
5442
  for (const node of candidateNodes(rule, ctx)) {
4579
5443
  const value = ctx.metricsByNode.get(node.id)?.get(rule.metric);
4580
5444
  if (value === void 0 || value <= rule.max) continue;
4581
- out.push(toViolation(rule, thresholdBreach(node, rule.metric, value, "max", rule.max)));
5445
+ out.push(toViolation2(rule, thresholdBreach(node, rule.metric, value, "max", rule.max)));
4582
5446
  }
4583
5447
  return out;
4584
5448
  }
@@ -4587,7 +5451,7 @@ function runMetricMinRule(rule, ctx) {
4587
5451
  for (const node of candidateNodes(rule, ctx)) {
4588
5452
  const value = ctx.metricsByNode.get(node.id)?.get(rule.metric);
4589
5453
  if (value === void 0 || value >= rule.min) continue;
4590
- out.push(toViolation(rule, thresholdBreach(node, rule.metric, value, "min", rule.min)));
5454
+ out.push(toViolation2(rule, thresholdBreach(node, rule.metric, value, "min", rule.min)));
4591
5455
  }
4592
5456
  return out;
4593
5457
  }
@@ -4600,7 +5464,7 @@ function runMetricProductMaxRule(rule, ctx) {
4600
5464
  if (!components) continue;
4601
5465
  const product = components.reduce((a, b) => a * b, 1);
4602
5466
  if (product <= rule.max) continue;
4603
- out.push(toViolation(rule, productBreach(rule, node, components, product)));
5467
+ out.push(toViolation2(rule, productBreach(rule, node, components, product)));
4604
5468
  }
4605
5469
  return out;
4606
5470
  }
@@ -4634,6 +5498,8 @@ function runRule(rule, ctx) {
4634
5498
  return runMetricMinRule(rule, ctx);
4635
5499
  case "metric-product-max":
4636
5500
  return runMetricProductMaxRule(rule, ctx);
5501
+ case "metric-outlier":
5502
+ return runMetricOutlierRule(rule, ctx);
4637
5503
  case "forbid-import":
4638
5504
  return runForbidImportRule(rule, ctx);
4639
5505
  case "layered-deps":
@@ -4778,6 +5644,8 @@ function validateRule(raw, index, warn) {
4778
5644
  return assertMetricMin(r, warn);
4779
5645
  case "metric-product-max":
4780
5646
  return assertMetricProductMax(r, warn);
5647
+ case "metric-outlier":
5648
+ return assertMetricOutlier(r, warn);
4781
5649
  case "forbid-import":
4782
5650
  return assertForbidImport(r);
4783
5651
  case "layered-deps":
@@ -4887,6 +5755,37 @@ function assertMetricMin(r, warn) {
4887
5755
  excludeRoles: parseRoleArray(ruleId, r.excludeRoles, warn)
4888
5756
  };
4889
5757
  }
5758
+ var NODE_KINDS = /* @__PURE__ */ new Set(["package", "module", "file", "symbol", "external"]);
5759
+ function assertMetricOutlier(r, warn) {
5760
+ if (typeof r.metric !== "string") throw new Error(`${r.id}: metric must be a string`);
5761
+ if (typeof r.kind !== "string" || !NODE_KINDS.has(r.kind)) {
5762
+ throw new Error(`${r.id}: kind must be one of ${[...NODE_KINDS].join(", ")}`);
5763
+ }
5764
+ if (typeof r.percentile !== "number" || r.percentile < 50 || r.percentile > 100) {
5765
+ throw new Error(`${r.id}: percentile must be a number from 50 to 100`);
5766
+ }
5767
+ if (r.minSample !== void 0 && (!Number.isInteger(r.minSample) || r.minSample < 1)) {
5768
+ throw new Error(`${r.id}: minSample must be a positive integer`);
5769
+ }
5770
+ if (r.floor !== void 0 && (typeof r.floor !== "number" || !Number.isFinite(r.floor) || r.floor < 0)) {
5771
+ throw new Error(`${r.id}: floor must be a non-negative finite number`);
5772
+ }
5773
+ if (r.rankNonZero !== void 0 && typeof r.rankNonZero !== "boolean") {
5774
+ throw new Error(`${r.id}: rankNonZero must be a boolean`);
5775
+ }
5776
+ const ruleId = r.id;
5777
+ return {
5778
+ type: "metric-outlier",
5779
+ id: ruleId,
5780
+ metric: healMetricName(r.metric, ruleId, warn),
5781
+ kind: r.kind,
5782
+ percentile: r.percentile,
5783
+ minSample: r.minSample,
5784
+ floor: r.floor,
5785
+ rankNonZero: r.rankNonZero,
5786
+ severity: r.severity
5787
+ };
5788
+ }
4890
5789
  function assertForbidImport(r) {
4891
5790
  if (typeof r.from !== "string") throw new Error(`${r.id}: from must be a string`);
4892
5791
  if (typeof r.to !== "string") throw new Error(`${r.id}: to must be a string`);
@@ -4955,18 +5854,18 @@ function checkSnapshot(store, options) {
4955
5854
  });
4956
5855
  return { snapshot, baselineSnapshot, result };
4957
5856
  }
4958
- async function loadCheckRules(path12, options = {}) {
5857
+ async function loadCheckRules(path11, options = {}) {
4959
5858
  let raw;
4960
5859
  try {
4961
- raw = await readFile2(path12, "utf8");
5860
+ raw = await readFile2(path11, "utf8");
4962
5861
  } catch (err) {
4963
- throw new Error(`Cannot read rules file at ${path12}: ${errorMessage(err)}`);
5862
+ throw new Error(`Cannot read rules file at ${path11}: ${errorMessage(err)}`);
4964
5863
  }
4965
5864
  let parsed;
4966
5865
  try {
4967
5866
  parsed = JSON.parse(raw);
4968
5867
  } catch (err) {
4969
- throw new Error(`Invalid JSON in ${path12}: ${errorMessage(err)}`);
5868
+ throw new Error(`Invalid JSON in ${path11}: ${errorMessage(err)}`);
4970
5869
  }
4971
5870
  return validateRules(parsed, options);
4972
5871
  }
@@ -5378,7 +6277,7 @@ function pushMulti(map, key, value) {
5378
6277
  }
5379
6278
 
5380
6279
  // src/conventions/areas.ts
5381
- import * as path11 from "path";
6280
+ import * as path10 from "path";
5382
6281
  import { contentHashOf as contentHashOf2 } from "@titan-design/store-sqlite";
5383
6282
  var DEFAULT_MIN_SIZE = 3;
5384
6283
  var PROMPT_FILE_CAP = 30;
@@ -5462,7 +6361,7 @@ function rankByDegree(members, edges) {
5462
6361
  function dominantDirectory(files) {
5463
6362
  const counts = /* @__PURE__ */ new Map();
5464
6363
  for (const f of files) {
5465
- const dir = path11.posix.dirname(f);
6364
+ const dir = path10.posix.dirname(f);
5466
6365
  counts.set(dir, (counts.get(dir) ?? 0) + 1);
5467
6366
  }
5468
6367
  const dirs = [...counts.entries()].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1));
@@ -5587,7 +6486,9 @@ export {
5587
6486
  CodeGraphStore,
5588
6487
  DEAD_CODE_METRIC_NAMES,
5589
6488
  DEFAULT_CHURN_WINDOWS,
6489
+ DEFAULT_OUTLIER_MIN_SAMPLE,
5590
6490
  DOMAIN_DDL,
6491
+ EXCEPTION_METRIC_NAMES,
5591
6492
  GROWTH_RISK_METRIC_NAMES,
5592
6493
  INDEX_VERSION,
5593
6494
  KIT,
@@ -5600,6 +6501,7 @@ export {
5600
6501
  SOURCE_METRIC_NAMES,
5601
6502
  SYMBOL_EMBEDDING_NAMESPACE,
5602
6503
  SYMBOL_ID_SEP,
6504
+ SYMBOL_METRIC_NAMES,
5603
6505
  TsMorphGraphExtractor,
5604
6506
  aggregateMetrics,
5605
6507
  aliasChain,
@@ -5682,6 +6584,7 @@ export {
5682
6584
  parseFile2 as parseFile,
5683
6585
  parseSymbolId,
5684
6586
  patternToRegex,
6587
+ percentileOf,
5685
6588
  planPrune,
5686
6589
  priorSnapshotForRef,
5687
6590
  pruneDanglingReferences,