ucn 5.2.0 → 5.2.1

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.
@@ -59,6 +59,12 @@ For caller-bearing `show`, `impact`, `trace`, `tests`, and `check` views:
59
59
  - `WARNING` identifies unreadable, unparsed, or partially indexed files, and lists unsupported-language occurrence sites (file:line plus the line text) so nothing grep would show is hidden. `usages` and `tests` carry the same disclosure as a note.
60
60
  - `FILTERED` means query options hid evidence.
61
61
 
62
+ When the selected definition is a property/getter/setter, `impact` adds a
63
+ separate `PROPERTY ACCESS SITES` band. Confirmed reads/writes have receiver
64
+ identity; matching attribute syntax with an unresolved receiver stays
65
+ unverified. These are change dependencies, not fabricated caller edges, so
66
+ the caller `ACCOUNT` remains a call-shaped partition.
67
+
62
68
  An observed-text zero is not semantic zero or safe-delete proof. Numeric evidence values are ordinal ranking weights, not probabilities.
63
69
 
64
70
  When a plain name selects more than one definition, action-oriented commands
@@ -101,7 +107,7 @@ definition as an implicit repository-wide target.
101
107
 
102
108
  ## Deletion protocol
103
109
 
104
- Treat `deadcode` as a candidate generator. Before deletion, inspect `usages`, `impact`, `entrypoints`, `api`, and `repo --sections=health --deep`; then corroborate with the compiler/type checker and tests. Computed dispatch such as `handlers[key]()` is a reported blind spot; registry members reached by a modeled computed receiver are withheld. Unknown decorators/annotations and member-assigned event handlers are also withheld by default because they can be the registration itself. All remaining candidates are still review-only. Never delete solely from `deadcode` or an observed-text-zero result.
110
+ Treat `deadcode` as a candidate generator. Before deletion, inspect `usages`, `impact`, `entrypoints`, `api`, and `repo --sections=health --deep`; then corroborate with the compiler/type checker and tests. Computed dispatch such as `handlers[key]()` is a reported blind spot; registry members reached by a modeled computed receiver are withheld. Statically named reflection such as `getattr(obj, "run")` is positive liveness evidence, so every matching member spelling is withheld; recognized dynamic reflection is counted and warned because it cannot be attributed to one member. Unknown decorators/annotations and member-assigned event handlers are also withheld by default because they can be the registration itself. All remaining candidates are still review-only. Never delete solely from `deadcode` or an observed-text-zero result.
105
111
 
106
112
  `usages` includes comment/string/docstring occurrences in an `OTHER TEXT` section unless
107
113
  `--code-only` is set. This is a literal-name inventory, not exact target
@@ -122,8 +128,16 @@ imports/exports, Python `__all__` strings, and module-attribute references.
122
128
  Exact token/expression spans keep a foreign same-named occurrence on the same
123
129
  line unchanged. Open external interfaces, incomplete ownership, unresolved
124
130
  dispatch, or an inexact token route to `needsReview` instead of a synthesized
125
- edit. Read `changeSummary` and every review item. `plan` previews only: it does
126
- not modify files, run a compiler, or prove runtime compatibility.
131
+ edit. Comment/string occurrences in indexed source appear in `reviewItems` and
132
+ are never rewritten automatically; the result also tells you to search
133
+ documentation, configuration, generated files, and unsupported languages for
134
+ the old spelling. Read `changeSummary` and every review item. `plan` previews
135
+ only: it does not modify files, run a compiler, or prove runtime compatibility.
136
+
137
+ For `deps --cycles`, `eager` means every edge executes at module scope.
138
+ `deferred` means at least one Python edge is function-local, so the chain is
139
+ not an unconditional import-time cycle; it remains visible because invoking
140
+ that function during initialization can still matter.
127
141
 
128
142
  ## Efficient use
129
143
 
@@ -22,17 +22,17 @@ structural or code-only search.
22
22
 
23
23
  | Command | Purpose |
24
24
  |---|---|
