@titan-design/code-graph 0.6.0 → 0.7.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
@@ -1685,6 +1685,220 @@ import { createHash } from "crypto";
1685
1685
  import * as fs2 from "fs/promises";
1686
1686
  import { getLanguageFromPath as getLanguageFromPath2 } from "@titan-design/code-parser";
1687
1687
 
1688
+ // src/analysis/comment-lines.ts
1689
+ function commentLineStats(fn, body, lines) {
1690
+ const docstring = pythonDocstring(body);
1691
+ const comments = functionComments(fn, body);
1692
+ const commentLines = rowsOf(comments).size;
1693
+ const bodyLines = codeRows(body, docstring, comments, lines);
1694
+ return {
1695
+ commentLines,
1696
+ docstringLines: docstring ? rowSpan(docstring) : jsDocLines(fn),
1697
+ bodyLines,
1698
+ commentRatio: commentLines / Math.max(bodyLines, 1)
1699
+ };
1700
+ }
1701
+ function pythonDocstring(body) {
1702
+ if (body.type !== "block") return null;
1703
+ const first = body.namedChildren.find((c) => c !== null && c.type !== "comment");
1704
+ if (first?.type !== "expression_statement" || first.namedChildCount !== 1) return null;
1705
+ return first.namedChildren[0]?.type === "string" ? first : null;
1706
+ }
1707
+ function bodyStatements(body, docstring = pythonDocstring(body)) {
1708
+ if (body.type !== "block" && body.type !== "statement_block") return [body];
1709
+ return body.namedChildren.filter(
1710
+ (c) => c !== null && c.type !== "comment" && c.id !== docstring?.id
1711
+ );
1712
+ }
1713
+ function functionComments(fn, body) {
1714
+ if (body.type !== "block") return descendantsOfType(body, "comment");
1715
+ const signatureEnd = fn.childForFieldName("parameters")?.endIndex ?? body.startIndex;
1716
+ return descendantsOfType(fn, "comment").filter((c) => c.startIndex >= signatureEnd);
1717
+ }
1718
+ function descendantsOfType(root, type) {
1719
+ const out = [];
1720
+ const visit = (node) => {
1721
+ if (node.type === type) out.push(node);
1722
+ for (const child of node.namedChildren) if (child) visit(child);
1723
+ };
1724
+ visit(root);
1725
+ return out;
1726
+ }
1727
+ function rowSpan(node) {
1728
+ return node.endPosition.row - node.startPosition.row + 1;
1729
+ }
1730
+ function rowsOf(nodes) {
1731
+ const rows = /* @__PURE__ */ new Set();
1732
+ for (const n of nodes) {
1733
+ for (let r = n.startPosition.row; r <= n.endPosition.row; r++) rows.add(r);
1734
+ }
1735
+ return rows;
1736
+ }
1737
+ function codeRows(body, docstring, comments, lines) {
1738
+ let count = 0;
1739
+ for (const row of rowsOf(bodyStatements(body, docstring))) {
1740
+ if (codeOnRow(row, lines[row] ?? "", comments).trim() !== "") count++;
1741
+ }
1742
+ return count;
1743
+ }
1744
+ function codeOnRow(row, text, comments) {
1745
+ let out = text;
1746
+ for (const c of comments) {
1747
+ if (row < c.startPosition.row || row > c.endPosition.row) continue;
1748
+ const from = row === c.startPosition.row ? c.startPosition.column : 0;
1749
+ const to = row === c.endPosition.row ? c.endPosition.column : out.length;
1750
+ out = out.slice(0, from) + " ".repeat(Math.max(to - from, 0)) + out.slice(to);
1751
+ }
1752
+ return out;
1753
+ }
1754
+ function jsDocLines(fn) {
1755
+ const anchor = declarationAnchor(fn);
1756
+ const prev = anchor.previousNamedSibling;
1757
+ if (prev?.type !== "comment" || !prev.text.startsWith("/**")) return 0;
1758
+ const gap = anchor.startPosition.row - prev.endPosition.row;
1759
+ return gap === 0 || gap === 1 ? rowSpan(prev) : 0;
1760
+ }
1761
+ function declarationAnchor(fn) {
1762
+ let anchor = fn;
1763
+ if (fn.parent?.type === "variable_declarator") anchor = fn.parent.parent ?? fn;
1764
+ while (anchor.parent?.type === "export_statement") anchor = anchor.parent;
1765
+ return anchor;
1766
+ }
1767
+
1768
+ // src/analysis/narrating-comments.ts
1769
+ var MIN_SHARED = 2;
1770
+ var MIN_SHARE_OF_COMMENT = 0.5;
1771
+ var STOPWORDS = /* @__PURE__ */ new Set([
1772
+ "the",
1773
+ "and",
1774
+ "for",
1775
+ "this",
1776
+ "that",
1777
+ "with",
1778
+ "from",
1779
+ "into",
1780
+ "then",
1781
+ "else",
1782
+ "when",
1783
+ "not",
1784
+ "are",
1785
+ "was",
1786
+ "has",
1787
+ "have",
1788
+ "its",
1789
+ "our",
1790
+ "all",
1791
+ "any",
1792
+ "can",
1793
+ "will",
1794
+ "just",
1795
+ "now",
1796
+ "return",
1797
+ "const",
1798
+ "let",
1799
+ "var",
1800
+ "def",
1801
+ "self",
1802
+ "none",
1803
+ "true",
1804
+ "false",
1805
+ "null",
1806
+ "undefined",
1807
+ "new",
1808
+ "await",
1809
+ "async",
1810
+ "function"
1811
+ ]);
1812
+ var IDENTIFIER_TYPES = /* @__PURE__ */ new Set([
1813
+ "identifier",
1814
+ "property_identifier",
1815
+ "shorthand_property_identifier",
1816
+ "type_identifier"
1817
+ ]);
1818
+ function countNarratingComments(fn, body) {
1819
+ let count = 0;
1820
+ for (const comment of functionComments(fn, body)) {
1821
+ const target = trailedStatement(comment) ?? nextStatement(comment);
1822
+ if (target && narrates(comment.text, target)) count++;
1823
+ }
1824
+ return count;
1825
+ }
1826
+ function trailedStatement(comment) {
1827
+ const prev = comment.previousNamedSibling;
1828
+ if (!prev || prev.type === "comment") return null;
1829
+ return prev.endPosition.row === comment.startPosition.row ? prev : null;
1830
+ }
1831
+ function identifierTokens(text) {
1832
+ const words = text.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^A-Za-z0-9]+/);
1833
+ const out = /* @__PURE__ */ new Set();
1834
+ for (const w of words) {
1835
+ const t = w.toLowerCase();
1836
+ if (t.length >= 3 && !STOPWORDS.has(t)) out.add(t);
1837
+ }
1838
+ return out;
1839
+ }
1840
+ function nextStatement(comment) {
1841
+ let next = comment.nextNamedSibling;
1842
+ while (next?.type === "comment") next = next.nextNamedSibling;
1843
+ if (next?.type !== "block") return next;
1844
+ return next.namedChildren.find((c) => c !== null && c.type !== "comment") ?? null;
1845
+ }
1846
+ function narrates(commentText, statement) {
1847
+ const said = identifierTokens(commentText);
1848
+ if (said.size === 0) return false;
1849
+ const code = identifierTokens(statementIdentifiers(statement).join(" "));
1850
+ let shared = 0;
1851
+ for (const t of said) if (code.has(t)) shared++;
1852
+ return shared >= MIN_SHARED || shared / said.size >= MIN_SHARE_OF_COMMENT;
1853
+ }
1854
+ function statementIdentifiers(statement) {
1855
+ const out = [];
1856
+ const visit = (node) => {
1857
+ if (IDENTIFIER_TYPES.has(node.type)) out.push(node.text);
1858
+ for (const child of node.namedChildren) if (child) visit(child);
1859
+ };
1860
+ visit(statement);
1861
+ return out;
1862
+ }
1863
+
1864
+ // src/analysis/exception-handling.ts
1865
+ var EXCEPTION_METRIC_NAMES = ["except_count", "except_density", "swallowed_except"];
1866
+ var HANDLER_TYPES = ["except_clause", "except_group_clause", "catch_clause"];
1867
+ var SILENT_STATEMENTS = /* @__PURE__ */ new Set(["pass_statement", "continue_statement", "empty_statement"]);
1868
+ var EMPTY_VALUES = /* @__PURE__ */ new Set(["None", "null", "undefined"]);
1869
+ var LOGGING_TOKENS = /* @__PURE__ */ new Set(["log", "logger", "logging", "warn", "warning", "warnings", "print", "console"]);
1870
+ function exceptionMetrics(nodeId, root, loc) {
1871
+ const handlers = HANDLER_TYPES.flatMap((type) => descendantsOfType(root, type));
1872
+ const swallowed = handlers.filter((h) => isSwallowed(h)).length;
1873
+ return [
1874
+ { nodeId, name: "except_count", value: handlers.length, unit: "count" },
1875
+ { nodeId, name: "except_density", value: loc === 0 ? 0 : handlers.length * 100 / loc, unit: "per100loc" },
1876
+ { nodeId, name: "swallowed_except", value: swallowed, unit: "count" }
1877
+ ];
1878
+ }
1879
+ function isSwallowed(handler) {
1880
+ const body = handler.childForFieldName("body") ?? handler.namedChildren.find((c) => c?.type === "block");
1881
+ if (!body) return false;
1882
+ const statements = bodyStatements(body, null);
1883
+ if (statements.length === 0) return true;
1884
+ return statements.length === 1 && isSilent(statements[0]);
1885
+ }
1886
+ function isSilent(statement) {
1887
+ if (SILENT_STATEMENTS.has(statement.type)) return true;
1888
+ const value = statement.namedChildren[0];
1889
+ if (statement.type === "return_statement") return !value || EMPTY_VALUES.has(value.text);
1890
+ if (statement.type !== "expression_statement" || !value) return false;
1891
+ if (value.type === "ellipsis") return true;
1892
+ return isLoggingCall(value);
1893
+ }
1894
+ function isLoggingCall(node) {
1895
+ if (node.type !== "call" && node.type !== "call_expression") return false;
1896
+ const callee = node.childForFieldName("function");
1897
+ if (!callee) return false;
1898
+ for (const token of identifierTokens(callee.text)) if (LOGGING_TOKENS.has(token)) return true;
1899
+ return false;
1900
+ }
1901
+
1688
1902
  // src/cognitive-complexity.ts
