@lifeaitools/rdc-skills 0.34.1 → 0.35.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.
Files changed (60) hide show
  1. package/.claude-plugin/plugin.json +284 -1
  2. package/VALIDATOR-ARCHITECTURE.md +534 -0
  3. package/commands/analyze-tests.md +11 -0
  4. package/commands/check-clean-code.md +11 -0
  5. package/commands/check-packages.md +10 -0
  6. package/commands/compare-compliance.md +14 -0
  7. package/commands/full-analysis.md +50 -0
  8. package/commands/get-refactoring-plan.md +13 -0
  9. package/commands/quick-check.md +13 -0
  10. package/commands/recover.md +149 -0
  11. package/commands/review-arch.md +12 -0
  12. package/commands/review.md +12 -113
  13. package/commands/suggest-patterns.md +11 -0
  14. package/commands/validate-solid.md +11 -0
  15. package/package.json +14 -2
  16. package/scripts/architecture-score.mjs +157 -0
  17. package/scripts/clean-code-score.mjs +177 -0
  18. package/scripts/duplication-score.mjs +66 -0
  19. package/scripts/lib/architecture-scoring.mjs +695 -0
  20. package/scripts/lib/clean-code-scoring.mjs +258 -0
  21. package/scripts/lib/duplication-scoring.mjs +238 -0
  22. package/scripts/lib/language-plugin.mjs +82 -0
  23. package/scripts/lib/package-metrics.mjs +439 -0
  24. package/scripts/lib/pattern-scoring.mjs +351 -0
  25. package/scripts/lib/plugins/treesitter.mjs +1182 -0
  26. package/scripts/lib/plugins/typescript.mjs +672 -0
  27. package/scripts/lib/refactoring-scoring.mjs +307 -0
  28. package/scripts/lib/solid-scoring.mjs +101 -0
  29. package/scripts/lib/test-smell-scoring.mjs +581 -0
  30. package/scripts/lib/vendor/codeflow-parser/.source-commit +1 -0
  31. package/scripts/lib/vendor/codeflow-parser/grammars.d.ts +23 -0
  32. package/scripts/lib/vendor/codeflow-parser/grammars.js +57 -0
  33. package/scripts/lib/vendor/codeflow-parser/memberFacts.d.ts +274 -0
  34. package/scripts/lib/vendor/codeflow-parser/memberFacts.js +1117 -0
  35. package/scripts/lib/vendor/codeflow-parser/nativeParser.d.ts +115 -0
  36. package/scripts/lib/vendor/codeflow-parser/nativeParser.js +759 -0
  37. package/scripts/lib/vendor/codeflow-parser/package.json +3 -0
  38. package/scripts/lib/vendor/codeflow-parser/xmlParser.d.ts +77 -0
  39. package/scripts/lib/vendor/codeflow-parser/xmlParser.js +400 -0
  40. package/scripts/package-metrics-cli.mjs +112 -0
  41. package/scripts/pattern-score.mjs +143 -0
  42. package/scripts/refactoring-score.mjs +253 -0
  43. package/scripts/solid-score.mjs +337 -0
  44. package/skills/architecture-reviewer/SKILL.md +287 -0
  45. package/skills/clean-code-analyzer/SKILL.md +147 -0
  46. package/skills/package-design/SKILL.md +118 -0
  47. package/skills/pattern-advisor/SKILL.md +237 -0
  48. package/skills/pattern-refactoring-guide/SKILL.md +262 -0
  49. package/skills/review/SKILL.md +29 -0
  50. package/skills/solid-validator/SKILL.md +92 -0
  51. package/skills/testing-strategy/SKILL.md +132 -0
  52. package/tests/lib/architecture-scoring.test.mjs +335 -0
  53. package/tests/lib/clean-code-scoring.test.mjs +241 -0
  54. package/tests/lib/duplication-scoring.test.mjs +144 -0
  55. package/tests/lib/fixtures.mjs +58 -0
  56. package/tests/lib/package-metrics.test.mjs +241 -0
  57. package/tests/lib/pattern-scoring.test.mjs +251 -0
  58. package/tests/lib/refactoring-scoring.test.mjs +264 -0
  59. package/tests/lib/solid-scoring.test.mjs +291 -0
  60. package/tests/lib/test-smell-scoring.test.mjs +281 -0