25
- | `impact [handle]` | Show direct symbol impact when given a handle; without one, analyze the Git diff. |
25
+ | `impact [handle]` | Show direct symbol impact when given a handle; accessor targets include receiver-tiered property reads/writes as a separate dependency band. Without a handle, analyze the Git diff. |
26
26
  | `tests <handle\|file>` | Find statically linked direct tests. Set `--depth=N` for transitive affected tests. Empty results are not runtime coverage proof. |
27
27
  | `check [handle]` | Validate confirmed call-site arity for a symbol; without one, run the composed pre-commit diagnostic. |
28
- | `plan <handle>` | Preview `--rename-to`, `--add-param`, or `--remove-param` edits. For renames, the proven closure can include overload/signature groups, inheritance/trait/Go-interface slots, exact call/reference tokens, imports/exports, Python `__all__` strings, and module-attribute references. Open ownership or method sets stay `needsReview`; the command never applies or compiles edits. |
28
+ | `plan <handle>` | Preview `--rename-to`, `--add-param`, or `--remove-param` edits. For renames, the proven closure can include overload/signature groups, inheritance/trait/Go-interface slots, accessor reads/writes, exact call/reference tokens, imports/exports, Python `__all__` strings, and module-attribute references. Source comments/strings are explicit `reviewItems`; non-source files get a search handoff. Open ownership or method sets stay `needsReview`; the command never applies or compiles edits. |
29
29
 
30
30
  ## Repository and architecture
31
31
 
32
32
  | Command | Purpose |
33
33
  |---|---|
34
34
  | `repo` | Repository orientation. Select `summary,files,stats,health` with `--sections`; `--deep` includes readiness evidence. Skipped unsupported source is listed with a grep/language-tool handoff. |
35
- | `deps <file>` | File dependency graph. Use `--direction=imports\|importers\|both`, `--detailed`, or `--cycles`. |
35
+ | `deps <file>` | File dependency graph. Use `--direction=imports\|importers\|both`, `--detailed`, or `--cycles`. Python cycles are classified as eager or function-local/deferred without dropping either kind. |
36
36
  | `api [file]` | Static exported/public surface for a project or file. |
37
37
  | `entrypoints` | Framework, route, task, test, and runtime entry points. |
38
38
  | `endpoints` | Server/client HTTP surface; `--bridge` adds advisory matching. |
@@ -41,7 +41,7 @@ structural or code-only search.
41
41
 
42
42
  | Command | Purpose |
43
43
  |---|---|
44
- | `deadcode` | Conservative unreferenced-symbol candidates for review. Modeled computed-dispatch members, unknown decorated/annotated callables, and member-assigned event handlers are withheld by default. |
44
+ | `deadcode` | Conservative unreferenced-symbol candidates for review. Statically named reflection targets, modeled computed-dispatch members, unknown decorated/annotated callables, and member-assigned event handlers are withheld by default. Recognized dynamic reflection is counted and warned because it cannot be attributed. |
45
45
  | `audit-async` | Potential missing-await sites in JavaScript/TypeScript/Python and C#. MCP spelling: `audit_async`. |
46
46
  | `stacktrace <text>` | Advisory stack-frame parsing and source lookup. |
47
47
 
package/README.md CHANGED
@@ -287,7 +287,7 @@ SIGNATURE CHANGE:
287
287
 
288
288
  CHANGES NEEDED: 12
289
289
  Files affected: 5
290
- Definition 1, calls/references 7, imports 4, exports 0; manual review required for 0 of these changes
290
+ Definition 1, calls 7, references 0, text dependencies 0, imports 4, exports 0; manual review items 0
291
291
 
292
292
  BY FILE:
293
293
 
@@ -306,12 +306,16 @@ For a rename, `plan` closes the change over every relationship the index can
306
306
  prove: overload/signature groups, base and override declarations, Rust trait
307
307
  slots, Go interface slots and their satisfiers, exact call and value-reference
308
308
  tokens, imports/exports, Python `__all__` strings, and module-attribute
