@titan-design/code-graph 0.5.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,6 +2151,128 @@ 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
+
2233
+ // src/symbol-metrics.ts
2234
+ var SYMBOL_METRICS = [
2235
+ { name: "symbol_cognitive", stat: "cognitive", unit: "count" },
2236
+ { name: "symbol_cyclomatic", stat: "cyclomatic", unit: "count" },
2237
+ { name: "symbol_loc", stat: "loc", unit: "lines" },
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" }
2245
+ ];
2246
+ var SYMBOL_METRIC_NAMES = SYMBOL_METRICS.map((m) => m.name);
2247
+ function symbolMetrics(fileId2, stats, symbolNames) {
2248
+ if (symbolNames.size === 0) return [];
2249
+ const byName = /* @__PURE__ */ new Map();
2250
+ for (const s of stats) {
2251
+ if (!s.name || !symbolNames.has(s.name)) continue;
2252
+ const prev = byName.get(s.name);
2253
+ if (!prev) {
2254
+ byName.set(s.name, { ...s });
2255
+ continue;
2256
+ }
2257
+ for (const { stat } of SYMBOL_METRICS) prev[stat] = Math.max(prev[stat], s[stat]);
2258
+ }
2259
+ const out = [];
2260
+ for (const [name, values] of byName) {
2261
+ const nodeId = symbolId(fileId2, name);
2262
+ for (const m of SYMBOL_METRICS) {
2263
+ out.push({ nodeId, name: m.name, value: values[m.stat], unit: m.unit });
2264
+ }
2265
+ }
2266
+ return out;
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
+ }
2275
+
1940
2276
  // src/source-metrics.ts
1941
2277
  var TS_FUNCTION_TYPES = /* @__PURE__ */ new Set([
1942
2278
  "function_declaration",
@@ -1986,12 +2322,12 @@ var SOURCE_METRIC_NAMES = /* @__PURE__ */ new Set([
1986
2322
  "max_nesting_depth",
1987
2323
  "class_count",
1988
2324
  "lcom4_max",
1989
- // Per-symbol complexity (C-58; C-64 extends it to non-exported symbols), keyed
2325
+ ...EXCEPTION_METRIC_NAMES,
2326
+ // Per-symbol complexity, size, comments and shape (C-58, C-64, TP-317, TP-322), keyed
1990
2327
  // to `symbol` node ids. Source-local like the file-level metrics above, so an
1991
2328
  // unchanged file carries them forward — but their nodeId is `<fileId>#<name>`,
1992
2329
  // so the reuse basis buckets them under the symbol's parent file (incremental.ts).
1993
- "symbol_cognitive",
1994
- "symbol_cyclomatic"
2330
+ ...SYMBOL_METRIC_NAMES
1995
2331
  ]);
1996
2332
  var EMPTY_NAMES = /* @__PURE__ */ new Set();
1997
2333
  function computeSourceMetrics(files, fileIdOf, symbolNamesByFile2 = /* @__PURE__ */ new Map()) {
@@ -2045,28 +2381,9 @@ function metricsForFile(nodeId, file, symbolNames) {
2045
2381
  unit: "count"
2046
2382
  });
2047
2383
  }
2048
- out.push(...symbolComplexityMetrics(nodeId, stats, symbolNames));
2384
+ out.push(...symbolMetrics(nodeId, stats, symbolNames));
2049
2385
  out.push(...computeLcomMetrics(file, nodeId));
2050
- return out;
2051
- }
2052
- function symbolComplexityMetrics(fileId2, stats, symbolNames) {
2053
- if (symbolNames.size === 0) return [];
2054
- const byName = /* @__PURE__ */ new Map();
2055
- for (const s of stats) {
2056
- if (!s.name || !symbolNames.has(s.name)) continue;
2057
- const prev = byName.get(s.name);
2058
- if (!prev) byName.set(s.name, { cognitive: s.cognitive, cyclomatic: s.cyclomatic });
2059
- else {
2060
- prev.cognitive = Math.max(prev.cognitive, s.cognitive);
2061
- prev.cyclomatic = Math.max(prev.cyclomatic, s.cyclomatic);
2062
- }
2063
- }
2064
- const out = [];
2065
- for (const [name, m] of byName) {
2066
- const sid = symbolId(fileId2, name);
2067
- out.push({ nodeId: sid, name: "symbol_cognitive", value: m.cognitive, unit: "count" });
2068
- out.push({ nodeId: sid, name: "symbol_cyclomatic", value: m.cyclomatic, unit: "count" });
2069
- }
2386
+ out.push(...exceptionMetrics(nodeId, file.tree.rootNode, loc));
2070
2387
  return out;
2071
2388
  }
