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.
- package/.claude/skills/ucn/SKILL.md +26 -5
- package/.claude/skills/ucn/references/commands.md +5 -5
- package/README.md +42 -15
- package/core/accessors.js +183 -0
- package/core/analysis.js +46 -0
- package/core/ast-analysis.js +104 -0
- package/core/cache.js +32 -1
- package/core/callers.js +1255 -64
- package/core/command-contracts.js +13 -13
- package/core/deadcode.js +41 -2
- package/core/execute.js +4 -1
- package/core/graph-build.js +6 -0
- package/core/graph.js +72 -5
- package/core/index-ir.js +13 -2
- package/core/ir.js +5 -3
- package/core/output/analysis.js +30 -1
- package/core/output/graph.js +28 -8
- package/core/output/public.js +4 -0
- package/core/output/refactoring.js +31 -2
- package/core/output/reporting.js +7 -0
- package/core/project.js +32 -0
- package/core/search.js +9 -0
- package/core/verify.js +1168 -38
- package/languages/c-family.js +239 -26
- package/languages/csharp.js +40 -4
- package/languages/go.js +473 -71
- package/languages/javascript.js +86 -4
- package/languages/python.js +392 -101
- package/languages/rust.js +87 -4
- package/languages/utils.js +11 -0
- package/mcp/server.js +99 -103
- package/mcp/stdio-server.js +296 -0
- package/package.json +10 -8
|
@@ -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
|
|
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,
|
|
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
|
|
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(
|
|
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
|
package/core/graph-build.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
+
['field', 'property'].includes(inherited.memberType)
|
|
112
|
+
? 'state' : 'callable',
|
|
111
113
|
inherited.memberType || (inherited.isConstructor ? 'constructor' : 'method'),
|
|
112
114
|
type.name);
|
|
113
115
|
}
|
package/core/output/analysis.js
CHANGED
|
@@ -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:`);
|
package/core/output/graph.js
CHANGED
|
@@ -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
|
-
|
|
430
|
-
|
|
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(
|
|
433
|
-
|
|
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
|
}
|
package/core/output/public.js
CHANGED
|
@@ -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
|
|
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,
|
package/core/output/reporting.js
CHANGED
|
@@ -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 };
|