309
- references. It edits exact token or expression spans, so another same-named
310
- call on the same line is not swept up accidentally.
309
+ references. Accessor renames also follow receiver-proven property reads and
310
+ writes. It edits exact token or expression spans, so another same-named call
311
+ on the same line is not swept up accidentally.
311
312
 
312
313
  Open external interfaces, incomplete ownership, unresolved dispatch, or an
313
314
  inexact token are marked `needsReview` instead of receiving a synthesized
314
- edit. `plan` previews changes; it does not modify files or replace the
315
+ edit. Comments and strings in indexed source appear as separate review items
316
+ and are never rewritten automatically; documentation, configuration,
317
+ generated files, and unsupported languages get an explicit exact-text search
318
+ handoff. `plan` previews changes; it does not modify files or replace the
315
319
  compiler and test suite. Before committing, point the same machinery at your
316
320
  Git diff:
317
321
 
@@ -408,7 +412,10 @@ Three claims, and every one is re-checked against rust-analyzer in CI: a
408
412
  default-audit claim with an oracle-visible reference fails the build. Notice
409
413
  what it *didn't* claim: exported API that external code may call,
410
414
  framework-registered symbols, and anything whose name appears in files UCN
411
- couldn't parse. `deadcode` is deliberately a candidate generator. Before
415
+ couldn't parse. A literal reflection target such as `getattr(obj, "run")`
416
+ also withholds matching member names from deletion candidates; recognized
417
+ dynamic reflection is counted and warned because it cannot be attributed.
418
+ `deadcode` is deliberately a candidate generator. Before
412
419
  deleting, corroborate with `usages`, `impact`, `api`, and your compiler and
413
420
  tests.
414
421
 
@@ -426,6 +433,11 @@ ucn entrypoints --type=http # runtime and framework roots
426
433
  ucn endpoints --bridge --unmatched # server routes with no client, and vice versa
