ucn 5.3.8 → 5.4.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.
@@ -73,7 +73,9 @@ When the selected definition is a property/getter/setter, `impact` adds a
73
73
  separate `PROPERTY ACCESS SITES` band. Confirmed reads/writes have receiver
74
74
  identity; matching attribute syntax with an unresolved receiver stays
75
75
  unverified. These are change dependencies, not fabricated caller edges, so
76
- the caller `ACCOUNT` remains a call-shaped partition.
76
+ the caller `ACCOUNT` remains a call-shaped partition. Reads and writes include
77
+ access kind and token column; getter/setter handles describe the same property.
78
+ Unambiguous inherited properties share that owner evidence.
77
79
 
78
80
  When the selected definition is a type, interface, enum, trait, or record,
79
81
  `impact` adds a `TYPE REFERENCE SITES` band: annotation and reference sites
@@ -85,6 +87,10 @@ Target-less `impact` and `check` diff the working tree against `HEAD` AND
85
87
  include untracked, non-ignored source files as whole-file additions, so new
86
88
  modules are checked before `git add`. `--staged` keeps its index-only meaning.
87
89
  The changed-path note also counts untracked documentation and configuration.
90
+ Classes, interfaces, types, and fields appear in separate declaration bands
91
+ with their dependency sites. Modified/deleted declarations block `check` until
92
+ reviewed with the language toolchain: arity checks cannot validate shape or
93
+ inheritance compatibility.
88
94
 
89
95
  An observed-text zero is not semantic zero or safe-delete proof. Numeric evidence values are ordinal ranking weights, not probabilities.
90
96
 
@@ -136,15 +142,18 @@ ucn source src/server.js:40-80 --raw
136
142
  Records go to stdout; the `ACCOUNT` / `CONTRACT` lines, notes, and the
137
143
  same-name disambiguation go to stderr prefixed `# ` (MCP keeps them in the one
138
144
  text block, and `--raw` appends its note as one trailing `# ` line there).
139
- `usages` emits one record per occurrence, so a source line may repeat; deduplicate
140
- `path:line` values for line counts. Definition handles start at decorators when
145
+ `usages --lines` emits one record per source line, combining occurrence kinds
146
+ and disclosing repeated occurrences (including JSX open/close tags). JSON keeps
147
+ the individual occurrence records. Definition handles start at decorators when
141
148
  present, while usages point at token lines (`nameLine` identifies the declaration
142
149
  token when it differs from `startLine`). Structural `search --unused` keeps its
143
150
  safety note and decorator tags in shell output; runtime registrations can appear
144
151
  and zero call edges do not prove a symbol is safe to delete.
145
152
  `--lines` lists the whole band without default row/character caps, so pipe through
146
- `grep -v '# unverified'` for the confirmed tier or `cut -d: -f1 | sort | uniq -c`
147
- for callers per file. Nothing to list prints nothing and exits 1, grep's
153
+ `grep -v '# unverified'` for the confirmed tier. To count distinct source lines
154
+ per file, use `cut -d: -f1,2 | sort -u | cut -d: -f1 | sort | uniq -c`;
155
+ JSON usage records can count a source line more than once.
156
+ Nothing to list prints nothing and exits 1, grep's
148
157
  contract; errors exit 2. Explicit `--top`/`--limit` still apply and disclose
149
158
  omissions. `show --lines` accepts only callers/callees sections; target-less
150
159
  `impact --lines` lists Git-diff callers with per-target accounting. Closing a
@@ -189,6 +198,9 @@ target token); Java `pkg.Type.method()` and C# `Ns.Type.Method()` /
189
198
  same-name types exist. A qualifier the resolver cannot place stays visible as
190
199
  `method-ambiguous`, never confirmed by first-definition order.
191
200
 
201
+ `endpoints` labels test routes and requests with `[test]` (JSON `isTest`).
202
+ Use `--exclude-tests` for a production inventory or `--in=api/` for a directory.
203
+
192
204
  `endpoints` recognizes client receivers by evidence (a receiver typed to an
193
205
  HTTP client class, or bound to a pytest fixture that constructs one), not only
194
206
  by name. Request-shaped calls with a path literal on an unrecognized receiver
@@ -208,6 +220,30 @@ The selection note discloses that approximation. `find`, text `search`,
208
220
  (structural `search`: 50). Use an explicit `--limit=N` to request more;
209
221
  `usages` and `--lines` have no default row cap.
210
222
 
