ucn 5.3.0 → 5.3.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 +10 -0
- package/core/analysis.js +178 -14
- package/core/callers.js +1 -1
- package/core/check.js +2 -2
- package/core/output/analysis-ext.js +4 -0
- package/core/output/analysis.js +30 -2
- package/core/output/endpoints.js +1 -1
- package/core/output/lines.js +13 -0
- package/core/output/tracing.js +10 -2
- package/languages/python.js +52 -27
- package/package.json +1 -1
|
@@ -69,6 +69,16 @@ identity; matching attribute syntax with an unresolved receiver stays
|
|
|
69
69
|
unverified. These are change dependencies, not fabricated caller edges, so
|
|
70
70
|
the caller `ACCOUNT` remains a call-shaped partition.
|
|
71
71
|
|
|
72
|
+
When the selected definition is a type, interface, enum, trait, or record,
|
|
73
|
+
`impact` adds a `TYPE REFERENCE SITES` band: annotation and reference sites
|
|
74
|
+
confirmed by an import link to the definition's file (or package scope in
|
|
75
|
+
Go/Java), the rest visible as unverified with a reason. `DEPENDENCY SITES`
|
|
76
|
+
counts them; `CALL SITES` stays call-shaped.
|
|
77
|
+
|
|
78
|
+
Target-less `impact` and `check` diff the working tree against `HEAD` AND
|
|
79
|
+
include untracked, non-ignored source files as whole-file additions, so new
|
|
80
|
+
modules are checked before `git add`. `--staged` keeps its index-only meaning.
|
|
81
|
+
|
|
72
82
|
An observed-text zero is not semantic zero or safe-delete proof. Numeric evidence values are ordinal ranking weights, not probabilities.
|
|
73
83
|
|
|
74
84
|
When a plain name selects more than one definition, action-oriented commands
|
package/core/analysis.js
CHANGED
|
@@ -980,6 +980,113 @@ function related(index, name, options = {}) {
|
|
|
980
980
|
* @param {object} options - { file, className, exclude, top }
|
|
981
981
|
* @returns {object|null}
|
|
982
982
|
*/
|
|
983
|
+
// Kinds whose dependents are annotation/reference sites rather than calls.
|
|
984
|
+
// Classes and structs stay out: `new X()` / `X{}` are call-shaped and already
|
|
985
|
+
// flow through the caller sweep.
|
|
986
|
+
const TYPE_REFERENCE_KINDS = new Set(['type', 'interface', 'enum', 'trait', 'record']);
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* fix #345: tiered annotation-site band for a type-kind definition.
|
|
990
|
+
* Confirmed needs identity evidence: same file as the definition, or an
|
|
991
|
+
* import binding of the name in the referencing file that reaches the
|
|
992
|
+
* definition's file (the #215/#217 scope discipline). Anything else is
|
|
993
|
+
* VISIBLE unverified with a reason. A same-name definition elsewhere is
|
|
994
|
+
* excluded as other-target. Spelling alone never confirms.
|
|
995
|
+
*/
|
|
996
|
+
function findTypeReferences(index, name, def, options = {}) {
|
|
997
|
+
if (!def || !TYPE_REFERENCE_KINDS.has(def.type)) return null;
|
|
998
|
+
const { usages } = require('./search');
|
|
999
|
+
const { _importReaches, _sameNominalPackageDir } = require('./callers');
|
|
1000
|
+
const records = usages(index, name, {
|
|
1001
|
+
includeTests: true, codeOnly: true, exclude: options.exclude,
|
|
1002
|
+
});
|
|
1003
|
+
const targetFiles = new Set([def.file]);
|
|
1004
|
+
const sameNameDefs = (index.symbols.get(name) || []).filter(d => d !== def);
|
|
1005
|
+
const confirmed = [];
|
|
1006
|
+
const unverified = [];
|
|
1007
|
+
const excluded = [];
|
|
1008
|
+
for (const u of (Array.isArray(records) ? records : records?.usages || [])) {
|
|
1009
|
+
if (u.isDefinition || u.usageType !== 'reference') continue;
|
|
1010
|
+
const site = {
|
|
1011
|
+
file: u.relativePath, line: u.line,
|
|
1012
|
+
expression: (u.content || '').trim(),
|
|
1013
|
+
};
|
|
1014
|
+
// A same-name type defined in the referencing file owns that file's
|
|
1015
|
+
// bare references (the #215 scope rule): excluded, never confirmed.
|
|
1016
|
+
if (u.file !== def.file && sameNameDefs.some(d => d.file === u.file && TYPE_REFERENCE_KINDS.has(d.type))) {
|
|
1017
|
+
excluded.push({ ...site, reason: 'other-definition' });
|
|
1018
|
+
continue;
|
|
1019
|
+
}
|
|
1020
|
+
if (sameNameDefs.some(d => d.file === u.file && (d.nameLine || d.startLine) === u.line)) {
|
|
1021
|
+
excluded.push({ ...site, reason: 'other-definition' });
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
if (u.file === def.file) {
|
|
1025
|
+
confirmed.push({ ...site, evidence: 'same-file' });
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
1028
|
+
const fileEntry = index.files.get(u.file);
|
|
1029
|
+
// Directory-scoped packages (Go) and Java packages see sibling files'
|
|
1030
|
+
// types without an import; a same-name def in another package would
|
|
1031
|
+
// have been excluded above only if it shared the line, so require the
|
|
1032
|
+
// pinned def to be the package's own.
|
|
1033
|
+
const packageScoped = fileEntry && (
|
|
1034
|
+
(langTraits(fileEntry.language).packageScope === 'directory' &&
|
|
1035
|
+
path.dirname(u.file) === path.dirname(def.file)) ||
|
|
1036
|
+
(fileEntry.language === 'java' &&
|
|
1037
|
+
_sameNominalPackageDir(path.dirname(def.file), path.dirname(u.file), 'java')));
|
|
1038
|
+
if (packageScoped) {
|
|
1039
|
+
const foreign = sameNameDefs.some(d => TYPE_REFERENCE_KINDS.has(d.type) &&
|
|
1040
|
+
path.dirname(d.file) === path.dirname(u.file));
|
|
1041
|
+
if (foreign) unverified.push({ ...site, reason: 'same-package-ambiguous' });
|
|
1042
|
+
else confirmed.push({ ...site, evidence: 'package-scope' });
|
|
1043
|
+
continue;
|
|
1044
|
+
}
|
|
1045
|
+
const bindings = (fileEntry?.importBindings || []).filter(b =>
|
|
1046
|
+
b.name === name || b.alias === name);
|
|
1047
|
+
if (bindings.length > 0) {
|
|
1048
|
+
const reaches = bindings.some(b => {
|
|
1049
|
+
const rel = fileEntry.moduleResolved && fileEntry.moduleResolved[b.module];
|
|
1050
|
+
return rel && _importReaches(index, path.join(index.root, rel), targetFiles);
|
|
1051
|
+
});
|
|
1052
|
+
if (reaches) { confirmed.push({ ...site, evidence: 'import' }); continue; }
|
|
1053
|
+
const otherProject = bindings.some(b =>
|
|
1054
|
+
fileEntry.moduleResolved && fileEntry.moduleResolved[b.module]);
|
|
1055
|
+
if (otherProject) { excluded.push({ ...site, reason: 'other-definition-import' }); continue; }
|
|
1056
|
+
unverified.push({ ...site, reason: 'import-unresolved' });
|
|
1057
|
+
continue;
|
|
1058
|
+
}
|
|
1059
|
+
if (fileEntry?.importNames?.includes('*')) {
|
|
1060
|
+
unverified.push({ ...site, reason: 'star-import' });
|
|
1061
|
+
continue;
|
|
1062
|
+
}
|
|
1063
|
+
unverified.push({ ...site, reason: 'no-import-link' });
|
|
1064
|
+
}
|
|
1065
|
+
if (confirmed.length === 0 && unverified.length === 0 && excluded.length === 0) {
|
|
1066
|
+
return { owner: def.type, confirmedCount: 0, unverifiedCount: 0, totalCandidates: 0,
|
|
1067
|
+
byFile: [], unverifiedSites: [], excluded: { total: 0, byReason: {} } };
|
|
1068
|
+
}
|
|
1069
|
+
const bySite = (a, b) => a.file !== b.file ? codeUnitCompare(a.file, b.file) : a.line - b.line;
|
|
1070
|
+
confirmed.sort(bySite); unverified.sort(bySite);
|
|
1071
|
+
const byFile = new Map();
|
|
1072
|
+
for (const site of confirmed) {
|
|
1073
|
+
if (!byFile.has(site.file)) byFile.set(site.file, []);
|
|
1074
|
+
byFile.get(site.file).push(site);
|
|
1075
|
+
}
|
|
1076
|
+
return {
|
|
1077
|
+
owner: def.type,
|
|
1078
|
+
confirmedCount: confirmed.length,
|
|
1079
|
+
unverifiedCount: unverified.length,
|
|
1080
|
+
totalCandidates: confirmed.length + unverified.length,
|
|
1081
|
+
byFile: [...byFile.entries()].map(([file, sites]) => ({ file, count: sites.length, sites })),
|
|
1082
|
+
unverifiedSites: unverified,
|
|
1083
|
+
excluded: {
|
|
1084
|
+
total: excluded.length,
|
|
1085
|
+
byReason: excluded.reduce((out, s) => { out[s.reason] = (out[s.reason] || 0) + 1; return out; }, {}),
|
|
1086
|
+
},
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
|
|
983
1090
|
function impact(index, name, options = {}) {
|
|
984
1091
|
index._beginOp();
|
|
985
1092
|
try {
|
|
@@ -1270,6 +1377,13 @@ function impact(index, name, options = {}) {
|
|
|
1270
1377
|
};
|
|
1271
1378
|
}
|
|
1272
1379
|
|
|
1380
|
+
// fix #345: a type/interface/enum/trait is consumed through annotations,
|
|
1381
|
+
// not calls. Those sites were counted in the ACCOUNT as references and
|
|
1382
|
+
// listed nowhere, so the headline said 0 for a type with dozens of
|
|
1383
|
+
// dependents. Same design as the accessor band: a separate band, never
|
|
1384
|
+
// fake caller edges (the caller oracle and the account stay call-shaped).
|
|
1385
|
+
let typeReferences = findTypeReferences(index, name, def, options);
|
|
1386
|
+
|
|
1273
1387
|
// Apply top limit if specified (limits total call sites shown)
|
|
1274
1388
|
const totalBeforeLimit = filteredSites.length;
|
|
1275
1389
|
if (options.top && options.top > 0 && filteredSites.length > options.top) {
|
|
@@ -1315,6 +1429,8 @@ function impact(index, name, options = {}) {
|
|
|
1315
1429
|
...Array.from(byFile.keys()),
|
|
1316
1430
|
...(propertyAccesses?.byFile || []).map(group => group.file),
|
|
1317
1431
|
...(propertyAccesses?.unverifiedSites || []).map(site => site.file),
|
|
1432
|
+
...(typeReferences?.byFile || []).map(group => group.file),
|
|
1433
|
+
...(typeReferences?.unverifiedSites || []).map(site => site.file),
|
|
1318
1434
|
]);
|
|
1319
1435
|
|
|
1320
1436
|
return {
|
|
@@ -1332,6 +1448,11 @@ function impact(index, name, options = {}) {
|
|
|
1332
1448
|
totalDependencySites: totalBeforeLimit + propertyAccesses.confirmedCount,
|
|
1333
1449
|
affectedFiles: affectedFiles.size,
|
|
1334
1450
|
}),
|
|
1451
|
+
...(typeReferences && {
|
|
1452
|
+
typeReferences,
|
|
1453
|
+
totalDependencySites: totalBeforeLimit + typeReferences.confirmedCount,
|
|
1454
|
+
affectedFiles: affectedFiles.size,
|
|
1455
|
+
}),
|
|
1335
1456
|
account: impactAccount,
|
|
1336
1457
|
hasEntrypoints: !!impactReachable && impactReachable.size > 0,
|
|
1337
1458
|
callerHistogram,
|
|
@@ -1876,19 +1997,6 @@ function diffImpact(index, options = {}) {
|
|
|
1876
1997
|
}
|
|
1877
1998
|
}
|
|
1878
1999
|
|
|
1879
|
-
if (!diffText || !diffText.trim()) {
|
|
1880
|
-
return {
|
|
1881
|
-
base: staged ? '(staged)' : base,
|
|
1882
|
-
changedPaths: 0,
|
|
1883
|
-
nonSourcePaths: 0,
|
|
1884
|
-
functions: [],
|
|
1885
|
-
moduleLevelChanges: [],
|
|
1886
|
-
newFunctions: [],
|
|
1887
|
-
deletedFunctions: [],
|
|
1888
|
-
summary: { modifiedFunctions: 0, deletedFunctions: 0, newFunctions: 0, totalCallSites: 0, unverifiedCallSites: 0, affectedFiles: 0 }
|
|
1889
|
-
};
|
|
1890
|
-
}
|
|
1891
|
-
|
|
1892
2000
|
// Diff paths are git-root-relative. Resolve to index.root for file lookup.
|
|
1893
2001
|
// Normalize both through realpath to handle macOS /var → /private/var symlinks.
|
|
1894
2002
|
let realGitRoot, realProjectRoot;
|
|
@@ -1908,6 +2016,58 @@ function diffImpact(index, options = {}) {
|
|
|
1908
2016
|
changes.push({ ...c, gitRelativePath: c.relativePath, filePath: path.join(index.root, localRel), relativePath: localRel });
|
|
1909
2017
|
}
|
|
1910
2018
|
|
|
2019
|
+
// fix #346: untracked files are new work too. `git diff <base>` only
|
|
2020
|
+
// sees tracked paths, so a session's brand-new modules were invisible to
|
|
2021
|
+
// the pre-commit gate until `git add -N` — a silent pass on exactly the
|
|
2022
|
+
// code that has never been checked. Indexed, gitignore-respecting
|
|
2023
|
+
// untracked source files join the working-tree diff as whole-file
|
|
2024
|
+
// additions (staged mode keeps its index-only meaning).
|
|
2025
|
+
let untrackedPaths = 0;
|
|
2026
|
+
if (!staged) {
|
|
2027
|
+
const lsArgs = ['ls-files', '--others', '--exclude-standard', '-z'];
|
|
2028
|
+
if (file) lsArgs.push('--', file);
|
|
2029
|
+
let untrackedText = '';
|
|
2030
|
+
try {
|
|
2031
|
+
untrackedText = execFileSync('git', lsArgs, {
|
|
2032
|
+
cwd: index.root, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024,
|
|
2033
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
2034
|
+
});
|
|
2035
|
+
} catch (_) { untrackedText = ''; }
|
|
2036
|
+
const known = new Set(changes.map(c => c.relativePath));
|
|
2037
|
+
for (const localRel of untrackedText.split('\0').filter(Boolean).sort(codeUnitCompare)) {
|
|
2038
|
+
if (known.has(localRel)) continue;
|
|
2039
|
+
const filePath = path.join(index.root, localRel);
|
|
2040
|
+
const fileEntry = index.files.get(filePath);
|
|
2041
|
+
if (!fileEntry || !detectLanguage(filePath)) continue;
|
|
2042
|
+
let lineCount = fileEntry.lines;
|
|
2043
|
+
if (!Number.isFinite(lineCount)) {
|
|
2044
|
+
try { lineCount = fs.readFileSync(filePath, 'utf-8').split('\n').length; } catch (_) { continue; }
|
|
2045
|
+
}
|
|
2046
|
+
const addedLines = [];
|
|
2047
|
+
for (let i = 1; i <= lineCount; i++) addedLines.push(i);
|
|
2048
|
+
untrackedPaths++;
|
|
2049
|
+
changes.push({
|
|
2050
|
+
filePath, relativePath: localRel,
|
|
2051
|
+
gitRelativePath: projectPrefix ? `${projectPrefix}/${localRel}` : localRel,
|
|
2052
|
+
addedLines, deletedLines: [], untracked: true,
|
|
2053
|
+
});
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
if (changes.length === 0) {
|
|
2058
|
+
return {
|
|
2059
|
+
base: staged ? '(staged)' : base,
|
|
2060
|
+
changedPaths: 0,
|
|
2061
|
+
nonSourcePaths: 0,
|
|
2062
|
+
untrackedPaths: 0,
|
|
2063
|
+
functions: [],
|
|
2064
|
+
moduleLevelChanges: [],
|
|
2065
|
+
newFunctions: [],
|
|
2066
|
+
deletedFunctions: [],
|
|
2067
|
+
summary: { modifiedFunctions: 0, deletedFunctions: 0, newFunctions: 0, totalCallSites: 0, unverifiedCallSites: 0, affectedFiles: 0 }
|
|
2068
|
+
};
|
|
2069
|
+
}
|
|
2070
|
+
|
|
1911
2071
|
const functions = [];
|
|
1912
2072
|
const moduleLevelChanges = [];
|
|
1913
2073
|
const newFunctions = [];
|
|
@@ -2111,7 +2271,10 @@ function diffImpact(index, options = {}) {
|
|
|
2111
2271
|
const { symbol, addedLines } = data;
|
|
2112
2272
|
const identityKey = `${symbol.name}\0${symbol.className || ''}`;
|
|
2113
2273
|
let isNew;
|
|
2114
|
-
if (
|
|
2274
|
+
if (change.untracked) {
|
|
2275
|
+
// fix #346: nothing in an untracked file existed at the base.
|
|
2276
|
+
isNew = true;
|
|
2277
|
+
} else if (oldSymbolIdentities !== null) {
|
|
2115
2278
|
isNew = !oldSymbolIdentities.has(identityKey);
|
|
2116
2279
|
} else {
|
|
2117
2280
|
// Fallback: 80% of body lines added and no deletions hit this symbol.
|
|
@@ -2291,6 +2454,7 @@ function diffImpact(index, options = {}) {
|
|
|
2291
2454
|
base: staged ? '(staged)' : base,
|
|
2292
2455
|
changedPaths: changes.length,
|
|
2293
2456
|
nonSourcePaths,
|
|
2457
|
+
untrackedPaths,
|
|
2294
2458
|
functions,
|
|
2295
2459
|
moduleLevelChanges,
|
|
2296
2460
|
newFunctions,
|
package/core/callers.js
CHANGED
|
@@ -16128,4 +16128,4 @@ function findCallbackUsages(index, name) {
|
|
|
16128
16128
|
return usages;
|
|
16129
16129
|
}
|
|
16130
16130
|
|
|
16131
|
-
module.exports = { _unresolvedModuleIsGap, getCachedCalls, findCallers, findCallees, getInstanceAttributeTypes, findCallbackUsages, _nameBindingReaches, _moduleAttributeBindingReaches, _declaredFieldType, _projectTopLevelNames, _callArityCompatible, _closeCallableIdentityGroup, _overloadDiscipline, _overloadApplicable };
|
|
16131
|
+
module.exports = { _unresolvedModuleIsGap, _importReaches, _sameNominalPackageDir, getCachedCalls, findCallers, findCallees, getInstanceAttributeTypes, findCallbackUsages, _nameBindingReaches, _moduleAttributeBindingReaches, _declaredFieldType, _projectTopLevelNames, _callArityCompatible, _closeCallableIdentityGroup, _overloadDiscipline, _overloadApplicable };
|
package/core/check.js
CHANGED
|
@@ -88,9 +88,9 @@ function check(index, options = {}) {
|
|
|
88
88
|
const nonSourcePaths = dr?.nonSourcePaths || 0;
|
|
89
89
|
let reason = 'no changes detected';
|
|
90
90
|
if (changedPaths > 0 && nonSourcePaths === changedPaths) {
|
|
91
|
-
reason = `${changedPaths} changed path(s), all outside supported source files`;
|
|
91
|
+
reason = `${changedPaths} changed path(s), all outside supported source files; untracked source files are included`;
|
|
92
92
|
} else if (changedPaths > 0) {
|
|
93
|
-
reason = 'no callable-symbol changes in the diff';
|
|
93
|
+
reason = 'no callable-symbol changes in the diff or untracked source files';
|
|
94
94
|
}
|
|
95
95
|
return {
|
|
96
96
|
base: options.base || 'HEAD',
|
|
@@ -215,6 +215,10 @@ function formatDiffImpact(result, options = {}) {
|
|
|
215
215
|
if (result.nonSourcePaths > 0) {
|
|
216
216
|
lines.push(`Note: ${result.nonSourcePaths} changed path(s) outside supported source files not analyzed.`);
|
|
217
217
|
}
|
|
218
|
+
// fix #346: untracked source files join the working-tree diff.
|
|
219
|
+
if (result.untrackedPaths > 0) {
|
|
220
|
+
lines.push(`Note: ${result.untrackedPaths} untracked source file(s) included as whole-file additions.`);
|
|
221
|
+
}
|
|
218
222
|
lines.push('');
|
|
219
223
|
|
|
220
224
|
// Modified functions
|
package/core/output/analysis.js
CHANGED
|
@@ -786,8 +786,8 @@ 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;
|
|
789
|
+
if (impact.propertyAccesses || impact.typeReferences) {
|
|
790
|
+
const pa = impact.propertyAccesses || impact.typeReferences;
|
|
791
791
|
const uv = pa.unverifiedCount ? ` + ${pa.unverifiedCount} unverified` : '';
|
|
792
792
|
lines.push(`DEPENDENCY SITES: ${impact.totalDependencySites} confirmed${uv}`);
|
|
793
793
|
}
|
|
@@ -885,6 +885,34 @@ function formatImpact(impact, options = {}) {
|
|
|
885
885
|
}
|
|
886
886
|
}
|
|
887
887
|
|
|
888
|
+
// fix #345: annotation sites of a type-kind definition, tiered like the
|
|
889
|
+
// accessor band. The headline no longer says 0 for a type with dependents.
|
|
890
|
+
if (impact.typeReferences) {
|
|
891
|
+
const refs = impact.typeReferences;
|
|
892
|
+
lines.push(`${compact ? '' : '\n'}TYPE REFERENCE SITES: ${refs.confirmedCount} confirmed` +
|
|
893
|
+
(refs.unverifiedCount ? ` + ${refs.unverifiedCount} unverified` : '') +
|
|
894
|
+
(refs.excluded?.total ? ` (${refs.excluded.total} other-target)` : ''));
|
|
895
|
+
for (const group of refs.byFile) {
|
|
896
|
+
for (const site of group.sites) {
|
|
897
|
+
const expr = site.expression ? `: ${site.expression.replace(/\s+/g, ' ').slice(0, 100)}` : '';
|
|
898
|
+
lines.push(` ${group.file}:${site.line}${expr}`);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
if (refs.unverifiedSites.length > 0) {
|
|
902
|
+
lines.push(`${compact ? '' : '\n'}UNVERIFIED TYPE REFERENCE CANDIDATES (${refs.unverifiedSites.length}) — name matches, no import link to this definition:`);
|
|
903
|
+
for (const site of refs.unverifiedSites.slice(0, 10)) {
|
|
904
|
+
const expr = site.expression ? `: ${site.expression.replace(/\s+/g, ' ').slice(0, 100)}` : '';
|
|
905
|
+
lines.push(` ${site.file}:${site.line}${expr} (${site.reason})`);
|
|
906
|
+
}
|
|
907
|
+
if (refs.unverifiedSites.length > 10) {
|
|
908
|
+
lines.push(` (+${refs.unverifiedSites.length - 10} more unverified)`);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
if (refs.confirmedCount === 0 && refs.unverifiedCount === 0) {
|
|
912
|
+
lines.push(' (no annotation sites outside the definition)');
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
888
916
|
// Unverified tier: visible, capped at 10 one-liners
|
|
889
917
|
if (impactUnverified.length > 0) {
|
|
890
918
|
lines.push(`${compact ? '' : '\n'}UNVERIFIED CALL SITES (${impactUnverified.length}) — call syntax, no binding/receiver evidence:`);
|
package/core/output/endpoints.js
CHANGED
|
@@ -84,7 +84,7 @@ function formatRoutesAndRequests(routes, requests, meta, options, advisory = nul
|
|
|
84
84
|
|
|
85
85
|
if (showClient) {
|
|
86
86
|
if (requests.length === 0) {
|
|
87
|
-
if (showServer) lines.push('
|
|
87
|
+
if (showServer) lines.push('Client Requests: 0 — no static route literal found in any indexed file (wrapped or dynamically built request paths are invisible to this scan).');
|
|
88
88
|
} else {
|
|
89
89
|
if (showServer) lines.push('');
|
|
90
90
|
lines.push(`Client Requests: ${requests.length}`);
|
package/core/output/lines.js
CHANGED
|
@@ -184,6 +184,7 @@ function impactRecords(result) {
|
|
|
184
184
|
const summary = result.summary || {};
|
|
185
185
|
notes.push(`# Diff: ${summary.modifiedFunctions || 0} modified, ${summary.newFunctions || 0} new, ${summary.deletedFunctions || 0} deleted functions; ${(result.moduleLevelChanges || []).length} file(s) with module-level changes.`);
|
|
186
186
|
if (result.nonSourcePaths) notes.push(`# ${result.nonSourcePaths} changed path(s) outside supported source files not analyzed.`);
|
|
187
|
+
if (result.untrackedPaths) notes.push(`# ${result.untrackedPaths} untracked source file(s) included as whole-file additions.`);
|
|
187
188
|
return { records: [...new Set(out)], notes };
|
|
188
189
|
}
|
|
189
190
|
for (const group of result.byFile || []) {
|
|
@@ -207,6 +208,18 @@ function impactRecords(result) {
|
|
|
207
208
|
}
|
|
208
209
|
notes.push(`# PROPERTY ACCESS SITES: ${accesses.confirmedCount} confirmed, ${accesses.unverifiedCount} unverified, ${accesses.excluded?.total || 0} other-target (separate from caller ACCOUNT).`);
|
|
209
210
|
}
|
|
211
|
+
if (result.typeReferences) {
|
|
212
|
+
const refs = result.typeReferences;
|
|
213
|
+
for (const group of refs.byFile || []) {
|
|
214
|
+
for (const site of group.sites || []) {
|
|
215
|
+
out.push(record(group.file, site.line, site.expression, 'type-reference'));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
for (const site of refs.unverifiedSites || []) {
|
|
219
|
+
out.push(record(pathOf(site), site.line, site.expression, `unverified: ${site.reason}; type-reference`));
|
|
220
|
+
}
|
|
221
|
+
notes.push(`# TYPE REFERENCE SITES: ${refs.confirmedCount} confirmed, ${refs.unverifiedCount} unverified, ${refs.excluded?.total || 0} other-target (separate from caller ACCOUNT).`);
|
|
222
|
+
}
|
|
210
223
|
notes.push(...accountComments(result.account));
|
|
211
224
|
for (const warning of result.warnings || []) notes.push(...commentLines(warning.message));
|
|
212
225
|
if (result.scopeWarning?.hint) notes.push(...commentLines(result.scopeWarning.hint));
|
package/core/output/tracing.js
CHANGED
|
@@ -532,6 +532,14 @@ function formatReverseTraceJson(result) {
|
|
|
532
532
|
/**
|
|
533
533
|
* Format affected-tests command output - text
|
|
534
534
|
*/
|
|
535
|
+
// fix #347: a hub symbol at depth 2 links hundreds of names per test file;
|
|
536
|
+
// the list is the answer only for leaves. Cap at 8 unless --all.
|
|
537
|
+
function linkList(names, options) {
|
|
538
|
+
const MAX_LINKS = options?.all ? Infinity : 8;
|
|
539
|
+
if (!Array.isArray(names) || names.length <= MAX_LINKS) return (names || []).join(', ');
|
|
540
|
+
return `${names.slice(0, MAX_LINKS).join(', ')}, +${names.length - MAX_LINKS} more`;
|
|
541
|
+
}
|
|
542
|
+
|
|
535
543
|
function formatAffectedTests(result, options = {}) {
|
|
536
544
|
if (!result) return 'Function not found.';
|
|
537
545
|
|
|
@@ -553,7 +561,7 @@ function formatAffectedTests(result, options = {}) {
|
|
|
553
561
|
lines.push(`Test files to run (${summary.totalTestFiles}):`);
|
|
554
562
|
lines.push('');
|
|
555
563
|
for (const tf of displayFiles) {
|
|
556
|
-
lines.push(` ${tf.file} (links: ${tf.linkedFunctions
|
|
564
|
+
lines.push(` ${tf.file} (links: ${linkList(tf.linkedFunctions, options)})`);
|
|
557
565
|
// Show up to 5 key matches per file
|
|
558
566
|
const keyMatches = tf.matches
|
|
559
567
|
.filter(m => m.matchType === 'call' || m.matchType === 'test-case')
|
|
@@ -581,7 +589,7 @@ function formatAffectedTests(result, options = {}) {
|
|
|
581
589
|
lines.push(` Additional test files (${pat.length}):`);
|
|
582
590
|
const MAX_POSSIBLE = options.all ? Infinity : 10;
|
|
583
591
|
for (const tf of pat.slice(0, MAX_POSSIBLE)) {
|
|
584
|
-
lines.push(` ${tf.file} (links: ${tf.linkedFunctions
|
|
592
|
+
lines.push(` ${tf.file} (links: ${linkList(tf.linkedFunctions, options)})`);
|
|
585
593
|
}
|
|
586
594
|
if (pat.length > MAX_POSSIBLE) {
|
|
587
595
|
lines.push(` ... ${pat.length - MAX_POSSIBLE} more (${options.allHint || 'use --all'})`);
|
package/languages/python.js
CHANGED
|
@@ -1317,34 +1317,59 @@ function pythonTargetBindsName(left, name) {
|
|
|
1317
1317
|
return false;
|
|
1318
1318
|
}
|
|
1319
1319
|
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1320
|
+
// One walk per scope body collects every name it binds at this scope level
|
|
1321
|
+
// (declarations, assignment targets, for/with targets; nested def/class bodies
|
|
1322
|
+
// are separate scopes, lambdas too). Memoized per tree by native node id: the
|
|
1323
|
+
// caller loop asks the same body about many names, and a per-name walk made
|
|
1324
|
+
// the Python build cost functions x tracked names x body size (measured 10s of
|
|
1325
|
+
// a 60s sequential build on a 20MB Python repo).
|
|
1326
|
+
const scopeBoundNamesByTree = new WeakMap();
|
|
1327
|
+
function pythonScopeBoundNames(scopeNode) {
|
|
1328
|
+
let byId = scopeBoundNamesByTree.get(scopeNode.tree);
|
|
1329
|
+
if (!byId) { byId = new Map(); scopeBoundNamesByTree.set(scopeNode.tree, byId); }
|
|
1330
|
+
const cached = byId.get(scopeNode.id);
|
|
1331
|
+
if (cached) return cached;
|
|
1332
|
+
const names = new Set();
|
|
1333
|
+
const addTarget = (left) => {
|
|
1334
|
+
if (!left) return;
|
|
1335
|
+
if (left.type === 'identifier') names.add(left.text);
|
|
1336
|
+
else if (left.type === 'pattern_list' || left.type === 'tuple_pattern') {
|
|
1337
|
+
for (const item of left.namedChildren) {
|
|
1338
|
+
if (item.type === 'identifier') names.add(item.text);
|
|
1339
|
+
}
|
|
1330
1340
|
}
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
child.type === '
|
|
1335
|
-
|
|
1336
|
-
child.
|
|
1337
|
-
name)
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1341
|
+
};
|
|
1342
|
+
const walk = (node) => {
|
|
1343
|
+
for (const child of node.namedChildren) {
|
|
1344
|
+
if (child.type === 'function_definition' ||
|
|
1345
|
+
child.type === 'async_function_definition' ||
|
|
1346
|
+
child.type === 'class_definition') {
|
|
1347
|
+
const declared = child.childForFieldName('name')?.text;
|
|
1348
|
+
if (declared) names.add(declared);
|
|
1349
|
+
continue;
|
|
1350
|
+
}
|
|
1351
|
+
if (child.type === 'lambda') continue;
|
|
1352
|
+
if (child.type === 'assignment' ||
|
|
1353
|
+
child.type === 'augmented_assignment' ||
|
|
1354
|
+
child.type === 'named_expression') {
|
|
1355
|
+
addTarget(child.childForFieldName('left') || child.childForFieldName('name'));
|
|
1356
|
+
} else if (child.type === 'for_statement') {
|
|
1357
|
+
addTarget(child.childForFieldName('left'));
|
|
1358
|
+
} else if (child.type === 'with_statement') {
|
|
1359
|
+
const text = child.namedChild(0)?.text || '';
|
|
1360
|
+
const match = text.match(/\bas\s+([A-Za-z_][A-Za-z0-9_]*)/);
|
|
1361
|
+
if (match) names.add(match[1]);
|
|
1362
|
+
}
|
|
1363
|
+
walk(child);
|
|
1364
|
+
}
|
|
1365
|
+
};
|
|
1366
|
+
walk(scopeNode);
|
|
1367
|
+
byId.set(scopeNode.id, names);
|
|
1368
|
+
return names;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
function pythonScopeBindsName(scopeNode, name) {
|
|
1372
|
+
return pythonScopeBoundNames(scopeNode).has(name);
|
|
1348
1373
|
}
|
|
1349
1374
|
|
|
1350
1375
|
const PY_COMPREHENSIONS = new Set([
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ucn",
|
|
3
|
-
"version": "5.3.
|
|
3
|
+
"version": "5.3.1",
|
|
4
4
|
"mcpName": "io.github.mleoca/ucn",
|
|
5
5
|
"description": "Auditable AST code intelligence for AI agents: 18 task-oriented commands through one MCP tool, CLI, or agent skill. Supports JS/TS, Python, Go, Rust, Java, C, C++, C#, and HTML.",
|
|
6
6
|
"main": "index.js",
|