427
434
  ```
428
435
 
436
+ Cycle output separates eager import-time loops from Python chains containing
437
+ a function-local/deferred edge. Deferred chains stay visible—they are not
438
+ unconditional import-time cycles, but can still fail if invoked while modules
439
+ are initializing.
440
+
429
441
  `endpoints --bridge` matches server routes to client requests across
430
442
  languages: Express/Fastify/Koa/NestJS/Next.js, Flask/FastAPI, Spring/JAX-RS,
431
443
  Go net/http (Gin/Echo/Chi/Fiber), axum/actix-web, and ASP.NET on the server
@@ -0,0 +1,183 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const { codeUnitCompare } = require('./shared');
5
+
6
+ // Descriptors/properties are consumed through reads and writes, not only
7
+ // call syntax. Keep this vocabulary shared by impact and refactoring so the
8
+ // two commands cannot disagree about whether a selected symbol is an accessor.
9
+ const ACCESSOR_KINDS = new Set([
10
+ 'property', 'setter', 'deleter', 'get', 'set',
11
+ 'static get', 'static set', 'override get', 'override set',
12
+ 'static override get', 'static override set',
13
+ ]);
14
+
15
+ function isAccessorDefinition(definition) {
16
+ return !!definition && (ACCESSOR_KINDS.has(definition.type) ||
17
+ ACCESSOR_KINDS.has(definition.memberType));
18
+ }
19
+
20
+ function ownerName(definition) {
21
+ if (definition.className) return definition.className;
22
+ if (!definition.receiver) return null;
23
+ return String(definition.receiver)
24
+ .replace(/^[*&]\s*/, '')
25
+ .replace(/^mut\s+/, '')
26
+ .replace(/<.*$/, '')
27
+ .trim() || null;
28
+ }
29
+
30
+ function bareTypeName(value) {
31
+ if (!value) return null;
32
+ return String(value)
33
+ .replace(/^(?:typing\.)?(?:Optional|Annotated|Final)\s*\[/, '')
34
+ .replace(/[<[(].*$/s, '')
35
+ .split(/\.|::/).pop()
36
+ .replace(/[?*&\s]/g, '') || null;
37
+ }
38
+
39
+ function typeMatchesOwner(index, typeName, contextFile, owner, ownerFile) {
40
+ const bare = bareTypeName(typeName);
41
+ if (!bare || !owner || bare !== owner) return false;
42
+ const resolved = index._resolveClassFile?.(bare, contextFile);
43
+ if (resolved) return path.resolve(resolved) === path.resolve(ownerFile);
44
+ const ownerDefs = (index.symbols.get(owner) || []).filter(symbol =>
45
+ ['class', 'struct', 'interface', 'trait', 'record'].includes(symbol.type));
46
+ return ownerDefs.length === 1 &&
47
+ path.resolve(ownerDefs[0].file) === path.resolve(ownerFile);
48
+ }
49
+
50
+ function insideSelectedAccessorBody(index, name, definition, file, line) {
51
+ return (index.symbols.get(name) || []).some(candidate =>
52
+ isAccessorDefinition(candidate) &&
53
+ candidate.file === definition.file &&
54
+ candidate.className === definition.className &&
55
+ file === candidate.file &&
56
+ line >= candidate.startLine &&
57
+ line <= (candidate.endLine || candidate.startLine));
58
+ }
59
+
60
+ /**
61
+ * Find reads/writes that may consume a selected accessor. Confirmed entries
62
+ * require receiver identity (`self`/`this` in the owner or a Python instance
63
+ * field whose constructor assignment proves the owner type). Everything else
64
+ * remains visible in the unverified tier; accessor identity is never guessed
65
+ * from the spelling alone.
66
+ */
67
+ function findAccessorReferences(index, name, definition, options = {}) {
68
+ if (!isAccessorDefinition(definition)) return null;
69
+ const owner = ownerName(definition);
70
+ if (!owner) return null;
71
+
72
+ const confirmed = [];
73
+ const unverified = [];
74
+ const excluded = [];
75
+ // Use the parser's raw occurrence records rather than usages()' public
76
+ // line-oriented inventory. A line can contain two same-spelled member
77
+ // accesses with different receivers; collapsing them by file+line would
78
+ // make a rename edit one token while silently losing the other.
79
+ const refs = [];
80
+ for (const [file, entry] of index.files) {
81
+ if (!index.matchesFilters(entry.relativePath, options)) continue;
82
+ let content;
83
+ let occurrences;
84
+ try {
85
+ content = index._readFile(file);
86
+ if (!content.includes(name)) continue;
87
+ occurrences = index._getCachedUsages(file, name);
88
+ } catch { continue; }
89
+ if (!occurrences) continue;
90
+ const lines = content.split('\n');
91
+ for (const usage of occurrences) {
92
+ if (usage.usageType !== 'reference') continue;
93
+ refs.push({
94
+ ...usage,
95
+ file,
96
+ relativePath: entry.relativePath,
97
+ content: lines[usage.line - 1] || '',
98
+ });
99
+ }
100
+ }
101
+
102
+ for (const ref of refs) {
103
+ const rel = ref.relativePath || path.relative(index.root, ref.file);
104
+ const shaped = {
105
+ file: rel,
106
+ absoluteFile: ref.file,
107
+ line: ref.line,
108
+ expression: (ref.content || '').trim(),
109
+ ...(Number.isInteger(ref.column) && { column: ref.column }),
110
+ ...(ref.receiver && { receiver: ref.receiver }),
111
+ };
112
+
113
+ // A backing-store attribute inside the selected getter/setter body
114
+ // often has the same spelling (`self._local.value`). It is not a
115
+ // consumption of the descriptor being queried.
116
+ if (insideSelectedAccessorBody(index, name, definition, ref.file, ref.line) &&
117
+ !['self', 'cls', 'this'].includes(ref.receiver)) {
118
+ excluded.push({ ...shaped, reason: 'accessor-definition-body' });
119
+ continue;
120
+ }
121
+
122
+ const enclosing = index.findEnclosingFunction(ref.file, ref.line, true);
123
+ const receiver = ref.receiver || null;
124
+ if (receiver && ['self', 'cls', 'this'].includes(receiver) &&
125
+ enclosing?.className === owner && enclosing.file === definition.file) {
126
+ confirmed.push({
127
+ ...shaped,
128
+ callerName: enclosing.name,
129
+ resolution: 'same-class-accessor',
130
+ tier: 'confirmed',
131
+ });
132
+ continue;
133
+ }
134
+
135
+ let receiverType = ref.receiverType || null;
136
+ if (!receiverType && receiver && enclosing?.className) {
137
+ receiverType = index.getInstanceAttributeTypes(
138
+ ref.file, enclosing.className)?.get(receiver) || null;
139
+ }
140
+ if (receiverType && typeMatchesOwner(
141
+ index, receiverType, ref.file, owner, definition.file)) {
142
+ confirmed.push({
143
+ ...shaped,
144
+ callerName: enclosing?.name || null,
145
+ receiverType,
146
+ resolution: 'receiver-field-type',
147
+ tier: 'confirmed',
148
+ });
149
+ continue;
150
+ }
151
+ if (receiver && typeMatchesOwner(index, receiver, ref.file, owner, definition.file)) {
152
+ confirmed.push({
153
+ ...shaped,
154
+ callerName: enclosing?.name || null,
155
+ receiverType: receiver,
156
+ resolution: 'type-qualified-accessor',
157
+ tier: 'confirmed',
158
+ });
159
+ continue;
160
+ }
161
+
162
+ unverified.push({
163
+ ...shaped,
164
+ callerName: enclosing?.name || null,
165
+ ...(receiverType && { receiverType }),
166
+ reason: receiverType ? 'receiver-type-mismatch' :
167
+ receiver ? 'receiver-type-unresolved' : 'nested-receiver-unresolved',
168
+ tier: 'unverified',
169
+ });
170
+ }
171
+
172
+ const sort = (a, b) => codeUnitCompare(a.file, b.file) || a.line - b.line;
173
+ confirmed.sort(sort);
174
+ unverified.sort(sort);
175
+ excluded.sort(sort);
176
+ return { owner, confirmed, unverified, excluded };
177
+ }
178
+
179
+ module.exports = {
180
+ ACCESSOR_KINDS,
181
+ isAccessorDefinition,
182
+ findAccessorReferences,
183
+ };
package/core/analysis.js CHANGED
@@ -18,6 +18,7 @@ const { isTestFile } = require('./discovery');
18
18
  const { computeReachability, symbolKey } = require('./entrypoints');
19
19
  const { getLanguageAdapter } = require('../languages');
20
20
  const { projectComputedDispatch } = require('./ast-analysis');
21
+ const { findAccessorReferences } = require('./accessors');
21
22
 
22
23
  // JS/TS test framework helpers — calls to these bracket a test case.
23
24
  // Used to flag call sites whose enclosing function is an arrow callback
@@ -1235,6 +1236,40 @@ function impact(index, name, options = {}) {
1235
1236
  });
1236
1237
  }
1237
1238
 
1239
+ // Accessors are consumed through reads/writes. They are intentionally a
1240
+ // separate dependency band rather than fake caller edges: the caller
1241
+ // oracle and conservation account remain call-shaped, while impact no
1242
+ // longer hides the normal use form of a property/getter/setter.
1243
+ let propertyAccesses = findAccessorReferences(index, name, def, {
1244
+ includeTests: true,
1245
+ exclude: options.exclude,
1246
+ });
1247
+ if (propertyAccesses) {
1248
+ const accessByFile = new Map();
1249
+ for (const site of propertyAccesses.confirmed) {
1250
+ if (!accessByFile.has(site.file)) accessByFile.set(site.file, []);
1251
+ accessByFile.get(site.file).push(site);
1252
+ }
1253
+ propertyAccesses = {
1254
+ owner: propertyAccesses.owner,
1255
+ confirmedCount: propertyAccesses.confirmed.length,
1256
+ unverifiedCount: propertyAccesses.unverified.length,
1257
+ totalCandidates: propertyAccesses.confirmed.length +
1258
+ propertyAccesses.unverified.length,
1259
+ byFile: [...accessByFile.entries()]
1260
+ .sort((a, b) => codeUnitCompare(a[0], b[0]))
1261
+ .map(([file, sites]) => ({ file, count: sites.length, sites })),
1262
+ unverifiedSites: propertyAccesses.unverified,
1263
+ excluded: {
1264
+ total: propertyAccesses.excluded.length,
1265
+ byReason: propertyAccesses.excluded.reduce((out, site) => {
1266
+ out[site.reason] = (out[site.reason] || 0) + 1;
1267
+ return out;
1268
+ }, {}),
1269
+ },
1270
+ };
1271
+ }
1272
+
1238
1273
  // Apply top limit if specified (limits total call sites shown)
1239
1274
  const totalBeforeLimit = filteredSites.length;
1240
1275
  if (options.top && options.top > 0 && filteredSites.length > options.top) {
@@ -1276,6 +1311,12 @@ function impact(index, name, options = {}) {
1276
1311
  }
1277
1312
  }
1278
1313
 
1314
+ const affectedFiles = new Set([
1315
+ ...Array.from(byFile.keys()),
1316
+ ...(propertyAccesses?.byFile || []).map(group => group.file),
1317
+ ...(propertyAccesses?.unverifiedSites || []).map(site => site.file),
1318
+ ]);
1319
+
1279
1320
  return {
1280
1321
  function: name,
1281
1322
  file: def.relativePath,
@@ -1286,6 +1327,11 @@ function impact(index, name, options = {}) {
1286
1327
  totalCallSites: totalBeforeLimit,
1287
1328
  shownCallSites: filteredSites.length,
1288
1329
  unverifiedSites,
1330
+ ...(propertyAccesses && {
1331
+ propertyAccesses,
1332
+ totalDependencySites: totalBeforeLimit + propertyAccesses.confirmedCount,
1333
+ affectedFiles: affectedFiles.size,
1334
+ }),
1289
1335
  account: impactAccount,
1290
1336
  hasEntrypoints: !!impactReachable && impactReachable.size > 0,
1291
1337
  callerHistogram,
@@ -181,6 +181,108 @@ function computedReceiver(callee) {
181
181
  return node?.type === 'identifier' ? node.text : null;
182
182
  }
183
183
 
184
+ const STRING_LITERAL_NODES = new Set([
185
+ 'string', 'string_literal', 'raw_string_literal',
186
+ 'interpreted_string_literal', 'verbatim_string_literal',
187
+ ]);
188
+
189
+ function literalStringValue(node) {
190
+ if (!node || !STRING_LITERAL_NODES.has(node.type)) return null;
191
+ const content = (node.namedChildren || []).find(child =>
192
+ child.type === 'string_content' || child.type === 'interpreted_string_literal_content');
193
+ if (content) return content.text;
194
+ const raw = String(node.text || '');
195
+ const first = raw.search(/["']/);
196
+ if (first < 0) return null;
197
+ const quote = raw[first];
198
+ const triple = raw.slice(first, first + 3) === quote.repeat(3);
199
+ const width = triple ? 3 : 1;
200
+ if (!raw.endsWith(quote.repeat(width))) return null;
201
+ const value = raw.slice(first + width, -width);
202
+ // Escaped/interpolated member names are not a stable static spelling.
203
+ if (value.includes('\\') || value.includes('{') || value.includes('$')) return null;
204
+ return value;
205
+ }
206
+
207
+ function callShape(node) {
208
+ if (!['call', 'call_expression', 'invocation_expression',
209
+ 'method_invocation'].includes(node.type)) return null;
210
+ const callee = node.childForFieldName('function') ||
211
+ node.childForFieldName('name') || node.namedChild(0);
212
+ const args = node.childForFieldName('arguments') ||
213
+ (node.namedChildren || []).find(child =>
214
+ ['argument_list', 'arguments', 'bracketed_argument_list'].includes(child.type));
215
+ return callee && args ? { callee, args: args.namedChildren || [] } : null;
216
+ }
217
+
218
+ /**
219
+ * Extract recognized reflection operations and classify whether their member
220
+ * target is a stable literal. Literal targets are positive liveness evidence;
221
+ * dynamic targets cannot identify one member but must still be disclosed by
222
+ * deletion-oriented commands.
223
+ */
224
+ function reflectionSites(content, language) {
225
+ try {
226
+ const syntaxHints = {
227
+ python: ['getattr', 'setattr', 'hasattr', 'delattr'],
228
+ javascript: ['Reflect.'],
229
+ typescript: ['Reflect.'],
230
+ tsx: ['Reflect.'],
231
+ go: ['MethodByName', 'FieldByName'],
232
+ java: ['getMethod', 'getDeclaredMethod', 'getField', 'getDeclaredField'],
233
+ csharp: ['GetMethod', 'GetProperty', 'GetField'],
234
+ }[language];
235
+ if (!syntaxHints || !syntaxHints.some(hint => content.includes(hint))) {
236
+ return [];
237
+ }
238
+ const parser = getParser(language);
239
+ if (!parser) return [];
240
+ const tree = safeParse(parser, content);
241
+ const sites = [];
242
+ walkNamed(tree.rootNode, node => {
243
+ const call = callShape(node);
244
+ if (!call) return true;
245
+ const callee = String(call.callee.text || '');
246
+ let argIndex = null;
247
+ let kind = null;
248
+ if (language === 'python' &&
249
+ ['getattr', 'setattr', 'hasattr', 'delattr'].includes(callee)) {
250
+ argIndex = 1;
251
+ kind = callee;
252
+ } else if (['javascript', 'typescript', 'tsx'].includes(language) &&
253
+ /^(?:globalThis\.)?Reflect\.(?:get|set|has|deleteProperty)$/.test(callee)) {
254
+ argIndex = 1;
255
+ kind = callee.split('.').pop();
256
+ } else if (language === 'go' &&
257
+ /\.(?:MethodByName|FieldByName)$/.test(callee)) {
258
+ argIndex = 0;
259
+ kind = callee.split('.').pop();
260
+ } else if (['java', 'csharp'].includes(language) &&
261
+ /(?:^|\.)(?:getMethod|getDeclaredMethod|getField|getDeclaredField|GetMethod|GetProperty|GetField)$/.test(callee)) {
262
+ argIndex = 0;
263
+ kind = callee.split('.').pop();
264
+ }
265
+ if (argIndex == null || !call.args[argIndex]) return true;
266
+ const name = literalStringValue(call.args[argIndex]);
267
+ sites.push({
268
+ ...(name && /^[A-Za-z_$][\w$]*$/.test(name) && { name }),
269
+ kind,
270
+ line: node.startPosition.row + 1,
271
+ expression: node.text,
272
+ dynamic: !name || !/^[A-Za-z_$][\w$]*$/.test(name),
273
+ });
274
+ return true;
275
+ });
276
+ return sites;
277
+ } catch (_) {
278
+ return [];
279
+ }
280
+ }
281
+
282
+ function literalReflectionSites(content, language) {
283
+ return reflectionSites(content, language).filter(site => !site.dynamic);
284
+ }
285
+
184
286
  /**
185
287
  * Find direct computed dispatch calls such as handlers[name](). Literal keys
186
288
  * are excluded because they retain a statically visible member name.
@@ -276,4 +378,6 @@ module.exports = {
276
378
  computeAstComplexity,
277
379
  computedDispatchSites,
278
380
  projectComputedDispatch,
381
+ reflectionSites,
382
+ literalReflectionSites,
279
383
  };
package/core/cache.js CHANGED
@@ -644,7 +644,11 @@ function clearAllCaches() {
644
644
  // symbols retain AST-derived parameter qualification/forwarding effects, so
645
645
  // replacement-list requalification cannot masquerade as lexical calls
646
646
  // (fix #306).
647
- const CACHE_FORMAT_VERSION = 188;
647
+ // v189: Python import records retain function-local/deferred scope so cycle
648
+ // reporting can classify import-time vs lazy edges from fresh and cached
649
+ // indexes; C# properties retain property identity instead of masquerading as
650
+ // ordinary fields for accessor impact/refactoring.
651
+ const CACHE_FORMAT_VERSION = 189;
648
652
 
649
653
  /**
650
654
  * Save index to cache file
package/core/callers.js CHANGED
@@ -10708,7 +10708,7 @@ function _declaredFieldType(index, rootType, fieldName, language, info, rootName
10708
10708
  d.type === 'property' || d.memberType === 'property';
10709
10709
  const fields = defs.filter(d =>
10710
10710
  ((d.type === 'field' || d.memberType === 'field' || d.memberType === 'private field') && d.fieldType) ||
10711
- (isAccessor(d) && d.returnType));
10711
+ (isAccessor(d) && (d.returnType || d.fieldType)));
10712
10712
  let onType = fields.filter(d => d.className === rootType &&
10713
10713
  (language !== 'csharp' || !rootNamespace ||
10714
10714
  (d.namespace || null) === rootNamespace));
@@ -10767,7 +10767,7 @@ function _declaredFieldType(index, rootType, fieldName, language, info, rootName
10767
10767
  if (onType.length === 0) return null;
10768
10768
  const normalized = new Set();
10769
10769
  for (const f of onType) {
10770
- const rawText = isAccessor(f) ? f.returnType : f.fieldType;
10770
+ const rawText = isAccessor(f) ? (f.returnType || f.fieldType) : f.fieldType;
10771
10771
  // Qualified declared types resolve through the FIELD-DECLARING file's
10772
10772
  // imports or not at all (fix #268, chi-measured — the #206 identity
10773
10773
  // discipline): `inner http.Handler` is net/http's Handler, never a
@@ -10824,7 +10824,8 @@ function _declaredFieldType(index, rootType, fieldName, language, info, rootName
10824
10824
  complete = false;
10825
10825
  break;
10826
10826
  }
10827
- const rawText = isAccessor(field) ? field.returnType : field.fieldType;
10827
+ const rawText = isAccessor(field)
10828
+ ? (field.returnType || field.fieldType) : field.fieldType;
10828
10829
  const qualifier = language === 'java'
10829
10830
  ? _javaNestedTypeQualifier(rawText) : undefined;
10830
10831
  if (qualifier) namespaces.add(qualifier);
@@ -10983,10 +10984,14 @@ function _nonCallableFieldMember(index, typeName, name, language) {
10983
10984
  (d.receiver && d.receiver.replace(/^\*/, '') === typeName));
10984
10985
  if (onType.length === 0) return false;
10985
10986
  for (const d of onType) {
10986
- if (d.type !== 'field' && d.memberType !== 'field' && d.memberType !== 'private field') return false;
10987
- if (!d.fieldType) return false;
10987
+ const valueMember = d.type === 'field' || d.memberType === 'field' ||
10988
+ d.memberType === 'private field' || d.type === 'property' ||
10989
+ d.memberType === 'property';
10990
+ if (!valueMember) return false;
10991
+ const declaredType = d.fieldType || d.returnType;
10992
+ if (!declaredType) return false;
10988
10993
  if (_callableFieldDef(index, d)) return false;
10989
- const raw = String(d.fieldType).trim();
10994
+ const raw = String(declaredType).trim();
10990
10995
  if (/^func\b/.test(raw)) return false;
10991
10996
  if (/\bfn\s*\(|\b(?:Fn|FnMut|FnOnce)\s*[(<]/.test(raw)) return false;
10992
10997
  if (langTraits(language)?.typeSystem === 'structural') {
@@ -13455,7 +13460,8 @@ function _declaredFieldInterfaceType(index, rootType, fieldName, language, rootN
13455
13460
  const defs = index.symbols.get(fieldName);
13456
13461
  if (!defs) return null;
13457
13462
  const fields = defs.filter(d =>
13458
- (d.type === 'field' || d.memberType === 'field') &&
13463
+ (d.type === 'field' || d.memberType === 'field' ||
13464
+ d.type === 'property' || d.memberType === 'property') &&
13459
13465
  d.className === rootType && d.fieldType &&
13460
13466
  (language !== 'csharp' || !rootNamespace ||
13461
13467
  (d.namespace || null) === rootNamespace));