@lifeaitools/rdc-skills 0.34.0 → 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.
- package/.claude-plugin/plugin.json +284 -1
- package/VALIDATOR-ARCHITECTURE.md +534 -0
- package/commands/analyze-tests.md +11 -0
- package/commands/check-clean-code.md +11 -0
- package/commands/check-packages.md +10 -0
- package/commands/compare-compliance.md +14 -0
- package/commands/full-analysis.md +50 -0
- package/commands/get-refactoring-plan.md +13 -0
- package/commands/quick-check.md +13 -0
- package/commands/recover.md +149 -0
- package/commands/review-arch.md +12 -0
- package/commands/review.md +12 -113
- package/commands/suggest-patterns.md +11 -0
- package/commands/validate-solid.md +11 -0
- package/package.json +14 -2
- package/scripts/architecture-score.mjs +157 -0
- package/scripts/clean-code-score.mjs +177 -0
- package/scripts/duplication-score.mjs +66 -0
- package/scripts/lib/architecture-scoring.mjs +695 -0
- package/scripts/lib/clean-code-scoring.mjs +258 -0
- package/scripts/lib/duplication-scoring.mjs +238 -0
- package/scripts/lib/language-plugin.mjs +82 -0
- package/scripts/lib/package-metrics.mjs +439 -0
- package/scripts/lib/pattern-scoring.mjs +351 -0
- package/scripts/lib/plugins/treesitter.mjs +1182 -0
- package/scripts/lib/plugins/typescript.mjs +672 -0
- package/scripts/lib/refactoring-scoring.mjs +307 -0
- package/scripts/lib/solid-scoring.mjs +101 -0
- package/scripts/lib/test-smell-scoring.mjs +581 -0
- package/scripts/lib/vendor/codeflow-parser/.source-commit +1 -0
- package/scripts/lib/vendor/codeflow-parser/grammars.d.ts +23 -0
- package/scripts/lib/vendor/codeflow-parser/grammars.js +57 -0
- package/scripts/lib/vendor/codeflow-parser/memberFacts.d.ts +274 -0
- package/scripts/lib/vendor/codeflow-parser/memberFacts.js +1117 -0
- package/scripts/lib/vendor/codeflow-parser/nativeParser.d.ts +115 -0
- package/scripts/lib/vendor/codeflow-parser/nativeParser.js +759 -0
- package/scripts/lib/vendor/codeflow-parser/package.json +3 -0
- package/scripts/lib/vendor/codeflow-parser/xmlParser.d.ts +77 -0
- package/scripts/lib/vendor/codeflow-parser/xmlParser.js +400 -0
- package/scripts/package-metrics-cli.mjs +112 -0
- package/scripts/pattern-score.mjs +143 -0
- package/scripts/refactoring-score.mjs +253 -0
- package/scripts/solid-score.mjs +337 -0
- package/skills/architecture-reviewer/SKILL.md +287 -0
- package/skills/clean-code-analyzer/SKILL.md +147 -0
- package/skills/package-design/SKILL.md +118 -0
- package/skills/pattern-advisor/SKILL.md +237 -0
- package/skills/pattern-refactoring-guide/SKILL.md +262 -0
- package/skills/review/SKILL.md +29 -0
- package/skills/solid-validator/SKILL.md +92 -0
- package/skills/testing-strategy/SKILL.md +132 -0
- package/tests/lib/architecture-scoring.test.mjs +335 -0
- package/tests/lib/clean-code-scoring.test.mjs +241 -0
- package/tests/lib/duplication-scoring.test.mjs +144 -0
- package/tests/lib/fixtures.mjs +58 -0
- package/tests/lib/package-metrics.test.mjs +241 -0
- package/tests/lib/pattern-scoring.test.mjs +251 -0
- package/tests/lib/refactoring-scoring.test.mjs +264 -0
- package/tests/lib/solid-scoring.test.mjs +291 -0
- 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
|