@@ -0,0 +1,1117 @@
1
+ /**
2
+ * memberFacts.ts — per-member structural facts for the validation engine.
3
+ *
4
+ * WHY THIS MODULE EXISTS
5
+ * ----------------------
6
+ * `nativeParser.ts` extracted calls only under
7
+ * `exported && declNode.type === 'function_declaration'`. Class methods —
8
+ * which are most of what SOLID / Clean Code / design-pattern / refactoring
9
+ * scoring actually reads — produced nothing at all: no calls, and in
10
+ * nativeParser not even a symbol. A scorer fed that surface cannot tell an
11
+ * empty class from an unparsed one.
12
+ *
13
+ * This module walks EVERY callable body (free function, class method,
14
+ * constructor, accessor, arrow/lambda, nested helper) and reports the
15
+ * structural facts a scorer needs, per member and per unit.
16
+ *
17
+ * CONSUMER SPLIT — deliberate, not an oversight
18
+ * ---------------------------------------------
19
+ * CodeFlow's graph wants the coarse surface (symbols / calls / imports) and is
20
+ * unchanged by this file. The validation engine wants this. Both are served
21
+ * from ONE parse of ONE tree, so the two views can never disagree about what
22
+ * the source said — which is the failure mode that produced this work in the
23
+ * first place (two independent parsers, same bug in both).
24
+ *
25
+ * NON-FUNCTIONAL CONTRACT
26
+ * -----------------------
27
+ * - **Deterministic.** Identical bytes for identical input, across calls and
28
+ * across processes. Every collection is emitted in a defined order (source
29
+ * order, or lexicographic where the set has no natural order). No
30
+ * timestamps. No absolute paths — `path` is echoed exactly as the caller
31
+ * supplied it and is never resolved against the filesystem.
32
+ * - **No accumulating per-process state.** Every exported function here is
33
+ * pure over (node, profile) and allocates its own working set. The real
34
+ * defect behind this requirement was ts-morph's shared `Project` silently
35
+ * returning wrong reference counts once ~113 files accumulated in one
36
+ * process; nothing in this module caches across calls, so that class of
37
+ * drift cannot occur.
38
+ * - **In-process, no network.** No service, no port, no grammar download.
39
+ *
40
+ * GRAMMAR PORTABILITY
41
+ * -------------------
42
+ * Every tree-sitter grammar spells the same construct differently — `call` vs
43
+ * `call_expression` vs `invocation_expression` is the bug that cost python and
44
+ * csharp their entire CALLS surface. So node types live in one table
45
+ * (`LANGUAGE_PROFILES`) rather than being written inline at each test site. A
46
+ * new language is a table row, and a language with no row degrades to the
47
+ * C-like defaults with `profile_complete: false` on the result rather than
48
+ * silently reporting zeros that look like clean code.
49
+ */
50
+ const TS_LIKE = {
51
+ call: ['call_expression'],
52
+ newExpr: ['new_expression'],
53
+ newTypeField: ['constructor'],
54
+ func: ['function_declaration', 'generator_function_declaration', 'function_expression', 'function'],
55
+ method: ['method_definition', 'method_signature'],
56
+ constructorDecl: [],
57
+ klass: ['class_declaration', 'class', 'abstract_class_declaration'],
58
+ lambda: ['arrow_function'],
59
+ selfWords: ['this'],
60
+ selfNodes: ['this'],
61
+ memberAccess: ['member_expression'],
62
+ memberObjectField: 'object',
63
+ memberPropertyField: 'property',
64
+ ifStmt: ['if_statement'],
65
+ switchStmt: ['switch_statement'],
66
+ switchCase: ['switch_case'],
67
+ switchDefault: ['switch_default'],
68
+ catchClause: ['catch_clause'],
69
+ whileStmt: ['while_statement', 'do_statement'],
70
+ numberLit: ['number'],
71
+ trueLit: ['true'],
72
+ falseLit: ['false'],
73
+ nullLit: ['null', 'undefined'],
74
+ extraStatements: ['lexical_declaration', 'variable_declaration'],
75
+ blocks: ['statement_block', 'class_body', 'program'],
76
+ paramsField: ['parameters', 'parameter'],
77
+ bodyField: ['body'],
78
+ nameField: ['name'],
79
+ conditionField: ['condition'],
80
+ fieldDecl: ['public_field_definition', 'field_definition', 'property_signature'],
81
+ eqOperators: ['===', '!==', '==', '!='],
82
+ };
83
+ const PYTHON = {
84
+ call: ['call'],
85
+ // Python has no `new`; construction is a call to a class name, resolved by
86
+ // the caller against the file's declared classes rather than guessed here.
87
+ newExpr: [],
88
+ newTypeField: ['function'],
89
+ func: ['function_definition'],
90
+ method: [],
91
+ constructorDecl: [],
92
+ klass: ['class_definition'],
93
+ lambda: ['lambda'],
94
+ selfWords: ['self', 'cls'],
95
+ selfNodes: [],
96
+ memberAccess: ['attribute'],
97
+ memberObjectField: 'object',
98
+ memberPropertyField: 'attribute',
99
+ ifStmt: ['if_statement', 'elif_clause'],
100
+ switchStmt: ['match_statement'],
101
+ switchCase: ['case_clause'],
102
+ switchDefault: [],
103
+ catchClause: ['except_clause'],
104
+ whileStmt: ['while_statement'],
105
+ numberLit: ['integer', 'float'],
106
+ trueLit: ['true'],
107
+ falseLit: ['false'],
108
+ nullLit: ['none'],
109
+ extraStatements: [],
110
+ blocks: ['block', 'module'],
111
+ paramsField: ['parameters'],
112
+ bodyField: ['body'],
113
+ nameField: ['name'],
114
+ conditionField: ['condition'],
115
+ fieldDecl: [],
116
+ eqOperators: ['==', '!=', 'is', 'is not'],
117
+ };
118
+ const CSHARP = {
119
+ call: ['invocation_expression'],
120
+ newExpr: ['object_creation_expression'],
121
+ newTypeField: ['type'],
122
+ func: ['local_function_statement'],
123
+ method: ['method_declaration', 'property_declaration', 'accessor_declaration'],
124
+ constructorDecl: ['constructor_declaration'],
125
+ klass: ['class_declaration', 'struct_declaration', 'record_declaration'],
126
+ lambda: ['lambda_expression'],
127
+ selfWords: ['this'],
128
+ selfNodes: ['this_expression'],
129
+ memberAccess: ['member_access_expression'],
130
+ memberObjectField: 'expression',
131
+ memberPropertyField: 'name',
132
+ ifStmt: ['if_statement'],
133
+ switchStmt: ['switch_statement', 'switch_expression'],
134
+ switchCase: ['switch_section', 'switch_expression_arm'],
135
+ switchDefault: ['default_switch_label'],
136
+ catchClause: ['catch_clause'],
137
+ whileStmt: ['while_statement', 'do_statement'],
138
+ numberLit: ['integer_literal', 'real_literal'],
139
+ trueLit: ['boolean_literal'],
140
+ falseLit: [],
141
+ nullLit: ['null_literal'],
142
+ extraStatements: ['local_declaration_statement'],
143
+ blocks: ['block', 'declaration_list', 'compilation_unit'],
144
+ paramsField: ['parameters', 'parameter_list'],
145
+ bodyField: ['body'],
146
+ nameField: ['name'],
147
+ conditionField: ['condition'],
148
+ fieldDecl: ['field_declaration', 'property_declaration'],
149
+ eqOperators: ['==', '!='],
150
+ };
151
+ const C_LIKE = {
152
+ call: ['call_expression'],
153
+ newExpr: ['new_expression'],
154
+ newTypeField: ['type', 'constructor'],
155
+ func: ['function_definition'],
156
+ method: [],
157
+ constructorDecl: [],
158
+ klass: ['class_specifier', 'struct_specifier'],
159
+ lambda: ['lambda_expression'],
160
+ selfWords: ['this'],
161
+ selfNodes: ['this'],
162
+ memberAccess: ['field_expression'],
163
+ memberObjectField: 'argument',
164
+ memberPropertyField: 'field',
165
+ ifStmt: ['if_statement'],
166
+ switchStmt: ['switch_statement'],
167
+ switchCase: ['case_statement'],
168
+ switchDefault: [],
169
+ catchClause: ['catch_clause'],
170
+ whileStmt: ['while_statement', 'do_statement'],
171
+ numberLit: ['number_literal'],
172
+ trueLit: ['true'],
173
+ falseLit: ['false'],
174
+ nullLit: ['null', 'nullptr'],
175
+ extraStatements: ['declaration'],
176
+ blocks: ['compound_statement', 'field_declaration_list', 'translation_unit'],
177
+ paramsField: ['parameters', 'parameter_list'],
178
+ bodyField: ['body'],
179
+ // NOT 'declarator': in the C grammar that field's text is `main(void)` —
180
+ // the whole declarator including the parameter list — so using it as a name
181
+ // yields symbols nothing can ever match. memberName falls back to the first
182
+ // identifier inside the declarator instead.
183
+ nameField: ['name'],
184
+ conditionField: ['condition'],
185
+ fieldDecl: ['field_declaration'],
186
+ eqOperators: ['==', '!='],
187
+ };
188
+ const LANGUAGE_PROFILES = {
189
+ typescript: TS_LIKE,
190
+ javascript: TS_LIKE,
191
+ python: PYTHON,
192
+ csharp: CSHARP,
193
+ c: C_LIKE,
194
+ cpp: C_LIKE,
195
+ };
196
+ /** True when this language has a hand-written profile (not the fallback). */
197
+ export function hasLanguageProfile(language) {
198
+ return Object.hasOwn(LANGUAGE_PROFILES, language);
199
+ }
200
+ export function profileFor(language) {
201
+ return LANGUAGE_PROFILES[language] ?? C_LIKE;
202
+ }
203
+ // ── Small tree helpers ───────────────────────────────────────────────────────
204
+ /** Named children as a plain array. Tree-sitter nodes are not iterable. */
205
+ function namedChildren(node) {
206
+ const out = [];
207
+ if (!node)
208
+ return out;
209
+ for (let i = 0; i < node.namedChildCount; i++) {
210
+ const c = node.namedChild(i);
211
+ if (c)
212
+ out.push(c);
213
+ }
214
+ return out;
215
+ }
216
+ function allChildren(node) {
217
+ const out = [];
218
+ if (!node)
219
+ return out;
220
+ for (let i = 0; i < node.childCount; i++) {
221
+ const c = node.child(i);
222
+ if (c)
223
+ out.push(c);
224
+ }
225
+ return out;
226
+ }
227
+ function fieldOf(node, names) {
228
+ if (!node)
229
+ return null;
230
+ for (const name of names) {
231
+ const found = node.childForFieldName?.(name);
232
+ if (found)
233
+ return found;
234
+ }
235
+ return null;
236
+ }
237
+ /** Collapse all whitespace runs to single spaces and trim. */
238
+ export function normalizeText(text) {
239
+ return text.replace(/\s+/g, ' ').trim();
240
+ }
241
+ /**
242
+ * Strip one layer of grouping parens so a condition can be inspected
243
+ * structurally. `if (x === null)` hands us `(x === null)` in the C-like
244
+ * grammars and `x === null` in python; both must reduce to the comparison.
245
+ */
246
+ function unwrapGrouping(node) {
247
+ let current = node;
248
+ while (current
249
+ && (current.type === 'parenthesized_expression' || current.type === 'parenthesized_declarator')) {
250
+ const inner = namedChildren(current).find(c => c.type !== 'comment');
251
+ if (!inner)
252
+ break;
253
+ current = inner;
254
+ }
255
+ return current;
256
+ }
257
+ function operatorText(node) {
258
+ const field = node?.childForFieldName?.('operator');
259
+ if (field)
260
+ return field.text;
261
+ // Some grammars leave the operator as an anonymous child rather than a field.
262
+ for (const child of allChildren(node)) {
263
+ if (!child.isNamed && child.text)
264
+ return child.text;
265
+ }
266
+ return '';
267
+ }
268
+ /** Does this node type read as a statement in this grammar? */
269
+ function isStatementNode(node, profile) {
270
+ const t = node.type;
271
+ return t.endsWith('_statement') || profile.extraStatements.includes(t);
272
+ }
273
+ /** Every callable-body node type, for "stop descending at a nested member". */
274
+ function callableTypes(profile) {
275
+ return new Set([
276
+ ...profile.func,
277
+ ...profile.method,
278
+ ...profile.constructorDecl,
279
+ ...profile.lambda,
280
+ ]);
281
+ }
282
+ /**
283
+ * Walk a member body WITHOUT crossing into a nested callable.
284
+ *
285
+ * This boundary is the difference between "facts about this method" and "facts
286
+ * about this method and everything lexically inside it". A closure passed to
287
+ * `map()` is its own member with its own facts; folding its statements into
288
+ * the enclosing method would inflate every complexity signal and make an
289
+ * ordinary functional style read as an unmaintainable body.
290
+ */
291
+ function walkOwnBody(root, profile, visit) {
292
+ const nested = callableTypes(profile);
293
+ function go(node, isRoot) {
294
+ if (!isRoot && nested.has(node.type))
295
+ return;
296
+ visit(node);
297
+ for (const child of allChildren(node))
298
+ go(child, false);
299
+ }
300
+ if (root)
301
+ go(root, true);
302
+ }
303
+ // ── Per-fact extractors ──────────────────────────────────────────────────────
304
+ /** `this.x` / `self.x` names touched in the body. Deduped, lexicographic. */
305
+ function extractFieldAccess(body, profile) {
306
+ const names = new Set();
307
+ walkOwnBody(body, profile, node => {
308
+ if (!profile.memberAccess.includes(node.type))
309
+ return;
310
+ const object = node.childForFieldName?.(profile.memberObjectField);
311
+ if (!object)
312
+ return;
313
+ const isSelf = profile.selfNodes.includes(object.type) || profile.selfWords.includes(object.text);
314
+ if (!isSelf)
315
+ return;
316
+ const property = node.childForFieldName?.(profile.memberPropertyField);
317
+ if (property?.text)
318
+ names.add(property.text);
319
+ });
320
+ return [...names].sort();
321
+ }
322
+ /**
323
+ * Receiver chain depth for a call's function expression.
324
+ * `f()` → 0 · `a.f()` → 1 · `a.b.f()` → 2.
325
+ */
326
+ function receiverDepth(fnNode, profile) {
327
+ let depth = 0;
328
+ let current = fnNode;
329
+ while (current && profile.memberAccess.includes(current.type)) {
330
+ depth++;
331
+ current = current.childForFieldName?.(profile.memberObjectField);
332
+ }
333
+ return depth;
334
+ }
335
+ /** Bare trailing name of a call target, receiver fully stripped. */
336
+ function calleeNameOf(fnNode, profile) {
337
+ if (!fnNode)
338
+ return null;
339
+ if (profile.memberAccess.includes(fnNode.type)) {
340
+ const property = fnNode.childForFieldName?.(profile.memberPropertyField);
341
+ return property?.text ?? null;
342
+ }
343
+ // Fall back to splitting the source text — covers `a::b` and `a->b` in the
344
+ // C-family, where the access node type varies with the operator used.
345
+ const parts = fnNode.text.split(/->|::|\./);
346
+ const last = parts[parts.length - 1];
347
+ return last ? last.trim() : null;
348
+ }
349
+ function extractCallFacts(body, profile) {
350
+ const calleeNames = [];
351
+ const externalReceivers = new Set();
352
+ let deepChainCallCount = 0;
353
+ walkOwnBody(body, profile, node => {
354
+ if (!profile.call.includes(node.type))
355
+ return;
356
+ const fnNode = node.childForFieldName?.('function') ?? node.childForFieldName?.('expression');
357
+ if (!fnNode)
358
+ return;
359
+ const name = calleeNameOf(fnNode, profile);
360
+ if (name)
361
+ calleeNames.push(name);
362
+ const depth = receiverDepth(fnNode, profile);
363
+ if (depth >= 2)
364
+ deepChainCallCount++;
365
+ if (depth >= 1) {
366
+ const object = fnNode.childForFieldName?.(profile.memberObjectField);
367
+ const receiverText = object?.text;
368
+ const isSelf = object
369
+ && (profile.selfNodes.includes(object.type) || profile.selfWords.includes(object.text));
370
+ if (receiverText && !isSelf)
371
+ externalReceivers.add(normalizeText(receiverText));
372
+ }
373
+ });
374
+ return {
375
+ calleeNames,
376
+ deepChainCallCount,
377
+ externalReceivers: [...externalReceivers].sort(),
378
+ };
379
+ }
380
+ /**
381
+ * Every `new X()` target, unfiltered and in source order.
382
+ *
383
+ * "Unfiltered" is load-bearing: the scorer decides what counts as a concrete
384
+ * dependency, not the parser. Dropping `new Error()` here because it looks
385
+ * uninteresting would silently change a dependency-inversion score.
386
+ */
387
+ function extractNewTargets(body, profile) {
388
+ const targets = [];
389
+ walkOwnBody(body, profile, node => {
390
+ if (!profile.newExpr.includes(node.type))
391
+ return;
392
+ const typeNode = fieldOf(node, profile.newTypeField) ?? namedChildren(node)[0];
393
+ if (typeNode?.text)
394
+ targets.push(normalizeText(typeNode.text));
395
+ });
396
+ return targets;
397
+ }
398
+ /** Statement-kind descendants: count, and normalized text of the long ones. */
399
+ function extractStatements(body, profile) {
400
+ let statementCount = 0;
401
+ const statementTexts = [];
402
+ walkOwnBody(body, profile, node => {
403
+ if (!isStatementNode(node, profile))
404
+ return;
405
+ statementCount++;
406
+ const text = normalizeText(node.text);
407
+ if (text.length > 10)
408
+ statementTexts.push(text);
409
+ });
410
+ return { statementCount, statementTexts };
411
+ }
412
+ /** Parameters, then local bindings, each with its declaration line. */
413
+ function extractDeclaredNames(member, body, profile) {
414
+ const declaredNames = [];
415
+ const params = findParamsNode(member, profile);
416
+ const paramNodes = namedChildren(params).filter(p => p.type !== 'comment');
417
+ for (const param of paramNodes) {
418
+ const name = parameterName(param);
419
+ if (name)
420
+ declaredNames.push({ name, line: param.startPosition.row + 1, kind: 'param' });
421
+ }
422
+ walkOwnBody(body, profile, node => {
423
+ if (node.type === 'variable_declarator') {
424
+ const nameNode = node.childForFieldName?.('name');
425
+ if (nameNode?.text) {
426
+ declaredNames.push({
427
+ name: nameNode.text,
428
+ line: node.startPosition.row + 1,
429
+ kind: declarationKind(node),
430
+ });
431
+ }
432
+ return;
433
+ }
434
+ // Python and the C-family bind without a declarator node.
435
+ if (node.type === 'assignment' || node.type === 'init_declarator') {
436
+ const left = node.childForFieldName?.('left') ?? node.childForFieldName?.('declarator');
437
+ if (left && left.type === 'identifier') {
438
+ declaredNames.push({
439
+ name: left.text,
440
+ line: node.startPosition.row + 1,
441
+ kind: 'local',
442
+ });
443
+ }
444
+ }
445
+ });
446
+ return { declaredNames, paramCount: paramNodes.length };
447
+ }
448
+ /**
449
+ * The node holding a member's parameter list.
450
+ *
451
+ * A field lookup alone is not enough: in the C grammar the parameters live
452
+ * under `declarator > parameter_list`, not on a `parameters` field, so a
453
+ * field-only lookup returns the declarator and counts the function's own name
454
+ * as a parameter. The bounded descendant search covers that shape without
455
+ * needing a per-grammar path expression.
456
+ */
457
+ function findParamsNode(member, profile) {
458
+ const direct = fieldOf(member, profile.paramsField);
459
+ if (direct && /parameter_list$|^parameters$|^formal_parameters$/.test(direct.type))
460
+ return direct;
461
+ const declarator = member.childForFieldName?.('declarator') ?? direct ?? member;
462
+ let found = null;
463
+ (function search(node, depth) {
464
+ if (found || !node || depth > 3)
465
+ return;
466
+ if (/parameter_list$|^parameters$|^formal_parameters$/.test(node.type)) {
467
+ found = node;
468
+ return;
469
+ }
470
+ for (const child of namedChildren(node))
471
+ search(child, depth + 1);
472
+ })(declarator, 0);
473
+ return found ?? direct;
474
+ }
475
+ /** Peel type annotations, defaults and patterns down to the bound identifier. */
476
+ function parameterName(param) {
477
+ if (!param)
478
+ return null;
479
+ if (param.type === 'identifier' || param.type === 'shorthand_property_identifier_pattern') {
480
+ return param.text;
481
+ }
482
+ const pattern = param.childForFieldName?.('pattern')
483
+ ?? param.childForFieldName?.('name')
484
+ ?? param.childForFieldName?.('declarator');
485
+ if (pattern)
486
+ return parameterName(pattern);
487
+ const firstIdentifier = namedChildren(param).find(c => /identifier$/.test(c.type));
488
+ if (firstIdentifier?.text)
489
+ return firstIdentifier.text;
490
+ const raw = normalizeText(param.text);
491
+ return raw.length > 0 ? raw : null;
492
+ }
493
+ function declarationKind(declarator) {
494
+ const parentText = declarator.parent?.text ?? '';
495
+ if (parentText.startsWith('const'))
496
+ return 'const';
497
+ if (parentText.startsWith('let'))
498
+ return 'let';
499
+ if (parentText.startsWith('var'))
500
+ return 'var';
501
+ return 'local';
502
+ }
503
+ /**
504
+ * `if` chains, `switch` shapes, type checks — everything a scorer reads as a
505
+ * branch, plus the two conditional-quality signals.
506
+ */
507
+ function extractBranchFacts(body, profile) {
508
+ const switchStatements = [];
509
+ const complexConditionals = [];
510
+ let branchHits = 0;
511
+ let nullChecks = 0;
512
+ let deadConditionals = 0;
513
+ walkOwnBody(body, profile, node => {
514
+ // if / else-if chains — counted once per chain head so a 3-arm chain
515
+ // contributes 3, not 6.
516
+ if (profile.ifStmt.includes(node.type)) {
517
+ const condition = unwrapGrouping(fieldOf(node, profile.conditionField));
518
+ if (condition) {
519
+ const text = normalizeText(condition.text);
520
+ if (text.length >= 50)
521
+ complexConditionals.push(text);
522
+ if (isNullComparison(condition, profile))
523
+ nullChecks++;
524
+ if (isBooleanLiteral(condition, profile))
525
+ deadConditionals++;
526
+ }
527
+ if (!isChainContinuation(node, profile))
528
+ branchHits += chainLength(node, profile);
529
+ }
530
+ if (profile.whileStmt.includes(node.type)) {
531
+ const condition = unwrapGrouping(fieldOf(node, profile.conditionField));
532
+ if (condition && isFalseLiteral(condition, profile))
533
+ deadConditionals++;
534
+ }
535
+ if (profile.switchStmt.includes(node.type)) {
536
+ const fact = describeSwitch(node, profile);
537
+ switchStatements.push(fact);
538
+ // A `default` is a reachable arm, so it is a branch even though it is
539
+ // not a `case`. Counting only `caseCount` here would under-report every
540
+ // exhaustive switch by exactly one.
541
+ branchHits += fact.caseCount + (fact.hasDefault ? 1 : 0);
542
+ }
543
+ if (isTypeCheck(node, profile))
544
+ branchHits++;
545
+ });
546
+ return { branchHits, switchStatements, complexConditionals, nullChecks, deadConditionals };
547
+ }
548
+ /** True when this `if` is the `else` arm of another `if` (an else-if link). */
549
+ function isChainContinuation(node, profile) {
550
+ const parent = node.parent;
551
+ if (!parent)
552
+ return false;
553
+ if (profile.ifStmt.includes(parent.type)) {
554
+ const alternative = parent.childForFieldName?.('alternative');
555
+ if (alternative === node)
556
+ return true;
557
+ // Some grammars wrap the else arm in an `else_clause`.
558
+ if (alternative && namedChildren(alternative).includes(node))
559
+ return true;
560
+ }
561
+ if (parent.type === 'else_clause' && parent.parent && profile.ifStmt.includes(parent.parent.type)) {
562
+ return true;
563
+ }
564
+ // python spells else-if as its own `elif_clause` child of the head `if`.
565
+ if (node.type === 'elif_clause')
566
+ return true;
567
+ return false;
568
+ }
569
+ /** Number of arms in an if/else-if chain headed by this node. */
570
+ function chainLength(node, profile) {
571
+ let length = 1;
572
+ let current = node;
573
+ // python: every `elif_clause` is a sibling child of the head `if`.
574
+ const elifArms = namedChildren(node).filter(c => c.type === 'elif_clause').length;
575
+ if (elifArms > 0)
576
+ return 1 + elifArms;
577
+ while (current) {
578
+ let alternative = current.childForFieldName?.('alternative');
579
+ if (alternative && alternative.type === 'else_clause') {
580
+ alternative = namedChildren(alternative).find(c => c.type !== 'comment');
581
+ }
582
+ if (alternative && profile.ifStmt.includes(alternative.type)) {
583
+ length++;
584
+ current = alternative;
585
+ }
586
+ else {
587
+ break;
588
+ }
589
+ }
590
+ return length;
591
+ }
592
+ function isBooleanLiteral(node, profile) {
593
+ if (!node)
594
+ return false;
595
+ if (profile.trueLit.includes(node.type) || profile.falseLit.includes(node.type))
596
+ return true;
597
+ const text = node.text?.trim();
598
+ return text === 'true' || text === 'false' || text === 'True' || text === 'False';
599
+ }
600
+ function isFalseLiteral(node, profile) {
601
+ if (!node)
602
+ return false;
603
+ const text = node.text?.trim();
604
+ if (text === 'false' || text === 'False')
605
+ return true;
606
+ return profile.falseLit.includes(node.type) && text === 'false';
607
+ }
608
+ /** A top-level `x === null` / `x is None` / `x != null` comparison. */
609
+ function isNullComparison(condition, profile) {
610
+ const node = unwrapGrouping(condition);
611
+ if (!node)
612
+ return false;
613
+ const isComparison = node.type === 'binary_expression'
614
+ || node.type === 'comparison_operator'
615
+ || node.type === 'equality_expression';
616
+ if (!isComparison)
617
+ return false;
618
+ const operator = operatorText(node);
619
+ const operatorMatches = profile.eqOperators.some(op => operator === op)
620
+ || /^(is|is not)$/.test(normalizeText(node.text).replace(/^.*?\b(is not|is)\b.*$/, '$1'));
621
+ if (!operatorMatches && !/\bis\b/.test(node.text))
622
+ return false;
623
+ return allChildren(node).some(child => {
624
+ const text = child.text?.trim().toLowerCase();
625
+ return profile.nullLit.includes(child.type) || text === 'null' || text === 'none' || text === 'nullptr';
626
+ });
627
+ }
628
+ /** `instanceof` / `typeof` / `is` type interrogation. */
629
+ function isTypeCheck(node, profile) {
630
+ if (node.type === 'binary_expression') {
631
+ const operator = operatorText(node);
632
+ if (operator === 'instanceof')
633
+ return true;
634
+ }
635
+ if (node.type === 'unary_expression' || node.type === 'typeof_expression') {
636
+ if (operatorText(node) === 'typeof')
637
+ return true;
638
+ }
639
+ if (node.type === 'is_pattern_expression' || node.type === 'as_expression')
640
+ return true;
641
+ if (profile === PYTHON && node.type === 'call') {
642
+ const fn = node.childForFieldName?.('function');
643
+ if (fn?.text === 'isinstance' || fn?.text === 'type')
644
+ return true;
645
+ }
646
+ return false;
647
+ }
648
+ /** Case count plus the two refactoring-relevant shapes. */
649
+ function describeSwitch(node, profile) {
650
+ const discriminantNode = unwrapGrouping(node.childForFieldName?.('value')
651
+ ?? node.childForFieldName?.('condition')
652
+ ?? node.childForFieldName?.('subject')
653
+ ?? namedChildren(node)[0]);
654
+ const body = fieldOf(node, profile.bodyField) ?? node;
655
+ const cases = [];
656
+ let hasDefault = false;
657
+ function collect(current) {
658
+ for (const child of allChildren(current)) {
659
+ if (profile.switchCase.includes(child.type)) {
660
+ cases.push(child);
661
+ // A C# switch_section can carry a default label; check before recursing.
662
+ if (child.text.trimStart().startsWith('default'))
663
+ hasDefault = true;
664
+ }
665
+ else if (profile.switchDefault.includes(child.type) || child.type === 'default') {
666
+ hasDefault = true;
667
+ }
668
+ collect(child);
669
+ }
670
+ }
671
+ collect(body);
672
+ let behaviorDispatch = false;
673
+ let typeConstruction = false;
674
+ for (const caseNode of cases) {
675
+ if (profile.newExpr.length > 0 && containsType(caseNode, profile.newExpr))
676
+ typeConstruction = true;
677
+ if (containsType(caseNode, profile.call) || containsType(caseNode, ['return_statement'])) {
678
+ behaviorDispatch = true;
679
+ }
680
+ }
681
+ return {
682
+ line: node.startPosition.row + 1,
683
+ discriminant: discriminantNode ? normalizeText(discriminantNode.text) : '',
684
+ caseCount: cases.length,
685
+ hasDefault,
686
+ behaviorDispatch,
687
+ typeConstruction,
688
+ };
689
+ }
690
+ function containsType(node, types) {
691
+ if (types.includes(node.type))
692
+ return true;
693
+ return allChildren(node).some(child => containsType(child, types));
694
+ }
695
+ /**
696
+ * Numeric literals that are not 0, 1 or -1 and are not initializing a named
697
+ * constant.
698
+ *
699
+ * The const exemption is what separates "magic number" from "declared
700
+ * constant": `const RETRY_LIMIT = 5` is the fix for `if (n > 5)`, so counting
701
+ * the 5 in both would score the fixed code exactly as badly as the broken code.
702
+ */
703
+ function extractMagicNumbers(body, profile) {
704
+ const magic = [];
705
+ walkOwnBody(body, profile, node => {
706
+ if (!profile.numberLit.includes(node.type))
707
+ return;
708
+ if (isInsideNamedConstant(node))
709
+ return;
710
+ const negated = node.parent?.type === 'unary_expression' && operatorText(node.parent) === '-';
711
+ const raw = node.text.trim();
712
+ const value = negated ? `-${raw}` : raw;
713
+ if (value === '0' || value === '1' || value === '-1')
714
+ return;
715
+ magic.push({ value, line: node.startPosition.row + 1 });
716
+ });
717
+ return magic;
718
+ }
719
+ function isInsideNamedConstant(node) {
720
+ let current = node.parent;
721
+ let hops = 0;
722
+ // Bounded: an initializer sits within a few levels of its declarator, and an
723
+ // unbounded climb would walk out to the file root and exempt everything.
724
+ while (current && hops < 4) {
725
+ if (current.type === 'variable_declarator' && declarationKind(current) === 'const')
726
+ return true;
727
+ if (current.type === 'enum_member' || current.type === 'enum_member_declaration')
728
+ return true;
729
+ current = current.parent;
730
+ hops++;
731
+ }
732
+ return false;
733
+ }
734
+ /** Catch blocks with zero statements — the classic swallowed error. */
735
+ function extractEmptyCatches(body, profile) {
736
+ let empty = 0;
737
+ walkOwnBody(body, profile, node => {
738
+ if (!profile.catchClause.includes(node.type))
739
+ return;
740
+ const block = fieldOf(node, profile.bodyField)
741
+ ?? namedChildren(node).find(c => profile.blocks.includes(c.type));
742
+ if (!block)
743
+ return;
744
+ const statements = namedChildren(block).filter(c => isStatementNode(c, profile));
745
+ // `except: pass` has one statement that does nothing; treat it as empty.
746
+ const meaningful = statements.filter(s => normalizeText(s.text) !== 'pass');
747
+ if (meaningful.length === 0)
748
+ empty++;
749
+ });
750
+ return empty;
751
+ }
752
+ // ── Member and unit assembly ─────────────────────────────────────────────────
753
+ function memberKind(node, profile) {
754
+ if (profile.constructorDecl.includes(node.type))
755
+ return 'constructor';
756
+ if (profile.lambda.includes(node.type))
757
+ return 'arrow';
758
+ if (profile.method.includes(node.type)) {
759
+ const leading = allChildren(node).filter(c => !c.isNamed).map(c => c.text);
760
+ if (leading.includes('get'))
761
+ return 'getter';
762
+ if (leading.includes('set'))
763
+ return 'setter';
764
+ const name = fieldOf(node, profile.nameField)?.text;
765
+ if (name === 'constructor' || name === '__init__')
766
+ return 'constructor';
767
+ return 'method';
768
+ }
769
+ const name = fieldOf(node, profile.nameField)?.text;
770
+ if (name === '__init__')
771
+ return 'constructor';
772
+ return 'function';
773
+ }
774
+ function memberName(node, profile, fallbackIndex) {
775
+ const nameNode = fieldOf(node, profile.nameField);
776
+ if (nameNode?.text)
777
+ return normalizeText(nameNode.text);
778
+ // C-family functions carry the name inside a declarator subtree.
779
+ const declarator = node.childForFieldName?.('declarator');
780
+ if (declarator) {
781
+ const identifier = findFirstIdentifier(declarator);
782
+ if (identifier)
783
+ return identifier;
784
+ }
785
+ // An anonymous lambda still needs a stable, deterministic identity — its
786
+ // position in the file is the only thing that qualifies.
787
+ return `<anonymous:${node.startPosition.row + 1}:${fallbackIndex}>`;
788
+ }
789
+ function findFirstIdentifier(node) {
790
+ if (/identifier$/.test(node.type))
791
+ return node.text;
792
+ for (const child of namedChildren(node)) {
793
+ const found = findFirstIdentifier(child);
794
+ if (found)
795
+ return found;
796
+ }
797
+ return null;
798
+ }
799
+ function isStaticMember(node) {
800
+ return allChildren(node).some(c => !c.isNamed && c.text === 'static')
801
+ || node.text.trimStart().startsWith('static ');
802
+ }
803
+ function bodyOf(node, profile) {
804
+ return fieldOf(node, profile.bodyField)
805
+ ?? node.childForFieldName?.('expression_body')
806
+ ?? namedChildren(node).find(c => profile.blocks.includes(c.type) || c.type === 'arrow_expression_clause')
807
+ ?? null;
808
+ }
809
+ function returnTypeOf(node) {
810
+ const explicit = node.childForFieldName?.('return_type') ?? node.childForFieldName?.('type');
811
+ if (!explicit)
812
+ return null;
813
+ return normalizeText(explicit.text).replace(/^:\s*/, '');
814
+ }
815
+ function callsSuperIn(body, profile) {
816
+ let found = false;
817
+ walkOwnBody(body, profile, node => {
818
+ if (found)
819
+ return;
820
+ if (node.type === 'super' || node.type === 'base_expression')
821
+ found = true;
822
+ if (profile.call.includes(node.type)) {
823
+ const fn = node.childForFieldName?.('function');
824
+ if (fn && (fn.type === 'super' || fn.text?.startsWith('super') || fn.text?.startsWith('base.'))) {
825
+ found = true;
826
+ }
827
+ }
828
+ });
829
+ return found;
830
+ }
831
+ /**
832
+ * Extract every callable body in a file, with its owning unit.
833
+ *
834
+ * `onBody` receives each member's identity and its body node as the walk finds
835
+ * it. That callback is how the coarse CALLS surface is driven: one traversal
836
+ * decides what a member IS, and both the fact surface and the call surface
837
+ * read that same decision. The alternative — a second walk with its own idea
838
+ * of which bodies count — is exactly the arrangement that let `call` vs
839
+ * `call_expression` diverge unnoticed across two parsers.
840
+ */
841
+ export function extractMembers(rootNode, language, onBody) {
842
+ const profile = profileFor(language);
843
+ const members = [];
844
+ const units = [];
845
+ const declaredClassNames = new Set();
846
+ let anonymousCounter = 0;
847
+ // First pass: every class name declared in this file, so `new X()` can be
848
+ // told apart from `new SomethingImported()` without guessing.
849
+ (function collectClasses(node) {
850
+ if (profile.klass.includes(node.type)) {
851
+ const name = fieldOf(node, profile.nameField)?.text;
852
+ if (name)
853
+ declaredClassNames.add(name);
854
+ }
855
+ for (const child of namedChildren(node))
856
+ collectClasses(child);
857
+ })(rootNode);
858
+ const callableSet = callableTypes(profile);
859
+ function visit(node, owner, exported) {
860
+ const type = node.type;
861
+ if (type === 'export_statement') {
862
+ for (const child of namedChildren(node))
863
+ visit(child, owner, true);
864
+ return;
865
+ }
866
+ if (profile.klass.includes(type)) {
867
+ const unit = buildUnit(node, profile, exported, declaredClassNames);
868
+ units.push(unit);
869
+ const body = fieldOf(node, profile.bodyField)
870
+ ?? namedChildren(node).find(c => profile.blocks.includes(c.type));
871
+ for (const child of namedChildren(body ?? node))
872
+ visit(child, unit, exported);
873
+ return;
874
+ }
875
+ if (callableSet.has(type)) {
876
+ const member = buildMember(node, profile, owner, exported, anonymousCounter++);
877
+ members.push(member);
878
+ if (owner)
879
+ owner.memberNames.push(member.name);
880
+ const body = bodyOf(node, profile);
881
+ if (body)
882
+ onBody?.(member, body);
883
+ // Nested callables are members in their own right — recurse into the
884
+ // body so a closure gets its own facts rather than being folded in.
885
+ if (body) {
886
+ for (const child of namedChildren(body))
887
+ visit(child, null, false);
888
+ }
889
+ return;
890
+ }
891
+ for (const child of namedChildren(node))
892
+ visit(child, owner, exported);
893
+ }
894
+ for (const child of namedChildren(rootNode))
895
+ visit(child, null, false);
896
+ return { members, units };
897
+ }
898
+ function buildMember(node, profile, owner, exported, fallbackIndex) {
899
+ const body = bodyOf(node, profile);
900
+ const { declaredNames, paramCount } = extractDeclaredNames(node, body, profile);
901
+ const { statementCount, statementTexts } = extractStatements(body, profile);
902
+ const callFacts = extractCallFacts(body, profile);
903
+ const branchFacts = extractBranchFacts(body, profile);
904
+ return {
905
+ name: memberName(node, profile, fallbackIndex),
906
+ owner: owner?.name ?? null,
907
+ kind: memberKind(node, profile),
908
+ exported: exported || Boolean(owner?.exported),
909
+ isStatic: isStaticMember(node),
910
+ start_line: node.startPosition.row + 1,
911
+ end_line: node.endPosition.row + 1,
912
+ return_type: returnTypeOf(node),
913
+ paramCount,
914
+ declaredNames,
915
+ statementCount,
916
+ statementTexts,
917
+ fieldAccess: extractFieldAccess(body, profile),
918
+ calleeNames: callFacts.calleeNames,
919
+ deepChainCallCount: callFacts.deepChainCallCount,
920
+ constructorNewCallTargets: extractNewTargets(body, profile),
921
+ branchHits: branchFacts.branchHits,
922
+ switchStatements: branchFacts.switchStatements,
923
+ complexConditionals: branchFacts.complexConditionals,
924
+ nullChecks: branchFacts.nullChecks,
925
+ magicNumbers: extractMagicNumbers(body, profile),
926
+ emptyCatches: extractEmptyCatches(body, profile),
927
+ deadConditionals: branchFacts.deadConditionals,
928
+ override: {
929
+ callsSuper: callsSuperIn(body, profile),
930
+ baseClass: owner?.baseClass ?? null,
931
+ // Resolved in a later pass, once every file in the batch is parsed.
932
+ baseParamCount: null,
933
+ paramCountDrift: null,
934
+ baseReturnType: null,
935
+ returnTypeDrift: null,
936
+ },
937
+ };
938
+ }
939
+ function buildUnit(node, profile, exported, declaredClassNames) {
940
+ const name = fieldOf(node, profile.nameField)?.text ?? `<class:${node.startPosition.row + 1}>`;
941
+ const baseClass = baseClassOf(node, profile);
942
+ const staticPropertyNames = new Set();
943
+ const newTargets = [];
944
+ const externalNames = new Set();
945
+ const ownMemberNames = new Set();
946
+ // Unit-level facts read the WHOLE class subtree, nested members included —
947
+ // a dependency introduced three methods deep is still the class's dependency.
948
+ (function scan(current) {
949
+ if (profile.fieldDecl.includes(current.type) && isStaticMember(current)) {
950
+ const fieldName = fieldOf(current, profile.nameField)?.text ?? findFirstIdentifier(current);
951
+ if (fieldName)
952
+ staticPropertyNames.add(fieldName);
953
+ }
954
+ if (profile.newExpr.includes(current.type)) {
955
+ const typeNode = fieldOf(current, profile.newTypeField) ?? namedChildren(current)[0];
956
+ if (typeNode?.text)
957
+ newTargets.push(normalizeText(typeNode.text));
958
+ }
959
+ if (profile.method.includes(current.type) || profile.func.includes(current.type)) {
960
+ const memberNameText = fieldOf(current, profile.nameField)?.text;
961
+ if (memberNameText)
962
+ ownMemberNames.add(memberNameText);
963
+ }
964
+ if (profile.call.includes(current.type)) {
965
+ const fn = current.childForFieldName?.('function') ?? current.childForFieldName?.('expression');
966
+ if (fn) {
967
+ const depth = receiverDepth(fn, profile);
968
+ if (depth >= 1) {
969
+ const object = fn.childForFieldName?.(profile.memberObjectField);
970
+ const isSelf = object
971
+ && (profile.selfNodes.includes(object.type) || profile.selfWords.includes(object.text));
972
+ if (object?.text && !isSelf)
973
+ externalNames.add(normalizeText(object.text));
974
+ }
975
+ else {
976
+ const callee = calleeNameOf(fn, profile);
977
+ if (callee)
978
+ externalNames.add(callee);
979
+ }
980
+ }
981
+ }
982
+ for (const child of namedChildren(current))
983
+ scan(child);
984
+ })(node);
985
+ for (const target of newTargets)
986
+ externalNames.add(target);
987
+ // A call to the unit's own method is not an external dependency.
988
+ for (const own of ownMemberNames)
989
+ externalNames.delete(own);
990
+ const hasGetInstanceMethod = [...ownMemberNames].some(m => m === 'getInstance' || m === 'get_instance' || m === 'Instance' || m === 'instance');
991
+ return {
992
+ name,
993
+ kind: 'class',
994
+ exported,
995
+ start_line: node.startPosition.row + 1,
996
+ end_line: node.endPosition.row + 1,
997
+ baseClass,
998
+ hasBaseClass: baseClass !== null,
999
+ concreteInstantiations: newTargets.filter(t => declaredClassNames.has(t)).length,
1000
+ totalDependencies: externalNames.size,
1001
+ staticPropertyNames: [...staticPropertyNames].sort(),
1002
+ hasGetInstanceMethod,
1003
+ memberNames: [],
1004
+ };
1005
+ }
1006
+ function baseClassOf(node, profile) {
1007
+ const heritage = namedChildren(node).find(c => c.type === 'class_heritage'
1008
+ || c.type === 'base_list'
1009
+ || c.type === 'superclasses'
1010
+ || c.type === 'base_class_clause');
1011
+ if (!heritage)
1012
+ return null;
1013
+ // `implements` clauses also live under class_heritage in TS; only the
1014
+ // `extends` arm names a base class, and reporting an interface as a base
1015
+ // would make hasBaseClass true for a class that inherits no behaviour.
1016
+ const clauses = namedChildren(heritage);
1017
+ const extendsClause = clauses.find(c => c.type === 'extends_clause');
1018
+ if (!extendsClause) {
1019
+ // An implements-only heritage names interfaces, not a base class. Falling
1020
+ // through to the heritage node here would return the first interface and
1021
+ // report every interface implementer as inheriting behaviour.
1022
+ if (clauses.some(c => c.type === 'implements_clause'))
1023
+ return null;
1024
+ if (/^\s*implements\b/.test(heritage.text))
1025
+ return null;
1026
+ }
1027
+ const source = extendsClause ?? heritage;
1028
+ const candidate = namedChildren(source).find(c => /identifier$/.test(c.type) || c.type === 'member_expression' || c.type === 'generic_name');
1029
+ if (candidate?.text)
1030
+ return normalizeText(candidate.text);
1031
+ const raw = normalizeText(source.text).replace(/^(extends|:)\s*/, '');
1032
+ return raw ? raw.split(/[,\s]/)[0] : null;
1033
+ }
1034
+ // ── Batch passes: override resolution + cross-file references ───────────────
1035
+ /**
1036
+ * Fill in override drift for members whose base class was parsed in the same
1037
+ * batch. Mutates in place; members whose base is not in the batch keep their
1038
+ * `null` drift fields, which is the "not determinable" signal, not "no drift".
1039
+ */
1040
+ export function resolveOverrideShapes(perFile) {
1041
+ const byClassAndMember = new Map();
1042
+ for (const file of perFile) {
1043
+ for (const member of file.members) {
1044
+ if (member.owner)
1045
+ byClassAndMember.set(`${member.owner}#${member.name}`, member);
1046
+ }
1047
+ }
1048
+ for (const file of perFile) {
1049
+ for (const member of file.members) {
1050
+ const base = member.override.baseClass;
1051
+ if (!base)
1052
+ continue;
1053
+ const baseMember = byClassAndMember.get(`${base}#${member.name}`);
1054
+ if (!baseMember)
1055
+ continue;
1056
+ member.override.baseParamCount = baseMember.paramCount;
1057
+ member.override.paramCountDrift = member.paramCount - baseMember.paramCount;
1058
+ member.override.baseReturnType = baseMember.return_type;
1059
+ member.override.returnTypeDrift =
1060
+ member.return_type !== null && baseMember.return_type !== null
1061
+ ? member.return_type !== baseMember.return_type
1062
+ : null;
1063
+ }
1064
+ }
1065
+ }
1066
+ /**
1067
+ * Count references to each file's exported symbols across the rest of the
1068
+ * batch — the equivalent of ts-morph `findReferencesAsNodes()`, minus the
1069
+ * shared-`Project` state that made its counts drift after ~113 files.
1070
+ *
1071
+ * Identifier occurrences are collected per file ONCE, then counted. That is
1072
+ * O(files + names), not O(files x names), so a large batch does not degrade.
1073
+ */
1074
+ export function buildReferenceGraph(files) {
1075
+ const byPath = new Map();
1076
+ for (const file of files) {
1077
+ const references = [];
1078
+ for (const name of file.exportedNames) {
1079
+ let count = 0;
1080
+ const referencingFiles = [];
1081
+ for (const other of files) {
1082
+ if (other.path === file.path)
1083
+ continue;
1084
+ const hits = other.identifierCounts.get(name) ?? 0;
1085
+ if (hits > 0) {
1086
+ count += hits;
1087
+ referencingFiles.push(other.path);
1088
+ }
1089
+ }
1090
+ references.push({ name, count, files: referencingFiles.sort() });
1091
+ }
1092
+ references.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
1093
+ byPath.set(file.path, references);
1094
+ }
1095
+ return byPath;
1096
+ }
1097
+ /**
1098
+ * Every identifier occurrence in a tree, counted by name.
1099
+ *
1100
+ * Declaration-site occurrences are excluded by the caller subtracting the
1101
+ * declaring file, which is why this stays a dumb frequency map: a
1102
+ * declaration-aware version would need scope resolution the grammar does not
1103
+ * give us, and a half-correct one would be worse than an honest count.
1104
+ */
1105
+ export function countIdentifiers(rootNode) {
1106
+ const counts = new Map();
1107
+ (function walk(node) {
1108
+ if (/identifier$/.test(node.type)) {
1109
+ const name = node.text;
1110
+ counts.set(name, (counts.get(name) ?? 0) + 1);
1111
+ }
1112
+ for (const child of allChildren(node))
1113
+ walk(child);
1114
+ })(rootNode);
1115
+ return counts;
1116
+ }
1117
+ //# sourceMappingURL=memberFacts.js.map