ucn 5.1.1 → 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.
@@ -140,21 +140,21 @@ const COMMAND_CONTRACTS = Object.freeze({
140
140
  }),
141
141
  impact: contract({
142
142
  question: 'What indexed code may be affected by changing this symbol or Git diff?',
143
- purpose: 'List direct symbol call sites or compose repository impact from changed lines.',
143
+ purpose: 'List direct symbol dependencies or compose repository impact from changed lines.',
144
144
  target: 'Optional symbol/handle. Omitting it selects Git-diff mode.',
145
145
  modes: [
146
- { name: 'symbol', when: 'A symbol target is supplied.', answer: 'Tiered direct call sites grouped by file with patterns/accounting.' },
146
+ { name: 'symbol', when: 'A symbol target is supplied.', answer: 'Tiered direct call sites grouped by file with patterns/accounting; accessor targets also list receiver-tiered property reads/writes.' },
147
147
  { name: 'diff', when: 'No symbol is supplied; `base` or `staged` selects the diff.', answer: 'Changed definitions and their composed impact.' },
148
148
  ],
149
149
  defaults: ['Symbol output is compact.', 'Diff base defaults to the repository default used by the Git analysis layer.'],
150
- truth: 'Symbol impact is static tiered caller evidence; diff impact composes indexed changed definitions and does not imply runtime reachability completeness.',
150
+ truth: 'Symbol impact is static tiered caller evidence plus a separate accessor-dependency band where applicable; diff impact composes indexed changed definitions and does not imply runtime reachability completeness.',
151
151
  nonGoals: ['Automatic edits.', 'Behavioral equivalence or deployment impact.'],
152
152
  invalidCombinations: [
153
153
  'A named symbol cannot also select staged/diff scope.',
154
154
  '`base` and `staged` cannot both select the diff.',
155
155
  ],
156
156
  examples: ['ucn impact src/parser.ts:42:parseRequest', 'ucn impact --staged'],
157
- jsonData: 'Symbol mode returns call sites/accounting; diff mode returns changed functions and aggregate impact.',
157
+ jsonData: 'Symbol mode returns call sites/accounting and optional property-access dependencies; diff mode returns changed functions and aggregate impact.',
158
158
  output: 'Potentially broad; narrow symbol mode by file/exclude and diff mode by a focused base.',
159
159
  next: ['Use `plan` for a concrete refactor preview.', 'Use `tests` for affected tests.', 'Use `check` after editing.'],
160
160
  benchmark: 'A08',
@@ -203,16 +203,16 @@ const COMMAND_CONTRACTS = Object.freeze({
203
203
  purpose: 'Preview rename or parameter-shape edits without mutating files.',
204
204
  target: 'Required symbol name or stable handle plus one refactor operation.',
205
205
  modes: [
206
- { name: 'rename', when: '`renameTo` is supplied.', answer: 'Selected declaration plus indexed call/import/export edit previews.' },
206
+ { name: 'rename', when: '`renameTo` is supplied.', answer: 'Selected declaration plus indexed call/reference/import/export edit previews and source-text review items.' },
207
207
  { name: 'add parameter', when: '`addParam` is supplied.', answer: 'Selected declaration and call-site preview, optionally with a default.' },
208
208
  { name: 'remove parameter', when: '`removeParam` is supplied.', answer: 'Selected declaration and affected call-site preview.' },
209
209
  ],
210
210
  defaults: ['Preview only; no file writes.'],
211
- truth: 'Changes are derived from indexed definitions/usages, include the selected declaration, and retain unverified/blocked or needs-review evidence separately.',
211
+ truth: 'Changes are derived from indexed definitions/usages, include the selected declaration, and retain unverified/blocked or needs-review evidence separately; non-source files receive an explicit exact-text search handoff.',
212
212
  nonGoals: ['Applying edits.', 'Guaranteeing the preview compiles.'],
213
213
  invalidCombinations: ['Exactly one of rename, add-parameter, or remove-parameter is required.', '`defaultValue` only applies to add-parameter.'],
214
214
  examples: ['ucn plan parseRequest --rename-to=parseIncoming', 'ucn plan parseRequest --add-param=context --default-value=null'],
215
- jsonData: 'Before/after signatures, concrete declaration/call/import/export previews, changeSummary, needsReview markers, unverified sites, and account metadata.',
215
+ jsonData: 'Before/after signatures, concrete declaration/call/reference/import/export previews, changeSummary, reviewItems, non-source handoff, unverified sites, and account metadata.',
216
216
  output: 'Targeted refactor preview; inspect every unverified or warning entry.',
217
217
  next: ['Use `impact` before editing.', 'Use `check` and the compiler after applying the change manually.'],
218
218
  benchmark: 'A11',
@@ -245,14 +245,14 @@ const COMMAND_CONTRACTS = Object.freeze({
245
245
  { name: 'imports', when: '`direction=imports`.', answer: 'Downstream imported files.' },
246
246
  { name: 'importers', when: '`direction=importers`.', answer: 'Upstream importing files.' },
247
247
  { name: 'both', when: '`direction=both`.', answer: 'Both graph directions.' },
248
- { name: 'cycles', when: '`cycles=true`.', answer: 'Detected static circular dependencies.' },
248
+ { name: 'cycles', when: '`cycles=true`.', answer: 'Detected static circular dependencies classified as eager or Python function-local/deferred.' },
249
249
  ],
250
250
  defaults: ['Direction defaults to both.', 'Traversal depth is bounded.'],
251
- truth: 'Edges are resolved static import/include relationships inside the indexed project.',
251
+ truth: 'Edges are resolved static import/include relationships inside the indexed project; a deferred classification means at least one Python edge is function-local, not that runtime initialization can never trigger it.',
252
252
  nonGoals: ['Dynamic imports that cannot be resolved statically.', 'Package-manager or runtime dependency graphs.'],
253
253
  invalidCombinations: ['A file is required outside cycles mode.', 'Cycle mode does not combine with a file-direction question.'],
254
254
  examples: ['ucn deps src/server.ts --direction=both --depth=2', 'ucn deps --cycles'],
255
- jsonData: 'Graph mode returns nodes/edges by direction; cycles mode returns cycle records.',
255
+ jsonData: 'Graph mode returns nodes/edges by direction; cycles mode returns classified cycle and deferred-edge records.',
256
256
  output: 'Broad graph output; use `depth=1`, one direction, or a file target.',
257
257
  next: ['Use `api` on a dependency boundary.', 'Use `show` on a symbol crossing the edge.'],
258
258
  benchmark: 'A12',
@@ -319,11 +319,11 @@ const COMMAND_CONTRACTS = Object.freeze({
319
319
  { name: 'candidates', when: 'Always; include flags can reveal protected categories.', answer: 'Static zero-usage candidates and usage count.' },
320
320
  ],
321
321
  defaults: ['Exported and decorated symbols excluded.', 'Tests excluded unless requested.'],
322
- truth: 'A result means no modeled usage survived the command policy; known computed-dispatch registry members are withheld, and the result is explicitly not safe-delete proof.',
323
- nonGoals: ['External consumers, reflection, generated registration, unresolved computed dispatch, or runtime reachability proof.'],
322
+ truth: 'A result means no modeled usage survived the command policy; known computed-dispatch registry members and statically named reflection targets are withheld, and the result is explicitly not safe-delete proof.',
323
+ nonGoals: ['External consumers, dynamically named reflection, generated registration, unresolved computed dispatch, or runtime reachability proof.'],
324
324
  invalidCombinations: [],
325
325
  examples: ['ucn deadcode --exclude=test', 'ucn deadcode --include-exported --limit=100'],
326
- jsonData: 'Candidate records plus computed-dispatch/deletion-safety metadata and any registry members withheld from candidates.',
326
+ jsonData: 'Candidate records plus computed-dispatch, literal/dynamic-reflection, deletion-safety, and coverage metadata.',
327
327
  output: 'Broad candidate list; narrow by file/in/exclude/limit.',
328
328
  next: ['Review `usages`, `impact`, `entrypoints`, and `api`, then run compiler/tests before deletion.'],
329
329
  benchmark: 'A16',
package/core/deadcode.js CHANGED
@@ -13,7 +13,7 @@ const { isTestFile } = require('./discovery');
13
13
  const { isFrameworkEntrypoint } = require('./entrypoints');
14
14
  const { splitParentList } = require('./graph-build');
15
15
  const { isOverrideMarked, codeUnitCompare, lineInRanges, maskBlockComments, escapeRegExp } = require('./shared');
16
- const { projectComputedDispatch } = require('./ast-analysis');
16
+ const { projectComputedDispatch, reflectionSites } = require('./ast-analysis');
17
17
 
18
18
  const _CLASS_KINDS = ['class', 'struct', 'interface', 'trait', 'record'];
19
19
 
@@ -738,6 +738,34 @@ function deadcode(index, options = {}) {
738
738
  computedDispatchInfo.files.push(fe.relativePath);
739
739
  }
740
740
  }
741
+ const literalReflectionNames = new Set();
742
+ const reflectionInfo = {
743
+ count: 0,
744
+ literalCount: 0,
745
+ dynamicCount: 0,
746
+ fileCount: 0,
747
+ files: [],
748
+ names: [],
749
+ };
750
+ for (const [reflectionFile, fe] of index.files) {
751
+ if (!index.matchesFilters(fe.relativePath, options)) continue;
752
+ let sites = [];
753
+ try {
754
+ sites = reflectionSites(index._readFile(reflectionFile), fe.language);
755
+ } catch { /* coverage diagnostics own unreadable-file reporting */ }
756
+ if (sites.length === 0) continue;
757
+ reflectionInfo.count += sites.length;
758
+ reflectionInfo.literalCount += sites.filter(site => !site.dynamic).length;
759
+ reflectionInfo.dynamicCount += sites.filter(site => site.dynamic).length;
760
+ reflectionInfo.fileCount++;
761
+ if (reflectionInfo.files.length < 10) {
762
+ reflectionInfo.files.push(fe.relativePath);
763
+ }
764
+ for (const site of sites) {
765
+ if (site.name) literalReflectionNames.add(site.name);
766
+ }
767
+ }
768
+ reflectionInfo.names = [...literalReflectionNames].sort(codeUnitCompare);
741
769
 
742
770
  // Ensure callee index is built (lazy, reused across operations)
743
771
  if (!index.calleeIndex) {
@@ -810,8 +838,18 @@ function deadcode(index, options = {}) {
810
838
  potentiallyDeadNames = filteredNames;
811
839
  }
812
840
 
841
+ // A literal reflection target is a statically named runtime use. We may
842
+ // not know which same-name member receives it, so conservatively withdraw
843
+ // every deletion claim for that spelling rather than invent identity.
844
+ for (const reflectedName of literalReflectionNames) {
845
+ potentiallyDeadNames.delete(reflectedName);
846
+ }
847
+
813
848
  const coverage = scanDeadcodeCoverage(index, potentiallyDeadNames);
814
- const coverageSuppressedNames = new Set(coverage.matchedNames);
849
+ const coverageSuppressedNames = new Set([
850
+ ...coverage.matchedNames,
851
+ ...literalReflectionNames,
852
+ ]);
815
853
  if (coverage.claimsWithdrawn) {
816
854
  // An unreadable file/directory can contain a use of any candidate.
817
855
  // Returning no deletion candidates is the only sound verdict.
@@ -1470,6 +1508,7 @@ function deadcode(index, options = {}) {
1470
1508
  results.excludedRuntimeContract = excludedRuntimeContract;
1471
1509
  results.excludedDynamicDispatch = excludedDynamicDispatch;
1472
1510
  results.computedDispatch = computedDispatchInfo;
1511
+ results.reflection = reflectionInfo;
1473
1512
  results.coverage = {
1474
1513
  complete: coverage.complete,
1475
1514
  claimsWithdrawn: coverage.claimsWithdrawn,
package/core/execute.js CHANGED
@@ -16,6 +16,7 @@ const { addTestExclusions, pickBestDefinition, parseSymbolHandle, looksLikeHandl
16
16
  const { cleanHtmlScriptTags, detectLanguage } = require('./parser');
17
17
  const { renderExpandItem } = require('./expand-cache');
18
18
  const { CANONICAL_COMMANDS } = require('./registry');
19
+ const { isAccessorDefinition } = require('./accessors');
19
20
 
20
21
  // ============================================================================
21
22
  // HELPERS
@@ -1639,6 +1640,7 @@ const HANDLERS = {
1639
1640
  if (result.excludedDynamicDispatch != null) sliced.excludedDynamicDispatch = result.excludedDynamicDispatch;
1640
1641
  if (result.pythonImplicitExportFiles != null) sliced.pythonImplicitExportFiles = result.pythonImplicitExportFiles;
1641
1642
  if (result.computedDispatch != null) sliced.computedDispatch = result.computedDispatch;
1643
+ if (result.reflection != null) sliced.reflection = result.reflection;
1642
1644
  if (result.coverage != null) sliced.coverage = result.coverage;
1643
1645
  // Truncation must be visible IN the JSON payload, not only in the
1644
1646
  // stderr note (fix #242) — the formatter reads this to emit
@@ -1875,7 +1877,8 @@ const HANDLERS = {
1875
1877
  const actualName = fnSplit ? fnSplit.methodName : fnName;
1876
1878
  const fnClassName = fnSplit ? fnSplit.className : p.className;
1877
1879
  let matches = index.find(actualName, { file: p.file, className: fnClassName, skipCounts: true })
1878
- .filter(m => m.type === 'function' || m.params !== undefined);
1880
+ .filter(m => m.type === 'function' || m.params !== undefined ||
1881
+ isAccessorDefinition(m));
1879
1882
 
1880
1883
  // Line pin from a stable handle or explicit line= (fix #249:
1881
1884
  // `fn both.js:5:run` returned the OTHER same-name def — the
@@ -451,6 +451,12 @@ function buildInheritanceGraph(index) {
451
451
  }
452
452
  index.extendsGraph.get(symbol.name).push({
453
453
  file: filePath,
454
+ // Per-def anchor (fix #300, attrs-measured): five
455
+ // function-local `class C2Slots(...)` defs in one file
456
+ // carry DIFFERENT parents — file-granular lookup returned
457
+ // the first entry for all of them. startLine lets scope-
458
+ // aware consumers pick the def the call site actually sees.
459
+ startLine: symbol.startLine,
454
460
  parents: resolvedParents
455
461
  });
456
462
 
package/core/graph.js CHANGED
@@ -48,7 +48,8 @@ function imports(index, filePath) {
48
48
  resolved: null,
49
49
  isExternal: false,
50
50
  isDynamic: true,
51
- line
51
+ line,
52
+ deferred: !!imp.deferred,
52
53
  };
53
54
  }
54
55
 
@@ -64,7 +65,8 @@ function imports(index, filePath) {
64
65
  resolved: null,
65
66
  isExternal: false,
66
67
  isDynamic: true,
67
- line
68
+ line,
69
+ deferred: !!imp.deferred,
68
70
  };
69
71
  }
70
72
 
@@ -94,7 +96,8 @@ function imports(index, filePath) {
94
96
  // is still mechanically dynamic even when the path resolves —
95
97
  // `type: 'dynamic'` with `isDynamic: false` was a contradiction.
96
98
  isDynamic: imp.type === 'dynamic',
97
- line
99
+ line,
100
+ deferred: !!imp.deferred,
98
101
  };
99
102
  });
100
103
  } catch (e) {
@@ -780,7 +783,37 @@ function circularDeps(index, options = {}) {
780
783
  }
781
784
  }
782
785
 
783
- // Convert to relative paths and deduplicate
786
+ const importEdgeDetails = (fromFile, toFile) => {
787
+ const entry = index.files.get(fromFile);
788
+ if (!entry || entry.language !== 'python') return [];
789
+ const matches = [];
790
+ for (const detail of entry.importDetails || []) {
791
+ const specs = [detail.module];
792
+ const mod = String(detail.module || '');
793
+ for (const importedName of detail.names || []) {
794
+ specs.push(mod.endsWith('.')
795
+ ? mod + importedName
796
+ : `${mod}.${importedName}`);
797
+ }
798
+ const reachesTarget = specs.some(spec => {
799
+ const rel = entry.moduleResolved?.[spec];
800
+ return rel && path.resolve(index.root, rel) === path.resolve(toFile);
801
+ });
802
+ if (!reachesTarget) continue;
803
+ matches.push({
804
+ from: entry.relativePath,
805
+ to: index.files.get(toFile)?.relativePath || path.relative(index.root, toFile),
806
+ line: detail.line ?? null,
807
+ deferred: !!detail.deferred,
808
+ });
809
+ }
810
+ return matches;
811
+ };
812
+
813
+ // Convert to relative paths, classify edge execution, and deduplicate.
814
+ // A cycle with at least one function-local Python import is still a
815
+ // structural dependency loop, but not an unconditional import-time
816
+ // loop. Preserve it as a deferred advisory rather than a false alarm.
784
817
  const seen = new Set();
785
818
  const uniqueCycles = [];
786
819
  for (const cycle of cycles) {
@@ -792,7 +825,37 @@ function circularDeps(index, options = {}) {
792
825
  const key = rotated.join('\0');
793
826
  if (!seen.has(key)) {
794
827
  seen.add(key);
795
- uniqueCycles.push({ files: rotated, length: rotated.length });
828
+ const rotatedAbs = [...cycle.slice(minIdx), ...cycle.slice(0, minIdx)];
829
+ const edges = [];
830
+ for (let edgeIndex = 0; edgeIndex < rotatedAbs.length; edgeIndex++) {
831
+ const from = rotatedAbs[edgeIndex];
832
+ const to = rotatedAbs[(edgeIndex + 1) % rotatedAbs.length];
833
+ const details = importEdgeDetails(from, to);
834
+ const deferred = details.length > 0 && details.every(detail => detail.deferred);
835
+ edges.push({
836
+ from: rotated[edgeIndex],
837
+ to: rotated[(edgeIndex + 1) % rotated.length],
838
+ deferred,
839
+ ...(details.length > 0 && {
840
+ lines: [...new Set(details.map(detail => detail.line)
841
+ .filter(line => line != null))].sort((a, b) => a - b),
842
+ }),
843
+ });
844
+ }
845
+ const deferredEdges = edges.filter(edge => edge.deferred)
846
+ .map(edge => ({
847
+ from: edge.from,
848
+ to: edge.to,
849
+ line: edge.lines?.[0] ?? null,
850
+ ...(edge.lines?.length > 1 && { lines: edge.lines }),
851
+ }));
852
+ uniqueCycles.push({
853
+ files: rotated,
854
+ length: rotated.length,
855
+ classification: deferredEdges.length > 0 ? 'deferred' : 'eager',
856
+ edges,
857
+ deferredEdges,
858
+ });
796
859
  }
797
860
  }
798
861
 
@@ -810,6 +873,8 @@ function circularDeps(index, options = {}) {
810
873
  if (targets && targets.size > 0) filesWithImports++;
811
874
  }
812
875
 
876
+ const eagerCycles = result.filter(cycle => cycle.classification !== 'deferred').length;
877
+ const deferredCycles = result.length - eagerCycles;
813
878
  return {
814
879
  cycles: result,
815
880
  totalFiles: index.files.size,
@@ -818,6 +883,8 @@ function circularDeps(index, options = {}) {
818
883
  summary: {
819
884
  totalCycles: result.length,
820
885
  filesInCycles: new Set(result.flatMap(c => c.files)).size,
886
+ eagerCycles,
887
+ deferredCycles,
821
888
  }
822
889
  };
823
890
  } finally {
package/core/index-ir.js CHANGED
@@ -20,6 +20,7 @@ function createImportBindings(imports) {
20
20
  ...(item.line != null && { line: item.line }),
21
21
  ...(rename && { alias: rename.local }),
22
22
  ...(item.defaultLike && { defaultLike: true }),
23
+ ...(item.deferred && { deferred: true }),
23
24
  };
24
25
  }));
25
26
  }
@@ -46,6 +47,15 @@ function createFileEntryFromIR({
46
47
  mtime,
47
48
  size,
48
49
  imports: imports.map(item => item.module),
50
+ ...(ir.language === 'python' && {
51
+ importDetails: imports.map(item => ({
52
+ module: item.module,
53
+ names: [...(item.names || [])],
54
+ ...(item.type && { type: item.type }),
55
+ ...(item.line != null && { line: item.line }),
56
+ ...(item.deferred && { deferred: true }),
57
+ })),
58
+ }),
49
59
  globalImports: imports.filter(item => item.global).map(item => item.module),
50
60
  importNames: imports.flatMap(item => item.names || []),
51
61
  importBindings: createImportBindings(imports),
@@ -66,7 +76,7 @@ function createFileEntryFromIR({
66
76
 
67
77
  const OPTIONAL_SYMBOL_FIELDS = Object.freeze([
68
78
  'returnedFunctionResult', 'isFunctionVariable', 'paramTypes', 'isAsync',
69
- 'isGenerator', 'generics', 'extends', 'implements', 'indent', 'isNested',
79
+ 'isGenerator', 'generics', 'genericBounds', 'extends', 'implements', 'indent', 'isNested',
70
80
  'enclosingType', 'isMethod', 'receiver', 'memberType', 'fieldType',
71
81
  'aliasOf', 'derefTarget', 'decorators', 'decoratorsWithArgs',
72
82
  'annotationsWithArgs', 'attributesWithArgs', 'nameLine', 'traitImpl',
@@ -76,8 +86,9 @@ const OPTIONAL_SYMBOL_FIELDS = Object.freeze([
76
86
  'lexicalScopeStartLine', 'lexicalScopeEndLine',
77
87
  'returnTypeQualifier', 'macroNeverReturns', 'callbackParamTypes', 'iteratorItemType',
78
88
  'returnedConcreteType', 'returnedConstructors', 'templateDependent',
89
+ 'isSpecialization',
79
90
  'linkage', 'functionLike', 'callableAlias', 'exportedAlias',
80
- 'aliasOwner', 'aliasMember',
91
+ 'aliasOwner', 'aliasMember', 'macroParamEffects',
81
92
  ]);
82
93
 
83
94
  function materializeSymbol(fileEntry, item) {
package/core/ir.js CHANGED
@@ -43,7 +43,7 @@ function normalizeSymbol(symbol, family, language, kind, owner = null) {
43
43
  };
44
44
  const passthrough = [
45
45
  'docstring', 'returnedFunctionResult', 'isFunctionVariable', 'paramTypes',
46
- 'isAsync', 'isGenerator', 'generics', 'extends', 'implements', 'indent',
46
+ 'isAsync', 'isGenerator', 'generics', 'genericBounds', 'extends', 'implements', 'indent',
47
47
  'isNested', 'enclosingType', 'isMethod', 'memberType', 'fieldType',
48
48
  'aliasOf', 'derefTarget', 'decorators', 'decoratorsWithArgs',
49
49
  'annotationsWithArgs', 'attributesWithArgs', 'nameLine', 'traitImpl',
@@ -53,8 +53,9 @@ function normalizeSymbol(symbol, family, language, kind, owner = null) {
53
53
  'namespace', 'lexicalScopeStartLine', 'lexicalScopeEndLine',
54
54
  'returnTypeQualifier', 'macroNeverReturns', 'callbackParamTypes', 'iteratorItemType',
55
55
  'returnedConcreteType', 'returnedConstructors', 'templateDependent',
56
+ 'isSpecialization',
56
57
  'linkage', 'functionLike', 'callableAlias', 'exportedAlias',
57
- 'aliasOwner', 'aliasMember',
58
+ 'aliasOwner', 'aliasMember', 'macroParamEffects',
58
59
  ];
59
60
  for (const field of passthrough) {
60
61
  if (symbol[field] !== undefined && symbol[field] !== null) {
@@ -107,7 +108,8 @@ function createFileIR({
107
108
  }),
108
109
  };
109
110
  append(inherited,
110
- inherited.memberType === 'field' ? 'state' : 'callable',
111
+ ['field', 'property'].includes(inherited.memberType)
112
+ ? 'state' : 'callable',
111
113
  inherited.memberType || (inherited.isConstructor ? 'constructor' : 'method'),
112
114
  type.name);
113
115
  }
@@ -786,12 +786,17 @@ function formatImpact(impact, options = {}) {
786
786
  // Summary (confirmed + unverified tiers reported separately)
787
787
  const impactUnverified = impact.unverifiedSites || [];
788
788
  const unverifiedSuffix = impactUnverified.length > 0 ? ` confirmed + ${impactUnverified.length} unverified` : '';
789
+ if (impact.propertyAccesses) {
790
+ const pa = impact.propertyAccesses;
791
+ const uv = pa.unverifiedCount ? ` + ${pa.unverifiedCount} unverified` : '';
792
+ lines.push(`DEPENDENCY SITES: ${impact.totalDependencySites} confirmed${uv}`);
793
+ }
789
794
  if (impact.shownCallSites !== undefined && impact.shownCallSites < impact.totalCallSites) {
790
795
  lines.push(`CALL SITES: ${impact.shownCallSites} shown of ${impact.totalCallSites}${unverifiedSuffix ? ` total${unverifiedSuffix}` : ' total'}`);
791
796
  } else {
792
797
  lines.push(`CALL SITES: ${impact.totalCallSites}${unverifiedSuffix}`);
793
798
  }
794
- lines.push(` Files affected: ${impact.byFile.length}`);
799
+ lines.push(` Files affected: ${impact.affectedFiles ?? impact.byFile.length}`);
795
800
 
796
801
  // Patterns
797
802
  // BUG-1: also surface structural classification counts (inLoop / inTry /
@@ -856,6 +861,30 @@ function formatImpact(impact, options = {}) {
856
861
  }
857
862
  if (impactReach.note && !compact) lines.push(impactReach.note);
858
863
 
864
+ if (impact.propertyAccesses) {
865
+ const access = impact.propertyAccesses;
866
+ lines.push(`${compact ? '' : '\n'}PROPERTY ACCESS SITES: ${access.confirmedCount} confirmed` +
867
+ (access.unverifiedCount ? ` + ${access.unverifiedCount} unverified` : ''));
868
+ for (const group of access.byFile) {
869
+ for (const site of group.sites) {
870
+ const caller = site.callerName ? ` [${site.callerName}]` : '';
871
+ const expr = site.expression ? `: ${site.expression.replace(/\s+/g, ' ').slice(0, 100)}` : '';
872
+ lines.push(` ${group.file}:${site.line}${caller}${expr}`);
873
+ }
874
+ }
875
+ if (access.unverifiedSites.length > 0) {
876
+ lines.push(`${compact ? '' : '\n'}UNVERIFIED PROPERTY ACCESS CANDIDATES (${access.unverifiedSites.length}) — matching attribute syntax, receiver identity unresolved:`);
877
+ for (const site of access.unverifiedSites.slice(0, 10)) {
878
+ const caller = site.callerName ? ` [${site.callerName}]` : '';
879
+ const expr = site.expression ? `: ${site.expression.replace(/\s+/g, ' ').slice(0, 100)}` : '';
880
+ lines.push(` ${site.file}:${site.line}${caller}${expr}`);
881
+ }
882
+ if (access.unverifiedSites.length > 10) {
883
+ lines.push(` (+${access.unverifiedSites.length - 10} more unverified)`);
884
+ }
885
+ }
886
+ }
887
+
859
888
  // Unverified tier: visible, capped at 10 one-liners
860
889
  if (impactUnverified.length > 0) {
861
890
  lines.push(`${compact ? '' : '\n'}UNVERIFIED CALL SITES (${impactUnverified.length}) — call syntax, no binding/receiver evidence:`);
@@ -21,7 +21,7 @@ function formatImports(imports, filePath) {
21
21
  if (internal.length > 0) {
22
22
  lines.push('INTERNAL:');
23
23
  for (const imp of internal) {
24
- lines.push(` ${imp.module}`);
24
+ lines.push(` ${imp.module}${imp.deferred ? ' [function-local/deferred]' : ''}`);
25
25
  if (imp.resolved) {
26
26
  lines.push(` -> ${imp.resolved}${imp.indexed === false
27
27
  ? ' (not indexed; absent from dependency graph)'
@@ -37,7 +37,7 @@ function formatImports(imports, filePath) {
37
37
  if (internal.length > 0) lines.push('');
38
38
  lines.push('EXTERNAL:');
39
39
  for (const imp of external) {
40
- lines.push(` ${imp.module}`);
40
+ lines.push(` ${imp.module}${imp.deferred ? ' [function-local/deferred]' : ''}`);
41
41
  if (imp.names && imp.names.length > 0) {
42
42
  lines.push(` ${imp.names.join(', ')}`);
43
43
  }
@@ -48,7 +48,7 @@ function formatImports(imports, filePath) {
48
48
  if (internal.length > 0 || external.length > 0) lines.push('');
49
49
  lines.push('DYNAMIC (unresolved):');
50
50
  for (const imp of dynamic) {
51
- lines.push(` ${imp.module || '(variable)'}`);
51
+ lines.push(` ${imp.module || '(variable)'}${imp.deferred ? ' [function-local/deferred]' : ''}`);
52
52
  if (imp.names && imp.names.length > 0) {
53
53
  lines.push(` ${imp.names.join(', ')}`);
54
54
  }
@@ -77,6 +77,7 @@ function formatImportsJson(imports, filePath) {
77
77
  resolved: i.resolved || null,
78
78
  indexed: i.resolved ? i.indexed !== false : false,
79
79
  isDynamic: !!i.isDynamic,
80
+ deferred: !!i.deferred,
80
81
  line: i.line ?? null
81
82
  }))
82
83
  }, null, 2);
@@ -426,16 +427,35 @@ function formatCircularDeps(result) {
426
427
  return lines.join('\n');
427
428
  }
428
429
 
429
- for (let i = 0; i < result.cycles.length; i++) {
430
- const cycle = result.cycles[i];
430
+ const eager = result.cycles.filter(cycle => cycle.classification !== 'deferred');
431
+ const deferred = result.cycles.filter(cycle => cycle.classification === 'deferred');
432
+ let cycleNumber = 0;
433
+ const renderGroup = (title, group, deferredGroup = false) => {
434
+ if (group.length === 0) return;
431
435
  lines.push('');
432
- lines.push(`Cycle ${i + 1} (${cycle.length} files):`);
433
- lines.push(` ${cycle.files.join(' → ')} → ${cycle.files[0]}`);
434
- }
436
+ lines.push(`${title} (${group.length}):`);
437
+ for (const cycle of group) {
438
+ cycleNumber++;
439
+ lines.push('');
440
+ lines.push(`Cycle ${cycleNumber} (${cycle.length} files):`);
441
+ lines.push(` ${cycle.files.join(' → ')} → ${cycle.files[0]}`);
442
+ if (deferredGroup) {
443
+ for (const edge of cycle.deferredEdges || []) {
444
+ const at = edge.line != null ? `:${edge.line}` : '';
445
+ lines.push(` deferred edge: ${edge.from}${at} → ${edge.to} (function-local import)`);
446
+ }
447
+ }
448
+ }
449
+ };
450
+ renderGroup('IMPORT-TIME CYCLES', eager);
451
+ renderGroup('DEFERRED CYCLES', deferred, true);
435
452
 
436
453
  lines.push('');
437
454
  const { totalCycles, filesInCycles } = result.summary;
438
455
  lines.push(`Summary: ${totalCycles} circular dependency chain${totalCycles !== 1 ? 's' : ''} involving ${filesInCycles} file${filesInCycles !== 1 ? 's' : ''} (${scannedCount} files with imports scanned).`);
456
+ if (deferred.length > 0) {
457
+ lines.push(`${deferred.length} chain${deferred.length === 1 ? '' : 's'} contain a function-local import; they are not unconditional import-time cycles, but may still matter if invoked during initialization.`);
458
+ }
439
459
 
440
460
  return lines.join('\n');
441
461
  }
@@ -384,6 +384,10 @@ function formatPublicJson(command, result, params = {}, execution = {}) {
384
384
  commandMeta.pythonImplicitExportFiles = result.pythonImplicitExportFiles || 0;
385
385
  commandMeta.excludedDynamicDispatch = result.excludedDynamicDispatch || 0;
386
386
  commandMeta.computedDispatch = result.computedDispatch || { count: 0, names: [] };
387
+ commandMeta.reflection = result.reflection || {
388
+ count: 0, literalCount: 0, dynamicCount: 0,
389
+ fileCount: 0, files: [], names: [],
390
+ };
387
391
  if (result.coverage) commandMeta.coverage = result.coverage;
388
392
  if (result.limitInfo) {
389
393
  commandMeta.total = result.limitInfo.total;
@@ -70,7 +70,7 @@ function formatPlan(plan, options = {}) {
70
70
  lines.push(` Files affected: ${plan.filesAffected}`);
71
71
  if (plan.changeSummary) {
72
72
  const summary = plan.changeSummary;
73
- lines.push(` Definition ${summary.definitions}, calls/references ${summary.calls}, imports ${summary.imports}, exports ${summary.exports}; manual review required for ${summary.reviewRequired} of these changes`);
73
+ lines.push(` Definition ${summary.definitions}, calls ${summary.calls}, references ${summary.references || 0}, text dependencies ${summary.textReferences || 0}, imports ${summary.imports}, exports ${summary.exports}; manual review items ${summary.reviewRequired}`);
74
74
  }
75
75
  if (plan.unchangedSites > 0) {
76
76
  lines.push(` ${plan.unchangedSites} existing call site${plan.unchangedSites === 1 ? '' : 's'} require no edit because the new parameter has a default.`);
@@ -78,6 +78,9 @@ function formatPlan(plan, options = {}) {
78
78
  if (plan.scopeWarning) {
79
79
  lines.push(` Note: ${plan.scopeWarning.hint}`);
80
80
  }
81
+ if (plan.outsideIndexedSource) {
82
+ lines.push(` Source boundary: ${plan.outsideIndexedSource.action}`);
83
+ }
81
84
  lines.push('');
82
85
 
83
86
  // Group by file
@@ -101,6 +104,17 @@ function formatPlan(plan, options = {}) {
101
104
  }
102
105
  }
103
106
 
107
+ if (plan.reviewItems?.length > 0) {
108
+ lines.push(`\nREVIEW ITEMS (${plan.reviewItems.length}) — source text dependencies are never rewritten automatically:`);
109
+ for (const item of plan.reviewItems.slice(0, 20)) {
110
+ lines.push(` ${item.file}:${item.line}: ${item.expression.replace(/\s+/g, ' ').slice(0, 120)}`);
111
+ lines.push(` → ${item.suggestion}`);
112
+ }
113
+ if (plan.reviewItems.length > 20) {
114
+ lines.push(` (+${plan.reviewItems.length - 20} more review items)`);
115
+ }
116
+ }
117
+
104
118
  // v4 tiered contract: candidates without evidence are not planned but
105
119
  // stay visible — a rename that misses one of these breaks at runtime.
106
120
  lines.push(..._unverifiedBandLines(plan.unverifiedSites));
@@ -139,7 +153,8 @@ function formatPlanJson(plan) {
139
153
  // commands still emitting a bare result object).
140
154
  return JSON.stringify({
141
155
  meta: {
142
- complete: (plan.unverifiedCount || 0) === 0,
156
+ complete: (plan.unverifiedCount || 0) === 0 &&
157
+ (plan.changeSummary?.reviewRequired || 0) === 0,
143
158
  unverified: plan.unverifiedCount || 0,
144
159
  ...(plan.account && { account: plan.account }),
145
160
  ...(plan.warnings?.length > 0 && { warnings: plan.warnings }),
@@ -153,8 +168,12 @@ function formatPlanJson(plan) {
153
168
  before: { signature: plan.before.signature },
154
169
  after: { signature: plan.after.signature },
155
170
  totalChanges: plan.totalChanges,
171
+ totalReviewItems: plan.totalReviewItems || 0,
156
172
  filesAffected: plan.filesAffected,
157
173
  ...(plan.changeSummary && { changeSummary: plan.changeSummary }),
174
+ ...(plan.outsideIndexedSource && {
175
+ outsideIndexedSource: plan.outsideIndexedSource,
176
+ }),
158
177
  ...(plan.unchangedSites > 0 && { unchangedSites: plan.unchangedSites }),
159
178
  changes: plan.changes.map(c => ({
160
179
  file: c.file,
@@ -167,6 +186,16 @@ function formatPlanJson(plan) {
167
186
  ...(c.isImport && { isImport: true }),
168
187
  ...(c.isExport && { isExport: true }),
169
188
  ...(c.needsReview && { needsReview: true }),
189
+ ...(c.textDependency && { textDependency: true }),
190
+ })),
191
+ reviewItems: (plan.reviewItems || []).map(item => ({
192
+ file: item.file,
193
+ line: item.line,
194
+ expression: item.expression,
195
+ suggestion: item.suggestion,
196
+ editKind: item.editKind,
197
+ needsReview: true,
198
+ textDependency: true,
170
199
  })),
171
200
  // v4 tiered contract passthrough
172
201
  unverifiedCount: plan.unverifiedCount,
@@ -294,6 +294,12 @@ function formatDeadcode(results, options = {}) {
294
294
  if (results.computedDispatch?.count > 0) {
295
295
  lines.push(`\nWARNING: ${results.computedDispatch.count} computed dispatch call(s) in ${results.computedDispatch.fileCount} file(s). Dead-code results are review candidates; runtime-selected members may not have a named static edge.`);
296
296
  }
297
+ if (results.reflection?.literalCount > 0) {
298
+ lines.push(`\n${results.reflection.literalCount} literal reflection use(s) name ${results.reflection.names.length} member spelling(s); matching symbols were withheld from deletion candidates.`);
299
+ }
300
+ if (results.reflection?.dynamicCount > 0) {
301
+ lines.push(`\nWARNING: ${results.reflection.dynamicCount} dynamic reflection use(s) have no static member spelling. Dead-code results remain review candidates because runtime-selected members cannot be attributed.`);
302
+ }
297
303
  if (results.coverage?.complete === false) {
298
304
  const c = results.coverage;
299
305
  const reasonText = Object.entries(c.reasons || {})
@@ -337,6 +343,7 @@ function formatDeadcodeJson(results) {
337
343
  ...(results.pythonImplicitExportFiles > 0 && { pythonImplicitExportFiles: results.pythonImplicitExportFiles }),
338
344
  ...(results.excludedDynamicDispatch > 0 && { excludedDynamicDispatch: results.excludedDynamicDispatch }),
339
345
  ...(results.computedDispatch?.count > 0 && { computedDispatch: results.computedDispatch }),
346
+ ...(results.reflection?.count > 0 && { reflection: results.reflection }),
340
347
  ...(results.coverage?.complete === false && { coverage: results.coverage }),
341
348
  symbols: results.map(item => {
342
349
  const handleSym = { ...item, relativePath: item.relativePath || item.file };