1689
1903
  function cognitiveComplexityOf(body, language) {
1690
1904
  const handler = language === "python" ? PY_HANDLER : TS_HANDLER;
@@ -1937,12 +2151,97 @@ function areLinked(a, b) {
1937
2151
  return false;
1938
2152
  }
1939
2153
 
2154
+ // src/analysis/pass-through.ts
2155
+ var RECEIVERS = /* @__PURE__ */ new Set(["self", "cls"]);
2156
+ var CALL_TYPES = /* @__PURE__ */ new Set(["call", "call_expression"]);
2157
+ var WRAPPER_TYPES = /* @__PURE__ */ new Set(["return_statement", "expression_statement"]);
2158
+ var PY_NAMED_PARAMS = /* @__PURE__ */ new Set(["default_parameter", "typed_default_parameter"]);
2159
+ var PY_SEPARATORS = /* @__PURE__ */ new Set(["keyword_separator", "positional_separator"]);
2160
+ function isPassThrough(fn, body) {
2161
+ const call = soleCall(body);
2162
+ if (!call) return false;
2163
+ const params = parameterShapes(fn);
2164
+ if (!params || params.length === 0) return false;
2165
+ const args = argumentShapes(call);
2166
+ return args !== null && args.length === params.length && args.every((a, i) => a === params[i]);
2167
+ }
2168
+ function soleCall(body) {
2169
+ const statements = bodyStatements(body);
2170
+ if (statements.length !== 1) return null;
2171
+ const only = statements[0];
2172
+ if (CALL_TYPES.has(only.type)) return only;
2173
+ if (!WRAPPER_TYPES.has(only.type) || only.namedChildCount !== 1) return null;
2174
+ const value = only.namedChildren[0];
2175
+ return CALL_TYPES.has(value.type) ? value : null;
2176
+ }
2177
+ function parameterShapes(fn) {
2178
+ const bare = fn.childForFieldName("parameter");
2179
+ if (bare) return [bare.text];
2180
+ const list = fn.childForFieldName("parameters");
2181
+ if (!list) return null;
2182
+ const shapes = [];
2183
+ for (const p of list.namedChildren) {
2184
+ if (!p || p.type === "comment" || PY_SEPARATORS.has(p.type)) continue;
2185
+ const shape = parameterShape(p);
2186
+ if (shape === null) return null;
2187
+ shapes.push(shape);
2188
+ }
2189
+ return RECEIVERS.has(shapes[0] ?? "") ? shapes.slice(1) : shapes;
2190
+ }
2191
+ function parameterShape(p) {
2192
+ if (p.type === "identifier") return p.text;
2193
+ if (p.type === "list_splat_pattern" || p.type === "dictionary_splat_pattern") return p.text;
2194
+ if (PY_NAMED_PARAMS.has(p.type)) return p.childForFieldName("name")?.text ?? null;
2195
+ if (p.type === "typed_parameter") return parameterShape(p.namedChildren[0]);
2196
+ if (p.type === "required_parameter" || p.type === "optional_parameter") {
2197
+ const pattern = p.childForFieldName("pattern");
2198
+ return pattern ? tsPatternShape(pattern) : null;
2199
+ }
2200
+ return tsPatternShape(p);
2201
+ }
2202
+ function tsPatternShape(pattern) {
2203
+ if (pattern.type === "identifier") return pattern.text;
2204
+ if (pattern.type === "rest_pattern") return `...${pattern.namedChildren[0]?.text ?? ""}`;
2205
+ if (pattern.type === "assignment_pattern") {
2206
+ const left = pattern.childForFieldName("left");
2207
+ return left ? tsPatternShape(left) : null;
2208
+ }
2209
+ return null;
2210
+ }
2211
+ function argumentShapes(call) {
2212
+ const list = call.childForFieldName("arguments");
2213
+ if (!list || list.type !== "argument_list" && list.type !== "arguments") return null;
2214
+ const shapes = [];
2215
+ for (const a of list.namedChildren) {
2216
+ if (!a || a.type === "comment") continue;
2217
+ const shape = argumentShape(a);
2218
+ if (shape === null) return null;
2219
+ shapes.push(shape);
2220
+ }
2221
+ return shapes;
2222
+ }
2223
+ function argumentShape(a) {
2224
+ if (a.type === "identifier") return a.text;
2225
+ const inner = a.namedChildren[0];
2226
+ if (inner?.type !== "identifier") return null;
2227
+ if (a.type === "list_splat") return `*${inner.text}`;
2228
+ if (a.type === "dictionary_splat") return `**${inner.text}`;
2229
+ if (a.type === "spread_element") return `...${inner.text}`;
2230
+ return null;
2231
+ }
2232
+
1940
2233
  // src/symbol-metrics.ts
1941
2234
  var SYMBOL_METRICS = [
1942
2235
  { name: "symbol_cognitive", stat: "cognitive", unit: "count" },
1943
2236
  { name: "symbol_cyclomatic", stat: "cyclomatic", unit: "count" },
1944
2237
  { name: "symbol_loc", stat: "loc", unit: "lines" },
1945
- { name: "symbol_max_nesting", stat: "nestingDepth", unit: "count" }
2238
+ { name: "symbol_max_nesting", stat: "nestingDepth", unit: "count" },
2239
+ { name: "symbol_comment_lines", stat: "commentLines", unit: "lines" },
2240
+ { name: "symbol_docstring_lines", stat: "docstringLines", unit: "lines" },
2241
+ { name: "symbol_body_lines", stat: "bodyLines", unit: "lines" },
2242
+ { name: "symbol_comment_ratio", stat: "commentRatio", unit: "ratio" },
2243
+ { name: "symbol_narrating_comments", stat: "narratingComments", unit: "count" },
2244
+ { name: "symbol_pass_through", stat: "passThrough", unit: "count" }
1946
2245
  ];
1947
2246
  var SYMBOL_METRIC_NAMES = SYMBOL_METRICS.map((m) => m.name);
1948
2247
  function symbolMetrics(fileId2, stats, symbolNames) {
@@ -1966,6 +2265,13 @@ function symbolMetrics(fileId2, stats, symbolNames) {
1966
2265
  }
1967
2266
  return out;
1968
2267
  }
2268
+ function functionShapeStats(fn, body, lines) {
2269
+ return {
2270
+ ...commentLineStats(fn, body, lines),
2271
+ narratingComments: countNarratingComments(fn, body),
2272
+ passThrough: isPassThrough(fn, body) ? 1 : 0
2273
+ };
2274
+ }
1969
2275
 
1970
2276
  // src/source-metrics.ts
1971
2277
  var TS_FUNCTION_TYPES = /* @__PURE__ */ new Set([
@@ -2016,7 +2322,8 @@ var SOURCE_METRIC_NAMES = /* @__PURE__ */ new Set([
2016
2322
  "max_nesting_depth",
2017
2323
  "class_count",
2018
2324
  "lcom4_max",
2019
- // Per-symbol complexity, loc and nesting (C-58, C-64, TP-317), keyed
2325
+ ...EXCEPTION_METRIC_NAMES,
2326
+ // Per-symbol complexity, size, comments and shape (C-58, C-64, TP-317, TP-322), keyed
2020
2327
  // to `symbol` node ids. Source-local like the file-level metrics above, so an
2021
2328
  // unchanged file carries them forward — but their nodeId is `<fileId>#<name>`,
2022
2329
  // so the reuse basis buckets them under the symbol's parent file (incremental.ts).
@@ -2076,6 +2383,7 @@ function metricsForFile(nodeId, file, symbolNames) {
2076
2383
  }
2077
2384
  out.push(...symbolMetrics(nodeId, stats, symbolNames));
2078
2385
  out.push(...computeLcomMetrics(file, nodeId));
2386
+ out.push(...exceptionMetrics(nodeId, file.tree.rootNode, loc));
2079
2387
  return out;
2080
2388
  }
2081
2389
  function countLoc(content) {
@@ -2084,6 +2392,7 @@ function countLoc(content) {
2084
2392
  function analyzeFunctions(file) {
2085
2393
  const stats = [];
2086
2394
  const fnTypes = file.language === "python" ? PY_FUNCTION_TYPES : TS_FUNCTION_TYPES;
2395
+ const lines = file.content.split("\n");
2087
2396
  walkScopes(file.tree.rootNode, file.language === "python", (node, scope) => {
2088
2397
  const fn = functionAt(node, fnTypes);
2089
2398
  if (!fn) return;
@@ -2092,7 +2401,8 @@ function analyzeFunctions(file) {
2092
2401
  cyclomatic: cyclomaticOf(fn.body, file.language),
2093
2402
  cognitive: cognitiveComplexityOf(fn.body, file.language),
2094
2403
  nestingDepth: nestingDepthOf(fn.body, file.language, 0),
2095
- loc: fn.node.endPosition.row - fn.node.startPosition.row + 1
2404
+ loc: fn.node.endPosition.row - fn.node.startPosition.row + 1,
2405
+ ...functionShapeStats(fn.node, fn.body, lines)
2096
2406
  });
2097
2407
  });
2098
2408
  return stats;
@@ -2149,7 +2459,7 @@ var TERMINALS = /* @__PURE__ */ new Set([
2149
2459
  ]);
2150
2460
  var SCOPE_TYPES = /* @__PURE__ */ new Set(["function_definition", "lambda", "class_definition"]);
2151
2461
  var SPLAT_TYPES = /* @__PURE__ */ new Set(["list_splat_pattern", "dictionary_splat_pattern"]);
2152
- var RECEIVERS = /* @__PURE__ */ new Set(["self", "cls"]);
2462
+ var RECEIVERS2 = /* @__PURE__ */ new Set(["self", "cls"]);
2153
2463
  function pythonDeadCode(root) {
2154
2464
  const out = { unreachable: 0, locals: 0, params: 0 };
2155
2465
  const visit = (node) => {
@@ -2211,7 +2521,7 @@ function ownParamNames(fn) {
2211
2521
  for (const p of params.namedChildren) {
2212
2522
  if (!p || p.type === "keyword_separator" || p.type === "positional_separator") continue;
2213
2523
  const id = paramIdentifier(p);
2214
- out.push(id && !RECEIVERS.has(id.text) ? id.text : null);
2524
+ out.push(id && !RECEIVERS2.has(id.text) ? id.text : null);
2215
2525
  }
2216
2526
  return out;
2217
2527
  }
@@ -3118,7 +3428,7 @@ function qualifiedByName(file) {
3118
3428
  }
3119
3429
 
3120
3430
  // src/indexer.ts
3121
- var INDEX_VERSION = "0.16.0";
3431
+ var INDEX_VERSION = "0.17.0";
3122
3432
  var LANGUAGES = ["typescript", "python"];
3123
3433
  function canonicalizePath(p) {
3124
3434
  try {
@@ -4106,6 +4416,96 @@ var SOURCE = [
4106
4416
  source: "source-metrics",
4107
4417
  description: "Deepest block nesting inside the function a symbol names; the max when several share the name."
4108
4418
  },
4419
+ {
4420
+ name: "symbol_comment_lines",
4421
+ unit: "lines",
4422
+ appliesTo: SYMBOL,
4423
+ rollup: "max",
4424
+ direction: "neutral",
4425
+ absent: "exclude",
4426
+ source: "source-metrics",
4427
+ description: "Rows holding a comment inside the function a symbol names, the docstring excluded; the max when several share the name."
4428
+ },
4429
+ {
4430
+ name: "symbol_docstring_lines",
4431
+ unit: "lines",
4432
+ appliesTo: SYMBOL,
4433
+ rollup: "max",
4434
+ direction: "neutral",
4435
+ absent: "exclude",
4436
+ source: "source-metrics",
4437
+ description: "Rows of the function's docstring: the Python docstring, or the JSDoc block attached to a TypeScript declaration."
4438
+ },
4439
+ {
4440
+ name: "symbol_body_lines",
4441
+ unit: "lines",
4442
+ appliesTo: SYMBOL,
4443
+ rollup: "max",
4444
+ direction: "higher-worse",
4445
+ absent: "exclude",
4446
+ source: "source-metrics",
4447
+ description: "Non-blank rows of the function's body that hold code, comments and the docstring excluded."
4448
+ },
4449
+ {
4450
+ name: "symbol_comment_ratio",
4451
+ unit: "ratio",
4452
+ appliesTo: SYMBOL,
4453
+ rollup: "max",
4454
+ direction: "neutral",
4455
+ absent: "exclude",
4456
+ source: "source-metrics",
4457
+ description: "symbol_comment_lines / max(symbol_body_lines, 1). High values suggest narration; zero is not a defect."
4458
+ },
4459
+ {
4460
+ name: "symbol_narrating_comments",
4461
+ unit: "count",
4462
+ appliesTo: SYMBOL,
4463
+ rollup: "max",
4464
+ direction: "higher-worse",
4465
+ absent: "exclude",
4466
+ source: "source-metrics",
4467
+ description: "Comments in the body whose words overlap the next statement's identifiers by 2 tokens or half the comment. A candidate signal."
4468
+ },
4469
+ {
4470
+ name: "symbol_pass_through",
4471
+ unit: "count",
4472
+ appliesTo: SYMBOL,
4473
+ rollup: "max",
4474
+ direction: "higher-worse",
4475
+ absent: "exclude",
4476
+ source: "source-metrics",
4477
+ description: "1 when the function's body is one call forwarding every parameter, in order, as a bare argument; else 0."
4478
+ },
4479
+ {
4480
+ name: "except_count",
4481
+ unit: "count",
4482
+ appliesTo: FILE,
4483
+ rollup: "sum",
4484
+ direction: "neutral",
4485
+ absent: "zero",
4486
+ source: "exception-handling",
4487
+ description: "Python except clauses and TypeScript catch clauses in the file."
4488
+ },
4489
+ {
4490
+ name: "except_density",
4491
+ unit: "per100loc",
4492
+ appliesTo: FILE,
4493
+ rollup: "none",
4494
+ direction: "higher-worse",
4495
+ absent: "zero",
4496
+ source: "exception-handling",
4497
+ description: "except_count per 100 non-blank lines. Recompute from the sums for a group."
4498
+ },
4499
+ {
4500
+ name: "swallowed_except",
4501
+ unit: "count",
4502
+ appliesTo: FILE,
4503
+ rollup: "sum",
4504
+ direction: "higher-worse",
4505
+ absent: "zero",
4506
+ source: "exception-handling",
4507
+ description: "Handlers whose body is empty, pass, ..., continue, a bare or empty return, or a single logging call."
4508
+ },
4109
4509
  {
4110
4510
  name: "class_count",
4111
4511
  unit: "count",
@@ -4529,6 +4929,43 @@ function locateNode(node) {
4529
4929
  return location;
4530
4930
  }
4531
4931
 
4932
+ // src/check/outlier-rule.ts
4933
+ var DEFAULT_OUTLIER_MIN_SAMPLE = 20;
4934
+ function runMetricOutlierRule(rule, ctx) {
4935
+ const pool = rule.kind === "symbol" ? ctx.symbolNodes : ctx.nodes;
4936
+ const carriers = [];
4937
+ for (const node of pool) {
4938
+ if (node.kind !== rule.kind) continue;
4939
+ const value = ctx.metricsByNode.get(node.id)?.get(rule.metric);
4940
+ if (value !== void 0) carriers.push({ node, value });
4941
+ }
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));
4945
+ }
4946
+ function percentileOf(values, percentile) {
4947
+ const sorted = [...values].sort((a, b) => a - b);
4948
+ const rank = percentile / 100 * (sorted.length - 1);
4949
+ const lower = Math.floor(rank);
4950
+ const upper = Math.min(lower + 1, sorted.length - 1);
4951
+ return sorted[lower] + (sorted[upper] - sorted[lower]) * (rank - lower);
4952
+ }
4953
+ function toViolation(rule, node, value, threshold) {
4954
+ const bound = `p${formatNumber(rule.percentile)} ${formatNumber(threshold)}`;
4955
+ return {
4956
+ ruleId: rule.id,
4957
+ severity: severityOf(rule),
4958
+ nodeId: node.id,
4959
+ metric: rule.metric,
4960
+ value,
4961
+ threshold,
4962
+ message: `${rule.metric}=${formatNumber(value)} > ${bound}`,
4963
+ ...locateNode(node),
4964
+ evidence: `${rule.metric}=${formatNumber(value)} (${bound})`,
4965
+ tool: CODE_GRAPH_TOOL
4966
+ };
4967
+ }
4968
+
4532
4969
  // src/check/metric-rules.ts
4533
4970
  function nodeFilter(rule) {
4534
4971
  return {
@@ -4548,7 +4985,7 @@ function candidateNodes(rule, ctx) {
4548
4985
  const pool = rule.kind === "symbol" ? ctx.symbolNodes : ctx.nodes;
4549
4986
  return pool.filter((node) => passesFilter(node, filter));
4550
4987
  }
4551
- function toViolation(rule, breach) {
4988
+ function toViolation2(rule, breach) {
4552
4989
  return {
4553
4990
  ruleId: rule.id,
4554
4991
  severity: severityOf(rule),
@@ -4578,7 +5015,7 @@ function runMetricMaxRule(rule, ctx) {
4578
5015
  for (const node of candidateNodes(rule, ctx)) {
4579
5016
  const value = ctx.metricsByNode.get(node.id)?.get(rule.metric);
4580
5017
  if (value === void 0 || value <= rule.max) continue;
4581
- out.push(toViolation(rule, thresholdBreach(node, rule.metric, value, "max", rule.max)));
5018
+ out.push(toViolation2(rule, thresholdBreach(node, rule.metric, value, "max", rule.max)));
4582
5019
  }
4583
5020
  return out;
4584
5021
  }
@@ -4587,7 +5024,7 @@ function runMetricMinRule(rule, ctx) {
4587
5024
  for (const node of candidateNodes(rule, ctx)) {
4588
5025
  const value = ctx.metricsByNode.get(node.id)?.get(rule.metric);
4589
5026
  if (value === void 0 || value >= rule.min) continue;
4590
- out.push(toViolation(rule, thresholdBreach(node, rule.metric, value, "min", rule.min)));
5027
+ out.push(toViolation2(rule, thresholdBreach(node, rule.metric, value, "min", rule.min)));
4591
5028
  }
4592
5029
  return out;
4593
5030
  }
@@ -4600,7 +5037,7 @@ function runMetricProductMaxRule(rule, ctx) {
4600
5037
  if (!components) continue;
4601
5038
  const product = components.reduce((a, b) => a * b, 1);
4602
5039
  if (product <= rule.max) continue;
4603
- out.push(toViolation(rule, productBreach(rule, node, components, product)));
5040
+ out.push(toViolation2(rule, productBreach(rule, node, components, product)));
4604
5041
  }
4605
5042
  return out;
4606
5043
  }
@@ -4634,6 +5071,8 @@ function runRule(rule, ctx) {
4634
5071
  return runMetricMinRule(rule, ctx);
4635
5072
  case "metric-product-max":
4636
5073
  return runMetricProductMaxRule(rule, ctx);
5074
+ case "metric-outlier":
5075
+ return runMetricOutlierRule(rule, ctx);
4637
5076
  case "forbid-import":
4638
5077
  return runForbidImportRule(rule, ctx);
4639
5078
  case "layered-deps":
@@ -4778,6 +5217,8 @@ function validateRule(raw, index, warn) {
4778
5217
  return assertMetricMin(r, warn);
4779
5218
  case "metric-product-max":
4780
5219
  return assertMetricProductMax(r, warn);
5220
+ case "metric-outlier":
5221
+ return assertMetricOutlier(r, warn);
4781
5222
  case "forbid-import":
4782
5223
  return assertForbidImport(r);
4783
5224
  case "layered-deps":
@@ -4887,6 +5328,29 @@ function assertMetricMin(r, warn) {
4887
5328
  excludeRoles: parseRoleArray(ruleId, r.excludeRoles, warn)
4888
5329
  };
4889
5330
  }
5331
+ var NODE_KINDS = /* @__PURE__ */ new Set(["package", "module", "file", "symbol", "external"]);
5332
+ function assertMetricOutlier(r, warn) {
5333
+ if (typeof r.metric !== "string") throw new Error(`${r.id}: metric must be a string`);
5334
+ if (typeof r.kind !== "string" || !NODE_KINDS.has(r.kind)) {
5335
+ throw new Error(`${r.id}: kind must be one of ${[...NODE_KINDS].join(", ")}`);
5336
+ }
5337
+ if (typeof r.percentile !== "number" || r.percentile < 50 || r.percentile > 100) {
5338
+ throw new Error(`${r.id}: percentile must be a number from 50 to 100`);
5339
+ }
5340
+ if (r.minSample !== void 0 && (!Number.isInteger(r.minSample) || r.minSample < 1)) {
5341
+ throw new Error(`${r.id}: minSample must be a positive integer`);
5342
+ }
5343
+ const ruleId = r.id;
5344
+ return {
5345
+ type: "metric-outlier",
5346
+ id: ruleId,
5347
+ metric: healMetricName(r.metric, ruleId, warn),
5348
+ kind: r.kind,
5349
+ percentile: r.percentile,
5350
+ minSample: r.minSample,
5351
+ severity: r.severity
5352
+ };
5353
+ }
4890
5354
  function assertForbidImport(r) {
4891
5355
  if (typeof r.from !== "string") throw new Error(`${r.id}: from must be a string`);
4892
5356
  if (typeof r.to !== "string") throw new Error(`${r.id}: to must be a string`);
@@ -5587,7 +6051,9 @@ export {
5587
6051
  CodeGraphStore,
5588
6052
  DEAD_CODE_METRIC_NAMES,
5589
6053
  DEFAULT_CHURN_WINDOWS,
6054
+ DEFAULT_OUTLIER_MIN_SAMPLE,
5590
6055
  DOMAIN_DDL,
6056
+ EXCEPTION_METRIC_NAMES,
5591
6057
  GROWTH_RISK_METRIC_NAMES,
5592
6058
  INDEX_VERSION,
5593
6059
  KIT,
@@ -5600,6 +6066,7 @@ export {
5600
6066
  SOURCE_METRIC_NAMES,
5601
6067
  SYMBOL_EMBEDDING_NAMESPACE,
5602
6068
  SYMBOL_ID_SEP,
6069
+ SYMBOL_METRIC_NAMES,
5603
6070
  TsMorphGraphExtractor,
5604
6071
  aggregateMetrics,
5605
6072
  aliasChain,
@@ -5682,6 +6149,7 @@ export {
5682
6149
  parseFile2 as parseFile,
5683
6150
  parseSymbolId,
5684
6151
  patternToRegex,
6152
+ percentileOf,
5685
6153
  planPrune,
5686
6154
  priorSnapshotForRef,
5687
6155
  pruneDanglingReferences,