223
+ Automatic test filtering follows the language's conventions: Python `spec.py`
224
+ and `chart_spec.py` are production paths; `test_*.py`, `*_test.py`, and test
225
+ directories remain test paths. Structural search discloses hidden test files,
226
+ including empty results; `--include-tests` gives the full indexed inventory.
227
+ Explicit `--exclude=spec` still means the requested path exclusion.
228
+
229
+ Public JSON source `file` fields are relative to `meta.pathBase` (the absolute
230
+ project root). Dependency edge paths use the same base. Both absolute and
231
+ relative indexed-file handles are accepted. Definition handles retain their
232
+ decorator span; use `nameLine` for the name token rather than joining usages
233
+ to a handle's start line.
234
+
235
+ Callable references passed to another function remain visible when a project
236
+ method or function could be their target. An ordinary attribute read with no
237
+ callable member candidate stays a non-call reference in ACCOUNT and `usages`.
238
+ The caller model includes callback dependencies; it does not prove that the
239
+ receiving function invokes every passed callable.
240
+
241
+ `audit-async` checks recognized async producers. In JS/TS/HTML it also checks
242
+ captured promises used in arithmetic, conditions, or resolved-value member
243
+ access within the same lexical scope. Awaiting, returning, promise handlers,
244
+ reassignment, and shadowed bindings are distinguished. It is a bounded AST
245
+ audit, not a compiler-wide proof that every missing await has been found.
246
+
211
247
  For `plan --rename-to`, the selected declaration is only the starting point.
212
248
  When the index proves the relationship, the rename unit closes over
213
249
  overload/signature groups, base and override declarations, Rust trait slots,
@@ -35,7 +35,7 @@ structural or code-only search.
35
35
  | `deps <file>` | File dependency graph. Use `--direction=imports\|importers\|both`, `--detailed`, or `--cycles`. Cycles distinguish eager edges from function-local, Python typing-guarded, and TypeScript type-only edges. Complete cycle groups remain visible when enumeration is capped. |
36
36
  | `api [file]` | Static exported/public surface for a project or file. An exact file includes tests; broader scans exclude tests with a count. Use `--include-tests` to include them. |
37
37
  | `entrypoints` | Framework, route, task, test, and runtime entry points. |
38
- | `endpoints` | Server/client HTTP surface; `--bridge` adds advisory matching. |
38
+ | `endpoints` | Server/client HTTP surface; `--bridge` adds advisory matching. Test sites carry `[test]`; `--exclude-tests` removes them and `--in=DIR` scopes the inventory. |
39
39
 
40
40
  ## Focused audits and runtime evidence
41
41
 