2072
2389
  function countLoc(content) {
@@ -2075,6 +2392,7 @@ function countLoc(content) {
2075
2392
  function analyzeFunctions(file) {
2076
2393
  const stats = [];
2077
2394
  const fnTypes = file.language === "python" ? PY_FUNCTION_TYPES : TS_FUNCTION_TYPES;
2395
+ const lines = file.content.split("\n");
2078
2396
  walkScopes(file.tree.rootNode, file.language === "python", (node, scope) => {
2079
2397
  const fn = functionAt(node, fnTypes);
2080
2398
  if (!fn) return;
@@ -2082,7 +2400,9 @@ function analyzeFunctions(file) {
2082
2400
  name: fn.name === null ? null : qualify(scope, fn.name),
2083
2401
  cyclomatic: cyclomaticOf(fn.body, file.language),
2084
2402
  cognitive: cognitiveComplexityOf(fn.body, file.language),
2085
- nestingDepth: nestingDepthOf(fn.body, file.language, 0)
2403
+ nestingDepth: nestingDepthOf(fn.body, file.language, 0),
2404
+ loc: fn.node.endPosition.row - fn.node.startPosition.row + 1,
2405
+ ...functionShapeStats(fn.node, fn.body, lines)
2086
2406
  });
2087
2407
  });
2088
2408
  return stats;
@@ -2090,12 +2410,12 @@ function analyzeFunctions(file) {
2090
2410
  function functionAt(node, fnTypes) {
2091
2411
  if (fnTypes.has(node.type)) {
2092
2412
  const body = node.childForFieldName("body");
2093
- return body ? { name: node.childForFieldName("name")?.text ?? null, body } : null;
2413
+ return body ? { name: node.childForFieldName("name")?.text ?? null, body, node } : null;
2094
2414
  }
2095
2415
  if ((node.type === "arrow_function" || node.type === "function_expression") && node.parent?.type === "variable_declarator") {
2096
2416
  const body = node.childForFieldName("body");
2097
2417
  if (!body) return null;
2098
- return { name: node.parent.childForFieldName("name")?.text ?? null, body };
2418
+ return { name: node.parent.childForFieldName("name")?.text ?? null, body, node };
2099
2419
  }
2100
2420
  return null;
2101
2421
  }
@@ -2130,6 +2450,146 @@ function cyclomaticOf(body, language) {
2130
2450
  return complexity;
2131
2451
  }
2132
2452
 
2453
+ // src/analysis/dead-code-python.ts
2454
+ var TERMINALS = /* @__PURE__ */ new Set([
2455
+ "return_statement",
2456
+ "raise_statement",
2457
+ "break_statement",
2458
+ "continue_statement"
2459
+ ]);
2460
+ var SCOPE_TYPES = /* @__PURE__ */ new Set(["function_definition", "lambda", "class_definition"]);
2461
+ var SPLAT_TYPES = /* @__PURE__ */ new Set(["list_splat_pattern", "dictionary_splat_pattern"]);
2462
+ var RECEIVERS2 = /* @__PURE__ */ new Set(["self", "cls"]);
2463
+ function pythonDeadCode(root) {
2464
+ const out = { unreachable: 0, locals: 0, params: 0 };
2465
+ const visit = (node) => {
2466
+ if (node.type === "block") out.unreachable += unreachableInBlock(node);
2467
+ if (node.type === "function_definition") {
2468
+ const r = analyzeFunction(node);
2469
+ out.locals += r.locals;
2470
+ out.params += r.params;
2471
+ }
2472
+ for (const child of node.namedChildren) {
2473
+ if (child) visit(child);
2474
+ }
2475
+ };
2476
+ visit(root);
2477
+ return out;
2478
+ }
2479
+ function unreachableInBlock(block) {
2480
+ let seenTerminal = false;
2481
+ let count = 0;
2482
+ for (const child of block.namedChildren) {
2483
+ if (!child) continue;
2484
+ if (seenTerminal && child.type !== "comment") count++;
2485
+ if (TERMINALS.has(child.type)) seenTerminal = true;
2486
+ }
2487
+ return count;
2488
+ }
2489
+ function analyzeFunction(fn) {
2490
+ const own = ownBindings(fn);
2491
+ const nested = nestedBindings(fn);
2492
+ const refs = countReferences(fn, own.declIds);
2493
+ const isUnused = (name) => !name.startsWith("_") && !nested.has(name) && !own.declared.has(name) && (refs.get(name) ?? 0) === 0;
2494
+ let params = 0;
2495
+ const paramNames = ownParamNames(fn);
2496
+ for (let i = isStub(fn) ? -1 : paramNames.length - 1; i >= 0; i--) {
2497
+ const name = paramNames[i] ?? null;
2498
+ if (name !== null && isUnused(name)) params++;
2499
+ else break;
2500
+ }
2501
+ const paramSet = new Set(paramNames);
2502
+ let locals = 0;
2503
+ for (const name of own.locals) {
2504
+ if (!paramSet.has(name) && isUnused(name)) locals++;
2505
+ }
2506
+ return { locals, params };
2507
+ }
2508
+ function isStub(fn) {
2509
+ const body = fn.childForFieldName("body");
2510
+ return (body?.namedChildren ?? []).every((stmt) => {
2511
+ if (!stmt || stmt.type === "comment" || stmt.type === "pass_statement") return true;
2512
+ if (stmt.type === "raise_statement") return /^raise NotImplementedError\b/.test(stmt.text);
2513
+ const expr = stmt.type === "expression_statement" ? stmt.namedChild(0) : null;
2514
+ return expr?.type === "ellipsis" || expr?.type === "string";
2515
+ });
2516
+ }
2517
+ function ownParamNames(fn) {
2518
+ const params = fn.childForFieldName("parameters");
2519
+ if (!params) return [];
2520
+ const out = [];
2521
+ for (const p of params.namedChildren) {
2522
+ if (!p || p.type === "keyword_separator" || p.type === "positional_separator") continue;
2523
+ const id = paramIdentifier(p);
2524
+ out.push(id && !RECEIVERS2.has(id.text) ? id.text : null);
2525
+ }
2526
+ return out;
2527
+ }
2528
+ function paramIdentifier(p) {
2529
+ if (p.type === "identifier") return p;
2530
+ if (SPLAT_TYPES.has(p.type)) return null;
2531
+ const inner = p.childForFieldName("name") ?? p.namedChild(0);
2532
+ return inner?.type === "identifier" ? inner : null;
2533
+ }
2534
+ function assignedIdentifier(node) {
2535
+ if (node.type !== "assignment" || !node.childForFieldName("right")) return null;
2536
+ const left = node.childForFieldName("left");
2537
+ return left?.type === "identifier" ? left : null;
2538
+ }
2539
+ function ownBindings(fn) {
2540
+ const out = { locals: /* @__PURE__ */ new Set(), declared: /* @__PURE__ */ new Set(), declIds: /* @__PURE__ */ new Set() };
2541
+ for (const p of fn.childForFieldName("parameters")?.namedChildren ?? []) {
2542
+ const id = p ? paramIdentifier(p) : null;
2543
+ if (id) out.declIds.add(id.id);
2544
+ }
2545
+ const walk2 = (node) => {
2546
+ if (SCOPE_TYPES.has(node.type)) return;
2547
+ const id = assignedIdentifier(node);
2548
+ if (id) {
2549
+ out.locals.add(id.text);
2550
+ out.declIds.add(id.id);
2551
+ }
2552
+ if (node.type === "global_statement" || node.type === "nonlocal_statement") {
2553
+ for (const n of node.namedChildren) if (n) out.declared.add(n.text);
2554
+ }
2555
+ for (const child of node.namedChildren) if (child) walk2(child);
2556
+ };
2557
+ const body = fn.childForFieldName("body");
2558
+ if (body) walk2(body);
2559
+ return out;
2560
+ }
2561
+ function nestedBindings(fn) {
2562
+ const out = /* @__PURE__ */ new Set();
2563
+ const walk2 = (node, nested) => {
2564
+ const inner = nested || node !== fn && SCOPE_TYPES.has(node.type);
2565
+ if (inner) {
2566
+ const id = assignedIdentifier(node);
2567
+ if (id) out.add(id.text);
2568
+ if (node.type === "parameters" || node.type === "lambda_parameters") {
2569
+ for (const p of node.namedChildren) {
2570
+ const pid = p ? paramIdentifier(p) : null;
2571
+ if (pid) out.add(pid.text);
2572
+ }
2573
+ }
2574
+ }
2575
+ for (const child of node.namedChildren) if (child) walk2(child, inner);
2576
+ };
2577
+ walk2(fn, false);
2578
+ return out;
2579
+ }
2580
+ function countReferences(fn, declIds) {
2581
+ const out = /* @__PURE__ */ new Map();
2582
+ const walk2 = (node) => {
2583
+ if (node.type === "identifier" && !declIds.has(node.id)) {
2584
+ out.set(node.text, (out.get(node.text) ?? 0) + 1);
2585
+ }
2586
+ for (const child of node.namedChildren) if (child) walk2(child);
2587
+ };
2588
+ const body = fn.childForFieldName("body");
2589
+ if (body) walk2(body);
2590
+ return out;
2591
+ }
2592
+
2133
2593
  // src/analysis/dead-code.ts
2134
2594
  var DEAD_CODE_METRIC_NAMES = /* @__PURE__ */ new Set([
2135
2595
  "unreachable_statements",
@@ -2145,7 +2605,7 @@ var FUNCTION_TYPES = /* @__PURE__ */ new Set([
2145
2605
  "generator_function"
2146
2606
  ]);
2147
2607
  var PARAM_TYPES = /* @__PURE__ */ new Set(["required_parameter", "optional_parameter"]);
2148
- var TERMINALS = /* @__PURE__ */ new Set([
2608
+ var TERMINALS2 = /* @__PURE__ */ new Set([
2149
2609
  "return_statement",
2150
2610
  "throw_statement",
2151
2611
  "break_statement",
@@ -2154,13 +2614,13 @@ var TERMINALS = /* @__PURE__ */ new Set([
2154
2614
  function computeDeadCodeMetrics(files, fileIdOf) {
2155
2615
  const out = [];
2156
2616
  for (const file of files) {
2157
- if (file.language !== "typescript" && file.language !== "tsx") continue;
2617
+ const counts = deadCodeOf(file);
2618
+ if (!counts) continue;
2158
2619
  const id = fileIdOf(file.filePath);
2159
- const unreachable = countUnreachable(file.tree.rootNode);
2620
+ const { unreachable, locals, params } = counts;
2160
2621
  if (unreachable > 0) {
2161
2622
  out.push({ nodeId: id, name: "unreachable_statements", value: unreachable, unit: "count" });
2162
2623
  }
2163
- const { locals, params } = countUnusedBindings(file.tree.rootNode);
2164
2624
  if (locals > 0) {
2165
2625
  out.push({ nodeId: id, name: "unused_locals", value: locals, unit: "count" });
2166
2626
  }
@@ -2170,10 +2630,16 @@ function computeDeadCodeMetrics(files, fileIdOf) {
2170
2630
  }
2171
2631
  return out;
2172
2632
  }
2633
+ function deadCodeOf(file) {
2634
+ const root = file.tree.rootNode;
2635
+ if (file.language === "python") return pythonDeadCode(root);
2636
+ if (file.language !== "typescript" && file.language !== "tsx") return null;
2637
+ return { unreachable: countUnreachable(root), ...countUnusedBindings(root) };
2638
+ }
2173
2639
  function countUnreachable(root) {
2174
2640
  let count = 0;
2175
2641
  const visit = (node) => {
2176
- if (node.type === "statement_block") count += unreachableInBlock(node);
2642
+ if (node.type === "statement_block") count += unreachableInBlock2(node);
2177
2643
  for (const child of node.namedChildren) {
2178
2644
  if (child) visit(child);
2179
2645
  }
@@ -2181,7 +2647,7 @@ function countUnreachable(root) {
2181
2647
  visit(root);
2182
2648
  return count;
2183
2649
  }
2184
- function unreachableInBlock(block) {
2650
+ function unreachableInBlock2(block) {
2185
2651
  let seenTerminal = false;
2186
2652
  let count = 0;
2187
2653
  for (const child of block.namedChildren) {
@@ -2189,7 +2655,7 @@ function unreachableInBlock(block) {
2189
2655
  if (seenTerminal && child.type !== "comment" && child.type !== "function_declaration") {
2190
2656
  count++;
2191
2657
  }
2192
- if (TERMINALS.has(child.type)) seenTerminal = true;
2658
+ if (TERMINALS2.has(child.type)) seenTerminal = true;
2193
2659
  }
2194
2660
  return count;
2195
2661
  }
@@ -2198,7 +2664,7 @@ function countUnusedBindings(root) {
2198
2664
  let params = 0;
2199
2665
  const visit = (node) => {
2200
2666
  if (FUNCTION_TYPES.has(node.type)) {
2201
- const r = analyzeFunction(node);
2667
+ const r = analyzeFunction2(node);
2202
2668
  locals += r.locals;
2203
2669
  params += r.params;
2204
2670
  }
@@ -2209,14 +2675,14 @@ function countUnusedBindings(root) {
2209
2675
  visit(root);
2210
2676
  return { locals, params };
2211
2677
  }
2212
- function analyzeFunction(fn) {
2678
+ function analyzeFunction2(fn) {
2213
2679
  const declIds = /* @__PURE__ */ new Set();
2214
2680
  const nameCount = /* @__PURE__ */ new Map();
2215
2681
  collectBindingDecls(fn, declIds, nameCount);
2216
2682
  const refs = /* @__PURE__ */ new Map();
2217
- countReferences(fn, declIds, refs);
2683
+ countReferences2(fn, declIds, refs);
2218
2684
  const isUnused = (name) => !name.startsWith("_") && (nameCount.get(name) ?? 0) <= 1 && (refs.get(name) ?? 0) === 0;
2219
- const paramNames = ownParamNames(fn);
2685
+ const paramNames = ownParamNames2(fn);
2220
2686
  let params = 0;
2221
2687
  for (let i = paramNames.length - 1; i >= 0; i--) {
2222
2688
  const name = paramNames[i] ?? null;
@@ -2229,7 +2695,7 @@ function analyzeFunction(fn) {
2229
2695
  }
2230
2696
  return { locals, params };
2231
2697
  }
2232
- function ownParamNames(fn) {
2698
+ function ownParamNames2(fn) {
2233
2699
  const params = fn.childForFieldName("parameters");
2234
2700
  if (!params) return [];
2235
2701
  const out = [];
@@ -2272,7 +2738,7 @@ function collectBindingDecls(fn, ids, nameCount) {
2272
2738
  };
2273
2739
  walk2(fn);
2274
2740
  }
2275
- function countReferences(fn, declIds, out) {
2741
+ function countReferences2(fn, declIds, out) {
2276
2742
  const walk2 = (node) => {
2277
2743
  if ((node.type === "identifier" || node.type === "shorthand_property_identifier") && !declIds.has(node.id)) {
2278
2744
  out.set(node.text, (out.get(node.text) ?? 0) + 1);
@@ -2962,7 +3428,7 @@ function qualifiedByName(file) {
2962
3428
  }
2963
3429
 
2964
3430
  // src/indexer.ts
2965
- var INDEX_VERSION = "0.15.0";
3431
+ var INDEX_VERSION = "0.17.0";
2966
3432
  var LANGUAGES = ["typescript", "python"];
2967
3433
  function canonicalizePath(p) {
2968
3434
  try {
@@ -3930,6 +4396,116 @@ var SOURCE = [
3930
4396
  source: "source-metrics",
3931
4397
  description: "Cyclomatic complexity of the function a symbol names; the max when several functions share the name."
3932
4398
  },
4399
+ {
4400
+ name: "symbol_loc",
4401
+ unit: "lines",
4402
+ appliesTo: SYMBOL,
4403
+ rollup: "max",
4404
+ direction: "higher-worse",
4405
+ absent: "exclude",
4406
+ source: "source-metrics",
4407
+ description: "Lines spanned by the function a symbol names, signature included; the max when several share the name."
4408
+ },
4409
+ {
4410
+ name: "symbol_max_nesting",
4411
+ unit: "count",
4412
+ appliesTo: SYMBOL,
4413
+ rollup: "max",
4414
+ direction: "higher-worse",
4415
+ absent: "exclude",
4416
+ source: "source-metrics",
4417
+ description: "Deepest block nesting inside the function a symbol names; the max when several share the name."
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
+ },
3933
4509
  {
3934
4510
  name: "class_count",
3935
4511
  unit: "count",
@@ -4353,6 +4929,43 @@ function locateNode(node) {
4353
4929
  return location;
4354
4930
  }
4355
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
+
4356
4969
  // src/check/metric-rules.ts
4357
4970
  function nodeFilter(rule) {
4358
4971
  return {
@@ -4372,7 +4985,7 @@ function candidateNodes(rule, ctx) {
4372
4985
  const pool = rule.kind === "symbol" ? ctx.symbolNodes : ctx.nodes;
4373
4986
  return pool.filter((node) => passesFilter(node, filter));
4374
4987
  }
4375
- function toViolation(rule, breach) {
4988
+ function toViolation2(rule, breach) {
4376
4989
  return {
4377
4990
  ruleId: rule.id,
4378
4991
  severity: severityOf(rule),
@@ -4402,7 +5015,7 @@ function runMetricMaxRule(rule, ctx) {
4402
5015
  for (const node of candidateNodes(rule, ctx)) {
4403
5016
  const value = ctx.metricsByNode.get(node.id)?.get(rule.metric);
4404
5017
  if (value === void 0 || value <= rule.max) continue;
4405
- 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)));
4406
5019
  }
4407
5020
  return out;
4408
5021
  }
@@ -4411,7 +5024,7 @@ function runMetricMinRule(rule, ctx) {
4411
5024
  for (const node of candidateNodes(rule, ctx)) {
4412
5025
  const value = ctx.metricsByNode.get(node.id)?.get(rule.metric);
4413
5026
  if (value === void 0 || value >= rule.min) continue;
4414
- 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)));
4415
5028
  }
4416
5029
  return out;
4417
5030
  }
@@ -4424,7 +5037,7 @@ function runMetricProductMaxRule(rule, ctx) {
4424
5037
  if (!components) continue;
4425
5038
  const product = components.reduce((a, b) => a * b, 1);
4426
5039
  if (product <= rule.max) continue;
4427
- out.push(toViolation(rule, productBreach(rule, node, components, product)));
5040
+ out.push(toViolation2(rule, productBreach(rule, node, components, product)));
4428
5041
  }
4429
5042
  return out;
4430
5043
  }
@@ -4458,6 +5071,8 @@ function runRule(rule, ctx) {
4458
5071
  return runMetricMinRule(rule, ctx);
4459
5072
  case "metric-product-max":
4460
5073
  return runMetricProductMaxRule(rule, ctx);
5074
+ case "metric-outlier":
5075
+ return runMetricOutlierRule(rule, ctx);
4461
5076
  case "forbid-import":
4462
5077
  return runForbidImportRule(rule, ctx);
4463
5078
  case "layered-deps":
@@ -4602,6 +5217,8 @@ function validateRule(raw, index, warn) {
4602
5217
  return assertMetricMin(r, warn);
4603
5218
  case "metric-product-max":
4604
5219
  return assertMetricProductMax(r, warn);
5220
+ case "metric-outlier":
5221
+ return assertMetricOutlier(r, warn);
4605
5222
  case "forbid-import":
4606
5223
  return assertForbidImport(r);
4607
5224
  case "layered-deps":
@@ -4711,6 +5328,29 @@ function assertMetricMin(r, warn) {
4711
5328
  excludeRoles: parseRoleArray(ruleId, r.excludeRoles, warn)
4712
5329
  };
4713
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
+ }
4714
5354
  function assertForbidImport(r) {
4715
5355
  if (typeof r.from !== "string") throw new Error(`${r.id}: from must be a string`);
4716
5356
  if (typeof r.to !== "string") throw new Error(`${r.id}: to must be a string`);
@@ -5411,7 +6051,9 @@ export {
5411
6051
  CodeGraphStore,
5412
6052
  DEAD_CODE_METRIC_NAMES,
5413
6053
  DEFAULT_CHURN_WINDOWS,
6054
+ DEFAULT_OUTLIER_MIN_SAMPLE,
5414
6055
  DOMAIN_DDL,
6056
+ EXCEPTION_METRIC_NAMES,
5415
6057
  GROWTH_RISK_METRIC_NAMES,
5416
6058
  INDEX_VERSION,
5417
6059
  KIT,
@@ -5424,6 +6066,7 @@ export {
5424
6066
  SOURCE_METRIC_NAMES,
5425
6067
  SYMBOL_EMBEDDING_NAMESPACE,
5426
6068
  SYMBOL_ID_SEP,
6069
+ SYMBOL_METRIC_NAMES,
5427
6070
  TsMorphGraphExtractor,
5428
6071
  aggregateMetrics,
5429
6072
  aliasChain,
@@ -5506,6 +6149,7 @@ export {
5506
6149
  parseFile2 as parseFile,
5507
6150
  parseSymbolId,
5508
6151
  patternToRegex,
6152
+ percentileOf,
5509
6153
  planPrune,
5510
6154
  priorSnapshotForRef,
5511
6155
  pruneDanglingReferences,