@titan-design/code-graph 0.7.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) {
@@ -1867,6 +2193,8 @@ var HANDLER_TYPES = ["except_clause", "except_group_clause", "catch_clause"];
1867
2193
  var SILENT_STATEMENTS = /* @__PURE__ */ new Set(["pass_statement", "continue_statement", "empty_statement"]);
1868
2194
  var EMPTY_VALUES = /* @__PURE__ */ new Set(["None", "null", "undefined"]);
1869
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"]);
1870
2198
  function exceptionMetrics(nodeId, root, loc) {
1871
2199
  const handlers = HANDLER_TYPES.flatMap((type) => descendantsOfType(root, type));
1872
2200
  const swallowed = handlers.filter((h) => isSwallowed(h)).length;
@@ -1877,12 +2205,24 @@ function exceptionMetrics(nodeId, root, loc) {
1877
2205
  ];
1878
2206
  }
1879
2207
  function isSwallowed(handler) {
2208
+ if (isOptionalImportExcept(handler)) return false;
1880
2209
  const body = handler.childForFieldName("body") ?? handler.namedChildren.find((c) => c?.type === "block");
1881
2210
  if (!body) return false;
1882
2211
  const statements = bodyStatements(body, null);
1883
2212
  if (statements.length === 0) return true;
1884
2213
  return statements.length === 1 && isSilent(statements[0]);
1885
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
+ }
1886
2226
  function isSilent(statement) {
1887
2227
  if (SILENT_STATEMENTS.has(statement.type)) return true;
1888
2228
  const value = statement.namedChildren[0];
@@ -2990,9 +3330,10 @@ function loadReuseBasis(db, indexVersion) {
2990
3330
  }
2991
3331
  const edgesBySrc = /* @__PURE__ */ new Map();
2992
3332
  for (const e of db.listEdges(snap.id, { includeReferences: true })) {
2993
- const bucket = edgesBySrc.get(e.srcId);
3333
+ const fileKey = parseSymbolId(e.srcId)?.fileId ?? e.srcId;
3334
+ const bucket = edgesBySrc.get(fileKey);
2994
3335
  if (bucket) bucket.push(e);
2995
- else edgesBySrc.set(e.srcId, [e]);
3336
+ else edgesBySrc.set(fileKey, [e]);
2996
3337
  }
2997
3338
  const sourceMetricsByFile = /* @__PURE__ */ new Map();
2998
3339
  for (const m of db.listMetrics(snap.id)) {
@@ -3111,6 +3452,7 @@ function weightedInbound(nodes, edges) {
3111
3452
  const util = /* @__PURE__ */ new Map();
3112
3453
  for (const n of nodes) util.set(n.id, 0);
3113
3454
  for (const e of resolveBarrelEdges(nodes, edges)) {
3455
+ if (e.kind === "calls") continue;
3114
3456
  if (util.has(e.dstId)) util.set(e.dstId, util.get(e.dstId) + edgeWeight(e));
3115
3457
  }
3116
3458
  return util;
@@ -3141,6 +3483,58 @@ function computeMetrics(nodes, edges) {
3141
3483
  return out;
3142
3484
  }
3143
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
+
3144
3538
  // src/analysis/test-linker.ts
3145
3539
  var DEFAULT_MIN_CO_EDIT_COUNT = 2;
3146
3540
  var TEST_INFIX_RE = /\.(?:test|spec)(\.[^./]+)$/;
@@ -3350,6 +3744,7 @@ function buildIndexerMetrics(input) {
3350
3744
  const history = input.history ? loadHistoryMetrics(nodeList, input.idRoot, input.history) : null;
3351
3745
  return [
3352
3746
  ...computeMetrics(nodeList, [...input.edges.values()]),
3747
+ ...computeCallMetrics(nodeList, input.edges.values()),
3353
3748
  ...computeSourceMetrics(
3354
3749
  input.parsedFiles,
3355
3750
  (p) => fileId(input.idRoot, p),
@@ -3428,7 +3823,7 @@ function qualifiedByName(file) {
3428
3823
  }
3429
3824
 
3430
3825
  // src/indexer.ts
3431
- var INDEX_VERSION = "0.17.0";
3826
+ var INDEX_VERSION = "0.18.0";
3432
3827
  var LANGUAGES = ["typescript", "python"];
3433
3828
  function canonicalizePath(p) {
3434
3829
  try {
@@ -3439,7 +3834,7 @@ function canonicalizePath(p) {
3439
3834
  }
3440
3835
  function normalizePaths(paths) {
3441
3836
  if (paths.length === 0) throw new Error("indexPaths requires at least one path");
3442
- return [...new Set(paths.map((p) => path9.resolve(p)))];
3837
+ return [...new Set(paths.map((p) => path8.resolve(p)))];
3443
3838
  }
3444
3839
  function countByKind(items) {
3445
3840
  const out = {};
@@ -3543,9 +3938,9 @@ import { getLanguageFromPath as getLanguageFromPath3, getSupportedLanguages, par
3543
3938
 
3544
3939
  // src/extractors/deep-ast.ts
3545
3940
  import { readFileSync as readFileSync2 } from "fs";
3546
- import * as path10 from "path";
3941
+ import * as path9 from "path";
3547
3942
  import {
3548
- Node as Node4,
3943
+ Node as Node6,
3549
3944
  Project as Project2,
3550
3945
  ScriptTarget as ScriptTarget2
3551
3946
  } from "ts-morph";
@@ -3562,7 +3957,7 @@ function loadSourceFile(absPath) {
3562
3957
  useInMemoryFileSystem: true,
3563
3958
  compilerOptions: { target: ScriptTarget2.Latest, allowJs: true }
3564
3959
  });
3565
- return project.createSourceFile(`f${path10.extname(absPath) || ".ts"}`, text);
3960
+ return project.createSourceFile(`f${path9.extname(absPath) || ".ts"}`, text);
3566
3961
  } catch {
3567
3962
  return null;
3568
3963
  }
@@ -3604,10 +3999,10 @@ function empty(target, kind) {
3604
3999
  };
3605
4000
  }
3606
4001
  function callableOf(decl) {
3607
- if (Node4.isFunctionDeclaration(decl) || Node4.isMethodDeclaration(decl)) return decl;
3608
- if (Node4.isVariableDeclaration(decl)) {
4002
+ if (Node6.isFunctionDeclaration(decl) || Node6.isMethodDeclaration(decl)) return decl;
4003
+ if (Node6.isVariableDeclaration(decl)) {
3609
4004
  const init = decl.getInitializer();
3610
- if (init && (Node4.isArrowFunction(init) || Node4.isFunctionExpression(init))) return init;
4005
+ if (init && (Node6.isArrowFunction(init) || Node6.isFunctionExpression(init))) return init;
3611
4006
  }
3612
4007
  return null;
3613
4008
  }
@@ -3623,8 +4018,8 @@ function returnTypeOf(decl) {
3623
4018
  return callableOf(decl)?.getReturnTypeNode()?.getText() ?? null;
3624
4019
  }
3625
4020
  function membersOf(decl) {
3626
- if (Node4.isClassDeclaration(decl)) return classMembers(decl);
3627
- if (Node4.isInterfaceDeclaration(decl)) return interfaceMembers(decl);
4021
+ if (Node6.isClassDeclaration(decl)) return classMembers(decl);
4022
+ if (Node6.isInterfaceDeclaration(decl)) return interfaceMembers(decl);
3628
4023
  return [];
3629
4024
  }
3630
4025
  function classMembers(cls) {
@@ -3632,7 +4027,7 @@ function classMembers(cls) {
3632
4027
  name: memberName(m),
3633
4028
  memberKind: m.getKindName(),
3634
4029
  signature: memberSignature(m),
3635
- isStatic: Node4.isStaticable(m) ? m.isStatic() : false
4030
+ isStatic: Node6.isStaticable(m) ? m.isStatic() : false
3636
4031
  }));
3637
4032
  }
3638
4033
  function interfaceMembers(iface) {
@@ -3658,17 +4053,17 @@ function exportedDeclarations(sf) {
3658
4053
  return out;
3659
4054
  }
3660
4055
  function memberName(m) {
3661
- if (Node4.isConstructorDeclaration(m)) return "constructor";
3662
- return Node4.hasName(m) ? m.getName() : "";
4056
+ if (Node6.isConstructorDeclaration(m)) return "constructor";
4057
+ return Node6.hasName(m) ? m.getName() : "";
3663
4058
  }
3664
4059
  function memberSignature(m) {
3665
- if (Node4.isMethodDeclaration(m) || Node4.isMethodSignature(m) || Node4.isConstructorDeclaration(m)) {
4060
+ if (Node6.isMethodDeclaration(m) || Node6.isMethodSignature(m) || Node6.isConstructorDeclaration(m)) {
3666
4061
  const params = m.getParameters().map((p) => oneLine2(p.getText())).join(", ");
3667
4062
  const ret = m.getReturnTypeNode()?.getText();
3668
- const name = Node4.isConstructorDeclaration(m) ? "constructor" : memberName(m);
4063
+ const name = Node6.isConstructorDeclaration(m) ? "constructor" : memberName(m);
3669
4064
  return oneLine2(`${name}(${params})${ret ? `: ${ret}` : ""}`);
3670
4065
  }
3671
- if (Node4.isPropertyDeclaration(m) || Node4.isPropertySignature(m)) {
4066
+ if (Node6.isPropertyDeclaration(m) || Node6.isPropertySignature(m)) {
3672
4067
  const type = m.getTypeNode()?.getText();
3673
4068
  return oneLine2(`${memberName(m)}${type ? `: ${type}` : ""}`);
3674
4069
  }
@@ -4476,6 +4871,36 @@ var SOURCE = [
4476
4871
  source: "source-metrics",
4477
4872
  description: "1 when the function's body is one call forwarding every parameter, in order, as a bare argument; else 0."
4478
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
+ },
4479
4904
  {
4480
4905
  name: "except_count",
4481
4906
  unit: "count",
@@ -4504,7 +4929,7 @@ var SOURCE = [
4504
4929
  direction: "higher-worse",
4505
4930
  absent: "zero",
4506
4931
  source: "exception-handling",
4507
- description: "Handlers whose body is empty, pass, ..., continue, a bare or empty return, or a single logging call."
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."
4508
4933
  },
4509
4934
  {
4510
4935
  name: "class_count",
@@ -4939,9 +5364,10 @@ function runMetricOutlierRule(rule, ctx) {
4939
5364
  const value = ctx.metricsByNode.get(node.id)?.get(rule.metric);
4940
5365
  if (value !== void 0) carriers.push({ node, value });
4941
5366
  }
4942
- if (carriers.length < (rule.minSample ?? DEFAULT_OUTLIER_MIN_SAMPLE)) return [];
4943
- const threshold = percentileOf(carriers.map((c) => c.value), rule.percentile);
4944
- return carriers.filter((c) => c.value > threshold).map((c) => toViolation(rule, c.node, c.value, threshold));
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));
4945
5371
  }
4946
5372
  function percentileOf(values, percentile) {
4947
5373
  const sorted = [...values].sort((a, b) => a - b);
@@ -4951,7 +5377,8 @@ function percentileOf(values, percentile) {
4951
5377
  return sorted[lower] + (sorted[upper] - sorted[lower]) * (rank - lower);
4952
5378
  }
4953
5379
  function toViolation(rule, node, value, threshold) {
4954
- const bound = `p${formatNumber(rule.percentile)} ${formatNumber(threshold)}`;
5380
+ const floorSuffix = rule.floor === void 0 ? "" : `, floor ${formatNumber(rule.floor)}`;
5381
+ const bound = `p${formatNumber(rule.percentile)} ${formatNumber(threshold)}${floorSuffix}`;
4955
5382
  return {
4956
5383
  ruleId: rule.id,
4957
5384
  severity: severityOf(rule),
@@ -5340,6 +5767,12 @@ function assertMetricOutlier(r, warn) {
5340
5767
  if (r.minSample !== void 0 && (!Number.isInteger(r.minSample) || r.minSample < 1)) {
5341
5768
  throw new Error(`${r.id}: minSample must be a positive integer`);
5342
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
+ }
5343
5776
  const ruleId = r.id;
5344
5777
  return {
5345
5778
  type: "metric-outlier",
@@ -5348,6 +5781,8 @@ function assertMetricOutlier(r, warn) {
5348
5781
  kind: r.kind,
5349
5782
  percentile: r.percentile,
5350
5783
  minSample: r.minSample,
5784
+ floor: r.floor,
5785
+ rankNonZero: r.rankNonZero,
5351
5786
  severity: r.severity
5352
5787
  };
5353
5788
  }
@@ -5419,18 +5854,18 @@ function checkSnapshot(store, options) {
5419
5854
  });
5420
5855
  return { snapshot, baselineSnapshot, result };
5421
5856
  }
5422
- async function loadCheckRules(path12, options = {}) {
5857
+ async function loadCheckRules(path11, options = {}) {
5423
5858
  let raw;
5424
5859
  try {
5425
- raw = await readFile2(path12, "utf8");
5860
+ raw = await readFile2(path11, "utf8");
5426
5861
  } catch (err) {
5427
- throw new Error(`Cannot read rules file at ${path12}: ${errorMessage(err)}`);
5862
+ throw new Error(`Cannot read rules file at ${path11}: ${errorMessage(err)}`);
5428
5863
  }
5429
5864
  let parsed;
5430
5865
  try {
5431
5866
  parsed = JSON.parse(raw);
5432
5867
  } catch (err) {
5433
- throw new Error(`Invalid JSON in ${path12}: ${errorMessage(err)}`);
5868
+ throw new Error(`Invalid JSON in ${path11}: ${errorMessage(err)}`);
5434
5869
  }
5435
5870
  return validateRules(parsed, options);
5436
5871
  }
@@ -5842,7 +6277,7 @@ function pushMulti(map, key, value) {
5842
6277
  }
5843
6278
 
5844
6279
  // src/conventions/areas.ts
5845
- import * as path11 from "path";
6280
+ import * as path10 from "path";
5846
6281
  import { contentHashOf as contentHashOf2 } from "@titan-design/store-sqlite";
5847
6282
  var DEFAULT_MIN_SIZE = 3;
5848
6283
  var PROMPT_FILE_CAP = 30;
@@ -5926,7 +6361,7 @@ function rankByDegree(members, edges) {
5926
6361
  function dominantDirectory(files) {
5927
6362
  const counts = /* @__PURE__ */ new Map();
5928
6363
  for (const f of files) {
5929
- const dir = path11.posix.dirname(f);
6364
+ const dir = path10.posix.dirname(f);
5930
6365
  counts.set(dir, (counts.get(dir) ?? 0) + 1);
5931
6366
  }
5932
6367
  const dirs = [...counts.entries()].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1));