@@ -55,7 +55,13 @@ Structural `search --param` matches parameter names, types, and defaults; `--ret
55
55
 
56
56
  `repo` summary/stats `buildTime` is the duration of the last index build (discovery, parsing, and graphs), retained in the cache. It excludes cache loading/saving and query execution, so it is not command wall time; `buildTimeNote` states this boundary.
57
57
 
58
- `--lines` writes one record per output line. `usages` records occurrences, so multiple tokens on the same source line can produce repeated `path:line` values. Deduplicate those values when counting source lines.
58
+ `--lines` writes one record per output line. `usages --lines` groups occurrences by source line and tags their kinds and counts. JSON retains individual occurrence records.
59
+
60
+ Public JSON source paths (`file`, caller files, dependency roots and edges) are project-relative, with the absolute base in `meta.pathBase`. Project roots and external paths remain absolute. Indexed absolute handles are accepted as well as relative handles.
61
+
62
+ Default test exclusions follow language conventions. Python `spec.py` and `*_spec.py` are included; `test_*.py`, `*_test.py`, and test directories are excluded. Structural search reports hidden test-file counts, including on empty results. `--include-tests` disables these defaults; explicit `--exclude` patterns still apply.
63
+
64
+ `audit-async` checks recognized async producers, including Python coroutine calls discarded in synchronous functions or module scope and captured JS/TS/HTML promises used as resolved values in the same lexical scope. Promise returns and handlers are valid; alias flow and unknown receivers require compiler/type-checker review.
59
65
 
60
66
  ## Common flags
61
67
 
package/cli/index.js CHANGED
@@ -485,6 +485,7 @@ function formatCliText(command, result, params, execution, displayFlags) {
485
485
  all: !!displayFlags?.all,
486
486
  surface: 'cli',
487
487
  params,
488
+ trailingChars: 1, // emitCliText appends the terminal newline.
488
489
  }).text;
489
490
  }
490
491
 
package/core/accessors.js CHANGED
@@ -2,6 +2,48 @@
2
2
 
3
3
  const path = require('path');
4
4
  const { codeUnitCompare } = require('./shared');
5
+ const { getParser, safeParse } = require('../languages');
6
+
7
+ function inheritedAccessor(index, enclosing, name, definition) {
8
+ let owner = enclosing?.className;
9
+ let file = enclosing?.file;
10
+ const seen = new Set();
11
+ while (owner && file && !seen.has(`${file}\0${owner}`)) {
12
+ seen.add(`${file}\0${owner}`);
13
+ if (owner === ownerName(definition) && file === definition.file) return true;
14
+ // An override owns this spelling. Multiple inheritance and duplicate
15
+ // declarations need richer lookup evidence, so keep them unverified.
16
+ if ((index.symbols.get(name) || []).some(d => d.className === owner && d.file === file)) return false;
17
+ const definitions = (index.symbols.get(owner) || []).filter(d => d.file === file && ['class', 'struct'].includes(d.type));
18
+ if (definitions.length !== 1) return false;
19
+ const parents = index._getInheritanceParentsAt(owner, file, definitions[0].startLine) || [];
20
+ if (parents.length !== 1) return false;
21
+ const parentName = bareTypeName(parents[0]);
22
+ const candidates = (index.symbols.get(parentName) || []).filter(d => ['class', 'struct'].includes(d.type));
23
+ const local = candidates.filter(d => d.file === file);
24
+ const imported = candidates.filter(d => index.importGraph.get(file)?.has(d.file));
25
+ const targets = local.length ? local : imported;
26
+ if (targets.length !== 1) return false;
27
+ owner = targets[0].name;
28
+ file = targets[0].file;
29
+ }
30
+ return false;
31
+ }
32
+
33
+ function accessKind(tree, usage) {
34
+ if (!tree || !Number.isInteger(usage.column)) return 'access';
35
+ let node = tree.rootNode.descendantForPosition({ row: usage.line - 1, column: usage.column });
36
+ if (['attribute', 'member_expression', 'member_access_expression'].includes(node.parent?.type)) node = node.parent;
37
+ const parent = node.parent;
38
+ if (parent?.type === 'update_expression' || parent?.type === 'delete_statement') return parent.type === 'update_expression' ? 'read/write' : 'delete';
39
+ if (['assignment', 'assignment_expression', 'augmented_assignment', 'augmented_assignment_expression'].includes(parent?.type)) {
40
+ const left = parent.childForFieldName('left');
41
+ if (left && left.startIndex === node.startIndex && left.endIndex === node.endIndex) {
42
+ return parent.type.startsWith('augmented') ? 'read/write' : 'write';
43
+ }
44
+ }
45
+ return 'read';
46
+ }
5
47
 
6
48
  // Descriptors/properties are consumed through reads and writes, not only
7
49
  // call syntax. Keep this vocabulary shared by impact and refactoring so the
@@ -87,11 +129,14 @@ function findAccessorReferences(index, name, definition, options = {}) {
87
129
  occurrences = index._getCachedUsages(file, name);
88
130
  } catch { continue; }
89
131
  if (!occurrences) continue;
132
+ const parser = getParser(entry.language);
133
+ const tree = parser ? safeParse(parser, content) : null;
90
134
  const lines = content.split('\n');
91
135
  for (const usage of occurrences) {
92
136
  if (usage.usageType !== 'reference') continue;
93
137
  refs.push({
94
138
  ...usage,
139
+ accessKind: accessKind(tree, usage),
95
140
  file,
96
141
  relativePath: entry.relativePath,
97
142
  content: lines[usage.line - 1] || '',
@@ -106,6 +151,7 @@ function findAccessorReferences(index, name, definition, options = {}) {
106
151
  absoluteFile: ref.file,
107
152
  line: ref.line,
108
153
  expression: (ref.content || '').trim(),
154
+ accessKind: ref.accessKind,
109
155
  ...(Number.isInteger(ref.column) && { column: ref.column }),
110
156
  ...(ref.receiver && { receiver: ref.receiver }),
111
157
  };
@@ -122,7 +168,8 @@ function findAccessorReferences(index, name, definition, options = {}) {
122
168
  const enclosing = index.findEnclosingFunction(ref.file, ref.line, true);
123
169
  const receiver = ref.receiver || null;
124
170
  if (receiver && ['self', 'cls', 'this'].includes(receiver) &&
125
- enclosing?.className === owner && enclosing.file === definition.file) {
171
+ ((enclosing?.className === owner && enclosing.file === definition.file) ||
172
+ inheritedAccessor(index, enclosing, name, definition))) {
126
173
  confirmed.push({
127
174
  ...shaped,
128
175
  callerName: enclosing.name,
package/core/account.js CHANGED
@@ -301,6 +301,10 @@ function classifyGroundLines(index, name, groundSet, claimedKeys) {
301
301
  const callLines = new Set();
302
302
  if (Array.isArray(cachedCalls)) {
303
303
  for (const c of cachedCalls) {
304
+ // Unclaimed callback/type references have no invocation
305
+ // syntax. Let the usage AST classify them as references;
306
+ // merely entering the candidate cache is not a call fact.
307
+ if (c.isFunctionReference || c.isTypeReference) continue;
304
308
  if (c.name === name || c.resolvedName === name ||
305
309
  (c.resolvedNames && c.resolvedNames.includes(name))) {
306
310
  callLines.add(c.line);
package/core/analysis.js CHANGED
@@ -17,7 +17,7 @@ const { NON_CALLABLE_TYPES, addTestExclusions, countTextBlindspots,
17
17
  const { isTestFile } = require('./discovery');
18
18
  const { computeReachability, symbolKey } = require('./entrypoints');
19
19
  const { getLanguageAdapter } = require('../languages');
20
- const { projectComputedDispatch } = require('./ast-analysis');
20
+ const { projectComputedDispatch, declarationSnapshots } = require('./ast-analysis');
21
21
  const { findAccessorReferences } = require('./accessors');
22
22
 
23
23
  // JS/TS test framework helpers — calls to these bracket a test case.
@@ -1181,6 +1181,7 @@ function impact(index, name, options = {}) {
1181
1181
  // Convert findCallers results to the format expected by analyzeCallSite
1182
1182
  const calls = callerResults.map(c => ({
1183
1183
  file: c.file,
1184
+ calledAs: c.calledAs,
1184
1185
  relativePath: c.relativePath,
1185
1186
  line: c.line,
1186
1187
  content: c.content,
@@ -2085,6 +2086,7 @@ function diffImpact(index, options = {}) {
2085
2086
  moduleLevelChanges: [],
2086
2087
  newFunctions: [],
2087
2088
  deletedFunctions: [],
2089
+ symbols: [], newSymbols: [], deletedSymbols: [],
2088
2090
  summary: { modifiedFunctions: 0, deletedFunctions: 0, newFunctions: 0, totalCallSites: 0, unverifiedCallSites: 0, affectedFiles: 0 }
2089
2091
  };
2090
2092
  }
@@ -2093,6 +2095,7 @@ function diffImpact(index, options = {}) {
2093
2095
  const moduleLevelChanges = [];
2094
2096
  const newFunctions = [];
2095
2097
  const deletedFunctions = [];
2098
+ const symbolChanges = { symbols: [], newSymbols: [], deletedSymbols: [] };
2096
2099
  const callerFileSet = new Set();
2097
2100
  let totalCallSites = 0;
2098
2101
  let totalUnverifiedSites = 0;
@@ -2120,6 +2123,7 @@ function diffImpact(index, options = {}) {
2120
2123
  { cwd: index.root, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] }
2121
2124
  );
2122
2125
  const oldParsed = parse(oldContent, lang);
2126
+ collectNonCallableChanges(index, change, oldParsed, [], symbolChanges);
2123
2127
  for (const oldFn of extractCallableSymbols(oldParsed)) {
2124
2128
  deletedFunctions.push({
2125
2129
  name: oldFn.name,
@@ -2144,6 +2148,8 @@ function diffImpact(index, options = {}) {
2144
2148
  // The identity key is `name\0className` (matches deletion-detection below).
2145
2149
  let oldSymbolIdentities = null; // null = unknown (file untracked or git failed)
2146
2150
  let oldCallables = null; // old symbols WITH ranges — deleted lines are old-file coordinates
2151
+ let oldParsed = null;
2152
+ let oldLines = null;
2147
2153
  if (change.deletedLines.length > 0 || change.addedLines.length > 0) {
2148
2154
  const ref = staged ? 'HEAD' : base;
2149
2155
  try {
@@ -2153,7 +2159,8 @@ function diffImpact(index, options = {}) {
2153
2159
  );
2154
2160
  const fileLang = detectLanguage(change.filePath);
2155
2161
  if (fileLang) {
2156
- const oldParsed = parse(oldContent, fileLang);
2162
+ oldParsed = parse(oldContent, fileLang);
2163
+ oldLines = oldContent.split('\n');
2157
2164
  oldSymbolIdentities = new Set();
2158
2165
  oldCallables = extractCallableSymbols(oldParsed);
2159
2166
  for (const oldFn of oldCallables) {
@@ -2165,8 +2172,13 @@ function diffImpact(index, options = {}) {
2165
2172
  }
2166
2173
  }
2167
2174
 
2175
+ collectNonCallableChanges(index, change, oldParsed, fileEntry.symbols, symbolChanges, oldLines);
2176
+
2168
2177
  for (const line of change.addedLines) {
2169
- const symbol = index.findEnclosingFunction(change.filePath, line, true);
2178
+ // Arrow fields are indexed as fields but their bodies still need
2179
+ // callable impact once declaration-only changes are separated out.
2180
+ const symbol = index.findEnclosingFunction(change.filePath, line, true) ||
2181
+ fileEntry.symbols.find(s => isDiffCallable(s) && s.startLine <= line && s.endLine >= line);
2170
2182
  if (symbol) {
2171
2183
  const key = `${symbol.name}:${symbol.startLine}`;
2172
2184
  if (!affectedSymbols.has(key)) {
@@ -2193,7 +2205,7 @@ function diffImpact(index, options = {}) {
2193
2205
  // still exists to its CURRENT definition for modification attribution.
2194
2206
  const currentByIdentity = new Map();
2195
2207
  for (const s of fileEntry.symbols) {
2196
- if (NON_CALLABLE_TYPES.has(s.type)) continue;
2208
+ if (!isDiffCallable(s)) continue;
2197
2209
  const k = `${s.name}\0${s.className || ''}`;
2198
2210
  if (!currentByIdentity.has(k)) currentByIdentity.set(k, []);
2199
2211
  currentByIdentity.get(k).push(s);
@@ -2249,7 +2261,7 @@ function diffImpact(index, options = {}) {
2249
2261
  let bestExact = false;
2250
2262
  let bestRange = Infinity;
2251
2263
  for (const symbol of fileEntry.symbols) {
2252
- if (NON_CALLABLE_TYPES.has(symbol.type)) continue;
2264
+ if (!isDiffCallable(symbol)) continue;
2253
2265
  const exact = line >= symbol.startLine && line <= symbol.endLine;
2254
2266
  const tolerant = line >= symbol.startLine - 2 && line <= symbol.endLine + 2;
2255
2267
  if (!exact && !tolerant) continue;
@@ -2324,7 +2336,7 @@ function diffImpact(index, options = {}) {
2324
2336
  if (change.deletedLines.length > 0 && oldCallables !== null) {
2325
2337
  const currentCounts = new Map();
2326
2338
  for (const s of fileEntry.symbols) {
2327
- if (NON_CALLABLE_TYPES.has(s.type)) continue;
2339
+ if (!isDiffCallable(s)) continue;
2328
2340
  const key = `${s.name}\0${s.className || ''}`;
2329
2341
  currentCounts.set(key, (currentCounts.get(key) || 0) + 1);
2330
2342
  }
@@ -2478,6 +2490,22 @@ function diffImpact(index, options = {}) {
2478
2490
  }
2479
2491
  }
2480
2492
 
2493
+ const dependencyFiles = new Set();
2494
+ let totalDependencySites = 0;
2495
+ let unverifiedDependencySites = 0;
2496
+ for (const symbol of [...symbolChanges.symbols, ...symbolChanges.newSymbols]) {
2497
+ const evidence = symbol.impact;
2498
+ if (!evidence) continue;
2499
+ totalCallSites += evidence.totalCallSites || 0;
2500
+ totalUnverifiedSites += evidence.unverifiedSites?.length || 0;
2501
+ for (const group of evidence.byFile || []) callerFileSet.add(path.resolve(index.root, group.file));
2502
+ const refs = evidence.typeReferences || evidence.propertyAccesses;
2503
+ totalDependencySites += refs?.confirmedCount || 0;
2504
+ unverifiedDependencySites += refs?.unverifiedCount || 0;
2505
+ for (const group of refs?.byFile || []) dependencyFiles.add(group.file);
2506
+ for (const site of refs?.unverifiedSites || []) dependencyFiles.add(site.file);
2507
+ }
2508
+
2481
2509
  return {
2482
2510
  base: staged ? '(staged)' : base,
2483
2511
  changedPaths: changes.length,
@@ -2487,10 +2515,17 @@ function diffImpact(index, options = {}) {
2487
2515
  moduleLevelChanges,
2488
2516
  newFunctions,
2489
2517
  deletedFunctions,
2518
+ ...symbolChanges,
2490
2519
  summary: {
2491
2520
  modifiedFunctions: functions.length,
2492
2521
  deletedFunctions: deletedFunctions.length,
2493
2522
  newFunctions: newFunctions.length,
2523
+ modifiedSymbols: symbolChanges.symbols.length,
2524
+ newSymbols: symbolChanges.newSymbols.length,
2525
+ deletedSymbols: symbolChanges.deletedSymbols.length,
2526
+ totalDependencySites,
2527
+ unverifiedDependencySites,
2528
+ dependencyFiles: dependencyFiles.size,
2494
2529
  totalCallSites,
2495
2530
  unverifiedCallSites: totalUnverifiedSites,
2496
2531
  affectedFiles: callerFileSet.size
@@ -2499,10 +2534,95 @@ function diffImpact(index, options = {}) {
2499
2534
  } finally { index._endOp(); }
2500
2535
  }
2501
2536
 
2537
+ // Non-callable declarations have change dependencies too. Keep them separate
2538
+ // from functions so type references never inflate call-site accounting.
2539
+ function collectNonCallableChanges(index, change, oldParsed, currentSymbols, output, oldLines = null) {
2540
+ const nonCallable = s => NON_CALLABLE_TYPES.has(s.type) || s.type === 'record';
2541
+ const oldSymbols = [];
2542
+ for (const cls of oldParsed?.classes || []) {
2543
+ oldSymbols.push(cls);
2544
+ for (const member of cls.members || []) {
2545
+ const symbol = { ...member, type: member.memberType, className: cls.name };
2546
+ if (nonCallable(symbol)) oldSymbols.push(symbol);
2547
+ }
2548
+ }
2549
+ oldSymbols.push(...(oldParsed?.stateObjects || []).map(s => ({ ...s, type: 'state' })));
2550
+ const identity = s => `${s.type}\0${s.className || ''}\0${s.name}`;
2551
+ const before = new Map();
2552
+ for (const s of oldSymbols.filter(nonCallable)) {
2553
+ const key = identity(s);
2554
+ if (!before.has(key)) before.set(key, []);
2555
+ before.get(key).push(s);
2556
+ }
2557
+ const touches = (s, lines) => lines.some(line => line >= s.startLine && line <= (s.endLine || s.startLine));
2558
+ const language = detectLanguage(change.filePath);
2559
+ let currentSnapshots = new Map();
2560
+ let oldSnapshots = new Map();
2561
+ // Unsupported/recovered declaration shapes retain conservative range
2562
+ // reporting. Never turn a failed AST projection into a clean diff.
2563
+ try {
2564
+ if (currentSymbols.some(nonCallable)) currentSnapshots = declarationSnapshots(
2565
+ index._getFileLines(change.filePath).join('\n'), language, currentSymbols.filter(nonCallable));
2566
+ } catch (_) { /* fall back to symbol ranges */ }
2567
+ try {
2568
+ if (oldLines) oldSnapshots = declarationSnapshots(oldLines.join('\n'), language, oldSymbols.filter(nonCallable));
2569
+ } catch (_) { /* fall back to symbol ranges */ }
2570
+ const declarationLines = (s, lines, snapshot) => lines.filter(line =>
2571
+ touches(s, [line]) && (!snapshot || snapshot.lines.has(line)));
2572
+ const append = (symbol, kind, old = null, lines = null) => {
2573
+ const item = {
2574
+ name: symbol.name, type: symbol.type, className: symbol.className,
2575
+ filePath: change.filePath, relativePath: change.relativePath,
2576
+ startLine: symbol.startLine, endLine: symbol.endLine,
2577
+ addedLines: kind === 'deletedSymbols' ? [] : lines.added,
2578
+ deletedLines: kind === 'deletedSymbols' ? change.deletedLines.filter(line => touches(old, [line])) : lines.deleted,
2579
+ };
2580
+ if (kind === 'deletedSymbols') {
2581
+ // The old identity no longer exists. Name occurrences are review
2582
+ // candidates, not proven references to the removed declaration.
2583
+ item.remainingReferences = index.usages(symbol.name, { includeTests: true, codeOnly: true })
2584
+ .filter(u => !u.isDefinition).map(u => ({
2585
+ file: u.relativePath, line: u.line, expression: u.content,
2586
+ reason: 'deleted-target-name-match', tier: 'unverified',
2587
+ }));
2588
+ } else {
2589
+ item.impact = impact(index, symbol.name, {
2590
+ file: change.relativePath, line: symbol.startLine, className: symbol.className,
2591
+ });
2592
+ }
2593
+ output[kind].push(item);
2594
+ };
2595
+ for (const symbol of currentSymbols.filter(nonCallable)) {
2596
+ const old = before.get(identity(symbol))?.shift();
2597
+ const currentSnapshot = currentSnapshots.get(symbol);
2598
+ const oldSnapshot = oldSnapshots.get(old);
2599
+ if (old && currentSnapshot && oldSnapshot && currentSnapshot.signature === oldSnapshot.signature) continue;
2600
+ const lines = {
2601
+ added: declarationLines(symbol, change.addedLines, currentSnapshot),
2602
+ deleted: old ? declarationLines(old, change.deletedLines, oldSnapshot) : [],
2603
+ };
2604
+ if (lines.added.length > 0 || lines.deleted.length > 0) {
2605
+ // When the base cannot be read, do not invent a new declaration.
2606
+ append(symbol, old || (!oldParsed && !change.untracked && !change.isNew)
2607
+ ? 'symbols' : 'newSymbols', old, lines);
2608
+ }
2609
+ }
2610
+ for (const group of before.values()) {
2611
+ for (const old of group) {
2612
+ if (touches(old, change.deletedLines)) append(old, 'deletedSymbols', old);
2613
+ }
2614
+ }
2615
+ }
2616
+
2502
2617
  // ========================================================================
2503
2618
  // STANDALONE HELPERS (used by diffImpact and parseDiff)
2504
2619
  // ========================================================================
2505
2620
 
2621
+ function isDiffCallable(symbol) {
2622
+ const type = symbol.type || symbol.memberType || 'method';
2623
+ return !NON_CALLABLE_TYPES.has(type) || (type === 'field' && symbol.isMethod);
2624
+ }
2625
+
2506
2626
  /**
2507
2627
  * Extract all callable symbols (functions + class methods) from a parse result,
2508
2628
  * matching how indexFile builds the symbol list — the identity keys
@@ -2525,7 +2645,7 @@ function extractCallableSymbols(parsed) {
2525
2645
  for (const cls of parsed.classes) {
2526
2646
  if (cls.members) {
2527
2647
  for (const m of cls.members) {
2528
- if (NON_CALLABLE_TYPES.has(m.memberType || 'method')) continue;
2648
+ if (!isDiffCallable(m)) continue;
2529
2649
  symbols.push({ name: m.name, className: cls.name, startLine: m.startLine, endLine: m.endLine });
2530
2650
  }
2531
2651
  }
@@ -2556,8 +2676,10 @@ function parseDiff(diffText, root) {
2556
2676
  const changes = [];
2557
2677
  let currentFile = null;
2558
2678
  let pendingOldPath = null; // Track --- a/ path for deleted files
2679
+ let pendingNewFile = false;
2559
2680
 
2560
2681
  for (const line of diffText.split('\n')) {
2682
+ if (line === '--- /dev/null') pendingNewFile = true;
2561
2683
  // Track old file path from --- header for deleted-file detection
2562
2684
  // Handles both unquoted (--- a/path) and quoted (--- "a/path") formats
2563
2685
  const oldMatch = line.match(/^--- (?:"a\/((?:[^"\\]|\\.)*)"|a\/(.+?))\s*$/);
@@ -2587,8 +2709,10 @@ function parseDiff(diffText, root) {
2587
2709
  relativePath,
2588
2710
  addedLines: [],
2589
2711
  deletedLines: [],
2712
+ ...(pendingNewFile && { isNew: true }),
2590
2713
  ...(isDevNull && { isDeleted: true })
2591
2714
  };
2715
+ pendingNewFile = false;
2592
2716
  changes.push(currentFile);
2593
2717
  continue;
2594
2718
  }
@@ -2656,6 +2780,78 @@ const _ASYNCIO_CONSUMER_FNS = new Set([
2656
2780
  'gather', 'create_task', 'ensure_future', 'wait', 'as_completed',
2657
2781
  ]);
2658
2782
 
2783
+ // Follow a captured JS promise within its lexical scope, looking only for
2784
+ // operations that require its resolved value. Passing/returning/awaiting the
2785
+ // promise and its own methods are valid. Reassignment stops the inference;
2786
+ // nested functions and shadowing blocks cannot borrow the outer binding.
2787
+ function storedPromiseMisuse(call, functionNodes) {
2788
+ const { sameNode } = require('../languages/utils');
2789
+ let value = call;
2790
+ while (value.parent?.type === 'parenthesized_expression') value = value.parent;
2791
+ const assignment = value.parent;
2792
+ if (!assignment || !['variable_declarator', 'assignment_expression'].includes(assignment.type)) return null;
2793
+ const binding = assignment.childForFieldName(assignment.type === 'variable_declarator' ? 'name' : 'left');
2794
+ if (binding?.type !== 'identifier') return null;
2795
+ let scope = assignment.parent;
2796
+ while (scope && scope.type !== 'statement_block' && !functionNodes.has(scope.type)) scope = scope.parent;
2797
+ if (!scope) return null;
2798
+ const name = binding.text;
2799
+ const promiseMembers = new Set(['then', 'catch', 'finally', 'constructor',
2800
+ 'toString', 'toLocaleString', 'valueOf', 'hasOwnProperty', 'isPrototypeOf', 'propertyIsEnumerable']);
2801
+ let stopped = false;
2802
+ let misuse = null;
2803
+ const namesBinding = node => !!node && ((['identifier', 'shorthand_property_identifier_pattern'].includes(node.type) && node.text === name) ||
2804
+ node.namedChildren.some(namesBinding));
2805
+ const shadows = block => (block.namedChildren || []).some(statement =>
2806
+ ['lexical_declaration', 'variable_declaration'].includes(statement.type) &&
2807
+ statement.namedChildren.some(decl => namesBinding(decl.childForFieldName('name'))));
2808
+ const visit = node => {
2809
+ if (stopped || misuse || node.endIndex <= call.endIndex) return;
2810
+ if (functionNodes.has(node.type)) return;
2811
+ if (!sameNode(node, scope) && node.type === 'statement_block' && shadows(node)) return;
2812
+ if (node.type === 'catch_clause' && namesBinding(node.childForFieldName('parameter'))) return;
2813
+ if (node.type === 'for_statement' && shadows(node)) return;
2814
+ if (node.type === 'for_in_statement' && namesBinding(node.childForFieldName('left'))) {
2815
+ // A declared loop variable shadows; an undeclared one overwrites
2816
+ // the promise, so later uses cannot inherit its earlier type.
2817
+ if (!node.children.some(child => ['let', 'const'].includes(child.type))) stopped = true;
2818
+ return;
2819
+ }
2820
+ if (node.type === 'assignment_expression' && !sameNode(node, assignment) && node.childForFieldName('left')?.text === name) {
2821
+ const right = node.childForFieldName('right');
2822
+ if (right) visit(right);
2823
+ stopped = true;
2824
+ return;
2825
+ }
2826
+ if (node.type === 'identifier' && node.text === name && node.startIndex >= call.endIndex) {
2827
+ let use = node;
2828
+ while (use.parent?.type === 'parenthesized_expression') use = use.parent;
2829
+ const parent = use.parent;
2830
+ if (parent?.type === 'member_expression' && sameNode(parent.childForFieldName('object'), use)) {
2831
+ const property = parent.childForFieldName('property');
2832
+ if (property && !promiseMembers.has(property.text)) misuse = node;
2833
+ } else if (parent?.type === 'subscript_expression' && sameNode(parent.childForFieldName('object'), use)) {
2834
+ misuse = node;
2835
+ } else if (parent?.type === 'binary_expression') {
2836
+ const operator = parent.childForFieldName('operator')?.text;
2837
+ if (['+', '-', '*', '/', '%', '**', '<', '>', '<=', '>=', '|', '&', '^', '<<', '>>', '>>>'].includes(operator)) misuse = node;
2838
+ } else if (parent?.type === 'unary_expression' && ['+', '-', '~'].includes(parent.childForFieldName('operator')?.text)) {
2839
+ misuse = node;
2840
+ } else if (parent && ['update_expression', 'augmented_assignment_expression'].includes(parent.type)) {
2841
+ misuse = node;
2842
+ } else if (parent && ['if_statement', 'while_statement', 'do_statement', 'ternary_expression'].includes(parent.type) &&
2843
+ sameNode(parent.childForFieldName('condition'), use)) {
2844
+ misuse = node;
2845
+ }
2846
+ }
2847
+ for (const child of node.namedChildren || []) visit(child);
2848
+ };
2849
+ // A function root's body is the scan scope, never its parameters.
2850
+ visit(scope.type === 'statement_block' ? scope : scope.childForFieldName('body') || scope);
2851
+ return misuse ? { line: misuse.startPosition.row + 1, variable: name,
2852
+ originLine: call.startPosition.row + 1, reason: 'stored-promise-used-as-value' } : null;
2853
+ }
2854
+
2659
2855
  /**
2660
2856
  * Run an async/await audit across the project.
2661
2857
  *
@@ -2824,7 +3020,7 @@ function auditAsync(index, options = {}) {
2824
3020
  }
2825
3021
  }
2826
3022
  }
2827
- if (asyncFns.length === 0) return;
3023
+ if (asyncFns.length === 0 && language !== 'python') return;
2828
3024
 
2829
3025
  // Re-parse file to find awaited-vs-not call sites. We use a fresh
2830
3026
  // parse rather than tree cache because we want to walk every
@@ -2893,6 +3089,12 @@ function auditAsync(index, options = {}) {
2893
3089
  // immediate enclosing fn-node is async (so callbacks inside an
2894
3090
  // async fn aren't misclassified as async themselves).
2895
3091
  function nearestAsyncEnclosing(callNode) {
3092
+ // Python creates a dormant coroutine even in a synchronous
3093
+ // scope. A discarded expression never runs; returning or
3094
+ // passing it to an event-loop consumer remains intentional.
3095
+ let expression = callNode;
3096
+ while (expression.parent?.type === 'parenthesized_expression') expression = expression.parent;
3097
+ const discardedPython = language === 'python' && expression.parent?.type === 'expression_statement';
2896
3098
  let cur = callNode.parent;
2897
3099
  while (cur) {
2898
3100
  if (FN_NODE_TYPES.has(cur.type)) {
@@ -2909,11 +3111,16 @@ function auditAsync(index, options = {}) {
2909
3111
  endLine: cur.endPosition.row + 1,
2910
3112
  };
2911
3113
  }
3114
+ if (discardedPython) return {
3115
+ name: cur.childForFieldName('name')?.text || '<anonymous>',
3116
+ startLine: cur.startPosition.row + 1,
3117
+ endLine: cur.endPosition.row + 1,
3118
+ };
2912
3119
  return null; // Inner non-async fn — stop, don't leak into outer scope.
2913
3120
  }
2914
3121
  cur = cur.parent;
2915
3122
  }
2916
- return null;
3123
+ return discardedPython ? { name: '<module>' } : null;
2917
3124
  }
2918
3125
 
2919
3126
  function visit(node) {
@@ -3012,6 +3219,10 @@ function auditAsync(index, options = {}) {
3012
3219
  let current = node.parent;
3013
3220
  let awaitDepth = 0;
3014
3221
  while (current && awaitDepth++ < 5) {
3222
+ if (current.type === 'parenthesized_expression') {
3223
+ current = current.parent;
3224
+ continue;
3225
+ }
3015
3226
  if (current.type === 'await_expression' ||
3016
3227
  current.type === 'await') {
3017
3228
  awaited = true;
@@ -3034,12 +3245,15 @@ function auditAsync(index, options = {}) {
3034
3245
  }
3035
3246
  break;
3036
3247
  }
3037
- if (!awaited && !isFireAndForget(node, language)) {
3248
+ const storedMisuse = !awaited && langTraits(language)?.storedPromises
3249
+ ? storedPromiseMisuse(node, FN_NODE_TYPES) : null;
3250
+ if (storedMisuse || (!awaited && !isFireAndForget(node, language))) {
3038
3251
  issues.push({
3039
3252
  file: fileEntry.relativePath || filePath,
3040
3253
  line,
3041
3254
  callerName: enclosing.name,
3042
3255
  calleeName,
3256
+ ...(storedMisuse || {}),
3043
3257
  });
3044
3258
  }
3045
3259
  }