ucn 5.3.0 → 5.3.2
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 +21 -3
- package/core/account.js +4 -4
- package/core/analysis.js +178 -14
- package/core/bridge.js +89 -2
- package/core/cache.js +3 -1
- package/core/callers.js +4 -2
- package/core/check.js +2 -2
- package/core/execute.js +1 -0
- package/core/output/analysis-ext.js +4 -0
- package/core/output/analysis.js +30 -2
- package/core/output/endpoints.js +24 -3
- package/core/output/lines.js +13 -0
- package/core/output/tracing.js +10 -2
- package/core/project.js +21 -2
- package/core/search.js +11 -9
- package/core/shared.js +13 -1
- package/languages/python.js +55 -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
|
|
@@ -147,9 +157,17 @@ definition, and `--raw` when the next step is an edit.
|
|
|
147
157
|
|
|
148
158
|
Treat `deadcode` as a candidate generator. Before deletion, inspect `usages`, `impact`, `entrypoints`, `api`, and `repo --sections=health --deep`; then corroborate with the compiler/type checker and tests. Computed dispatch such as `handlers[key]()` is a reported blind spot; registry members reached by a modeled computed receiver are withheld. Statically named reflection such as `getattr(obj, "run")` is positive liveness evidence, so every matching member spelling is withheld; recognized dynamic reflection is counted and warned because it cannot be attributed to one member. Unknown decorators/annotations and member-assigned event handlers are also withheld by default because they can be the registration itself. All remaining candidates are still review-only. Never delete solely from `deadcode` or an observed-text-zero result.
|
|
149
159
|
|
|
150
|
-
`usages` includes comment/string/docstring occurrences
|
|
151
|
-
|
|
152
|
-
|
|
160
|
+
`usages` includes comment/string/docstring occurrences and non-code text
|
|
161
|
+
(JSX children, HTML markup and attributes) in an `OTHER TEXT` section unless
|
|
162
|
+
`--code-only` is set, so it lists every line the `ACCOUNT` counts. This is a
|
|
163
|
+
literal-name inventory, not exact target binding. Identifier boundaries are
|
|
164
|
+
Unicode-aware: `hit` never matches inside `hitΔ`.
|
|
165
|
+
|
|
166
|
+
`endpoints` recognizes client receivers by evidence (a receiver typed to an
|
|
167
|
+
HTTP client class, or bound to a pytest fixture that constructs one), not only
|
|
168
|
+
by name. Request-shaped calls with a path literal on an unrecognized receiver
|
|
169
|
+
are listed under `Possible client requests` (JSON `uncertainRequests`),
|
|
170
|
+
counted in `meta`, never in the inventory. `search` treats its term literally by default; pass `--regex` only
|
|
153
171
|
when regular-expression semantics are intended. Ordinary regex patterns run
|
|
154
172
|
through an RE2-compatible linear-time engine; unsafe nested repetition is
|
|
155
173
|
rejected, and unsupported advanced syntax should be handed to ripgrep.
|
package/core/account.js
CHANGED
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
|
|
53
53
|
const fs = require('fs');
|
|
54
54
|
const path = require('path');
|
|
55
|
-
const {
|
|
55
|
+
const { codeUnitCompare, literalNameRegex } = require('./shared');
|
|
56
56
|
|
|
57
57
|
// Unsupported-language site listings are capped so a Rails-sized repo cannot
|
|
58
58
|
// flood the account object; the counts always cover the full set.
|
|
@@ -83,7 +83,7 @@ function computeGroundSet(index, name) {
|
|
|
83
83
|
index._groundSetCache.set(name, cached);
|
|
84
84
|
return cached.result;
|
|
85
85
|
}
|
|
86
|
-
const wordRe =
|
|
86
|
+
const wordRe = literalNameRegex(name);
|
|
87
87
|
const perFile = new Map();
|
|
88
88
|
let total = 0;
|
|
89
89
|
let fileCount = 0;
|
|
@@ -158,7 +158,7 @@ function scanFailedFiles(index, name) {
|
|
|
158
158
|
if (!index.failedFiles || index.failedFiles.size === 0) {
|
|
159
159
|
return { unparsed, unreadableFiles };
|
|
160
160
|
}
|
|
161
|
-
const wordRe =
|
|
161
|
+
const wordRe = literalNameRegex(name);
|
|
162
162
|
for (const failedPath of index.failedFiles) {
|
|
163
163
|
if (index.files.has(failedPath)) continue;
|
|
164
164
|
let content;
|
|
@@ -204,7 +204,7 @@ function scanUnsupportedFiles(index, name, opts = {}) {
|
|
|
204
204
|
if (!Array.isArray(index.unsupportedFiles) || index.unsupportedFiles.length === 0) {
|
|
205
205
|
return unsupported;
|
|
206
206
|
}
|
|
207
|
-
const wordRe =
|
|
207
|
+
const wordRe = literalNameRegex(name);
|
|
208
208
|
const languageCounts = new Map();
|
|
209
209
|
for (const skipped of index.unsupportedFiles) {
|
|
210
210
|
const absPath = path.join(index.root, skipped.relativePath);
|
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/bridge.js
CHANGED
|
@@ -1066,11 +1066,60 @@ function extractNextjsRoutes(index) {
|
|
|
1066
1066
|
* Detect HTTP client requests across the project.
|
|
1067
1067
|
* Cached on `index._endpointsCache.clientRequests`.
|
|
1068
1068
|
*/
|
|
1069
|
+
// Python HTTP client types whose instances are receivers of request calls.
|
|
1070
|
+
// A receiver typed to one of these (with-binding, constructor assignment) or
|
|
1071
|
+
// bound to a pytest fixture that constructs one is a client by evidence, not
|
|
1072
|
+
// by name (fix #349: 145 of 625 real `tc.get("/api/...")` sites on one repo
|
|
1073
|
+
// were invisible because the receiver was not literally named `client`).
|
|
1074
|
+
const PY_CLIENT_TYPES = new Set([
|
|
1075
|
+
'TestClient', 'Client', 'AsyncClient', 'Session', 'FlaskClient',
|
|
1076
|
+
'ClientSession', 'AsyncSession', 'HTTPConnection', 'HTTPSConnection',
|
|
1077
|
+
]);
|
|
1078
|
+
const PY_CLIENT_FACTORIES = new Set(['test_client', 'Session', 'Client', 'AsyncClient']);
|
|
1079
|
+
const PY_REQUEST_METHODS = /^(get|post|put|delete|patch|options|head|request)$/;
|
|
1080
|
+
|
|
1081
|
+
function _pythonClientFixtures(index) {
|
|
1082
|
+
if (index._endpointsCache?.pyClientFixtures) return index._endpointsCache.pyClientFixtures;
|
|
1083
|
+
const fixtures = new Set();
|
|
1084
|
+
for (const [filePath, fileEntry] of index.files) {
|
|
1085
|
+
if (fileEntry.language !== 'python') continue;
|
|
1086
|
+
const fixtureDefs = (fileEntry.symbols || []).filter(s =>
|
|
1087
|
+
(s.decorators || []).some(d => /(^|\.)fixture$/.test(String(d))));
|
|
1088
|
+
if (fixtureDefs.length === 0) continue;
|
|
1089
|
+
const calls = getCachedCalls(index, filePath) || [];
|
|
1090
|
+
for (const def of fixtureDefs) {
|
|
1091
|
+
const constructsClient = calls.some(c =>
|
|
1092
|
+
c.enclosingFunction?.name === def.name &&
|
|
1093
|
+
c.enclosingFunction?.startLine === def.startLine &&
|
|
1094
|
+
(PY_CLIENT_TYPES.has(c.name) || (c.isMethod && PY_CLIENT_FACTORIES.has(c.name))));
|
|
1095
|
+
if (constructsClient) fixtures.add(def.name);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
if (!index._endpointsCache) index._endpointsCache = {};
|
|
1099
|
+
index._endpointsCache.pyClientFixtures = fixtures;
|
|
1100
|
+
return fixtures;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function _pythonClientReceiver(index, fileEntry, call, fixtures) {
|
|
1104
|
+
if (!call.isMethod || !call.receiver || !PY_REQUEST_METHODS.test(call.name)) return null;
|
|
1105
|
+
if (call.receiverType && PY_CLIENT_TYPES.has(String(call.receiverType).split('.').pop())) {
|
|
1106
|
+
return 'python-client';
|
|
1107
|
+
}
|
|
1108
|
+
if (fixtures.size === 0 || !fixtures.has(call.receiver)) return null;
|
|
1109
|
+
const fn = call.enclosingFunction;
|
|
1110
|
+
if (!fn) return null;
|
|
1111
|
+
const sym = (fileEntry.symbols || []).find(s => s.name === fn.name && s.startLine === fn.startLine);
|
|
1112
|
+
const params = String(sym?.params || '').split(',').map(p => p.trim().split(/[:=]/)[0].trim());
|
|
1113
|
+
return params.includes(call.receiver) ? 'pytest-client-fixture' : null;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1069
1116
|
function extractClientRequests(index) {
|
|
1070
1117
|
if (index._endpointsCache && index._endpointsCache.clientRequests) {
|
|
1071
1118
|
return index._endpointsCache.clientRequests;
|
|
1072
1119
|
}
|
|
1073
1120
|
const requests = [];
|
|
1121
|
+
const uncertain = [];
|
|
1122
|
+
const pyFixtures = _pythonClientFixtures(index);
|
|
1074
1123
|
|
|
1075
1124
|
for (const [filePath, fileEntry] of index.files) {
|
|
1076
1125
|
const lang = fileEntry.language;
|
|
@@ -1079,8 +1128,30 @@ function extractClientRequests(index) {
|
|
|
1079
1128
|
|
|
1080
1129
|
for (const call of calls) {
|
|
1081
1130
|
if (!call.firstStringArg) continue;
|
|
1082
|
-
|
|
1083
|
-
if (!r)
|
|
1131
|
+
let r = matchClientRequest(call, lang, calls);
|
|
1132
|
+
if (!r && lang === 'python') {
|
|
1133
|
+
const framework = _pythonClientReceiver(index, fileEntry, call, pyFixtures);
|
|
1134
|
+
if (framework) {
|
|
1135
|
+
r = { method: call.name.toUpperCase() === 'REQUEST' ? 'ALL' : call.name.toUpperCase(),
|
|
1136
|
+
framework, methodInferred: call.name === 'request' };
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
if (!r) {
|
|
1140
|
+
// Visible uncertainty: request-shaped call on an unrecognized
|
|
1141
|
+
// receiver with a path-shaped literal. Listed, never counted.
|
|
1142
|
+
const conf = CLIENT_PATTERNS[lang];
|
|
1143
|
+
const pathShaped = call.firstStringArg.startsWith('/') || call.firstStringArg.includes('://');
|
|
1144
|
+
if (conf && call.isMethod && call.receiver && pathShaped &&
|
|
1145
|
+
conf.receivers.some(p => p.methodPattern.test(call.name))) {
|
|
1146
|
+
uncertain.push({
|
|
1147
|
+
receiver: call.receiver, method: call.name, path: call.firstStringArg,
|
|
1148
|
+
file: fileEntry.relativePath || filePath, absoluteFile: filePath, line: call.line,
|
|
1149
|
+
callerName: call.enclosingFunction?.name || '<top-level>',
|
|
1150
|
+
reason: 'receiver-unrecognized',
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
continue;
|
|
1154
|
+
}
|
|
1084
1155
|
|
|
1085
1156
|
// Python's common `session.get("key")` / `s.get("key")`
|
|
1086
1157
|
// dictionary and ORM idioms are not HTTP requests. Without
|
|
@@ -1114,6 +1185,9 @@ function extractClientRequests(index) {
|
|
|
1114
1185
|
}
|
|
1115
1186
|
|
|
1116
1187
|
// Stable sort
|
|
1188
|
+
uncertain.sort((a, b) => a.file !== b.file ? codeUnitCompare(a.file, b.file) : a.line - b.line);
|
|
1189
|
+
if (!index._endpointsCache) index._endpointsCache = {};
|
|
1190
|
+
index._endpointsCache.uncertainRequests = uncertain;
|
|
1117
1191
|
requests.sort((a, b) => {
|
|
1118
1192
|
if (a.file !== b.file) return codeUnitCompare(a.file, b.file);
|
|
1119
1193
|
if (a.line !== b.line) return a.line - b.line;
|
|
@@ -1418,6 +1492,13 @@ function endpoints(index, options = {}) {
|
|
|
1418
1492
|
|
|
1419
1493
|
let routes = opts.clientOnly ? [] : extractServerRoutes(index);
|
|
1420
1494
|
let requests = (opts.serverOnly ? [] : extractClientRequests(index));
|
|
1495
|
+
let uncertainRequests = opts.serverOnly ? [] : (index._endpointsCache?.uncertainRequests || []);
|
|
1496
|
+
if (uncertainRequests.length > 0) {
|
|
1497
|
+
// A server route registration (`@app.get("/x")`, `router.get("/x", h)`)
|
|
1498
|
+
// is request-shaped too; the route inventory already owns those lines.
|
|
1499
|
+
const routeLines = new Set(extractServerRoutes(index).map(r => `${r.absoluteFile}:${r.line}`));
|
|
1500
|
+
uncertainRequests = uncertainRequests.filter(r => !routeLines.has(`${r.absoluteFile}:${r.line}`));
|
|
1501
|
+
}
|
|
1421
1502
|
|
|
1422
1503
|
// Apply filters
|
|
1423
1504
|
if (opts.method) {
|
|
@@ -1427,6 +1508,10 @@ function endpoints(index, options = {}) {
|
|
|
1427
1508
|
if (opts.prefix) {
|
|
1428
1509
|
routes = routes.filter(r => r.path.startsWith(opts.prefix) || r.normalizedPath.startsWith(opts.prefix));
|
|
1429
1510
|
requests = requests.filter(r => r.path.startsWith(opts.prefix) || r.normalizedPath.startsWith(opts.prefix));
|
|
1511
|
+
uncertainRequests = uncertainRequests.filter(r => r.path.startsWith(opts.prefix));
|
|
1512
|
+
}
|
|
1513
|
+
if (opts.method) {
|
|
1514
|
+
uncertainRequests = uncertainRequests.filter(r => r.method.toUpperCase() === opts.method || r.method === 'request');
|
|
1430
1515
|
}
|
|
1431
1516
|
|
|
1432
1517
|
let bridges = opts.bridge ? bridgeEndpoints(index) : [];
|
|
@@ -1472,12 +1557,14 @@ function endpoints(index, options = {}) {
|
|
|
1472
1557
|
: 'incomplete-endpoint-inventory',
|
|
1473
1558
|
routes,
|
|
1474
1559
|
requests,
|
|
1560
|
+
uncertainRequests,
|
|
1475
1561
|
bridges,
|
|
1476
1562
|
unmatchedRoutes,
|
|
1477
1563
|
unmatchedRequests,
|
|
1478
1564
|
meta: {
|
|
1479
1565
|
totalRoutes: routes.length,
|
|
1480
1566
|
totalRequests: requests.length,
|
|
1567
|
+
uncertainRequests: uncertainRequests.length,
|
|
1481
1568
|
totalBridges: bridges.length,
|
|
1482
1569
|
unmatchedRoutes: unmatchedRoutes.length,
|
|
1483
1570
|
unmatchedRequests: unmatchedRequests.length,
|
package/core/cache.js
CHANGED
|
@@ -697,7 +697,9 @@ function clearAllCaches() {
|
|
|
697
697
|
// default/type imports and inline type re-exports preserve execution timing.
|
|
698
698
|
// Python TYPE_CHECKING guards require typing ownership and no rebinding.
|
|
699
699
|
// v212 (fix #342): extendsGraph/extendedByGraph no longer persisted (rebuilt on load).
|
|
700
|
-
|
|
700
|
+
// v213 (fix #348): Python from-import records carry per-name `renames` so
|
|
701
|
+
// import bindings pair each alias with its own module.
|
|
702
|
+
const CACHE_FORMAT_VERSION = 213;
|
|
701
703
|
const USAGE_CACHE_FILE = 'usage-results.json';
|
|
702
704
|
|
|
703
705
|
/**
|
package/core/callers.js
CHANGED
|
@@ -2802,7 +2802,9 @@ function findCallers(index, name, options = {}) {
|
|
|
2802
2802
|
// source name from DIFFERENT modules — the record's local
|
|
2803
2803
|
// alias (call.name) picks its own binding; source-name
|
|
2804
2804
|
// matching alone over-follows into the other module.
|
|
2805
|
-
|
|
2805
|
+
// Python records carry the alias as `calledAs` rather
|
|
2806
|
+
// than resolvedName; both name the paired binding.
|
|
2807
|
+
if ((call.resolvedName || calledAs) && nameBindings.some(b => b.alias)) {
|
|
2806
2808
|
const paired = nameBindings.filter(b => b.alias === call.name);
|
|
2807
2809
|
if (paired.length > 0) nameBindings = paired;
|
|
2808
2810
|
}
|
|
@@ -16128,4 +16130,4 @@ function findCallbackUsages(index, name) {
|
|
|
16128
16130
|
return usages;
|
|
16129
16131
|
}
|
|
16130
16132
|
|
|
16131
|
-
module.exports = { _unresolvedModuleIsGap, getCachedCalls, findCallers, findCallees, getInstanceAttributeTypes, findCallbackUsages, _nameBindingReaches, _moduleAttributeBindingReaches, _declaredFieldType, _projectTopLevelNames, _callArityCompatible, _closeCallableIdentityGroup, _overloadDiscipline, _overloadApplicable };
|
|
16133
|
+
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',
|
package/core/execute.js
CHANGED
|
@@ -1836,6 +1836,7 @@ const HANDLERS = {
|
|
|
1836
1836
|
result.meta = {
|
|
1837
1837
|
totalRoutes: result.routes.length,
|
|
1838
1838
|
totalRequests: result.requests.length,
|
|
1839
|
+
uncertainRequests: (result.uncertainRequests || []).length,
|
|
1839
1840
|
totalBridges: result.bridges.length,
|
|
1840
1841
|
unmatchedRoutes: result.unmatchedRoutes.length,
|
|
1841
1842
|
unmatchedRequests: result.unmatchedRequests.length,
|
|
@@ -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
|
@@ -26,7 +26,7 @@ function formatEndpoints(result, options = {}) {
|
|
|
26
26
|
const showBridge = options.bridge;
|
|
27
27
|
|
|
28
28
|
if (!showBridge) {
|
|
29
|
-
return formatRoutesAndRequests(routes, requests, meta, options, result.advisory);
|
|
29
|
+
return formatRoutesAndRequests(routes, requests, meta, options, result.advisory, result);
|
|
30
30
|
}
|
|
31
31
|
return formatBridges(bridges, unmatchedRoutes, unmatchedRequests, meta, options, result.advisory);
|
|
32
32
|
}
|
|
@@ -48,7 +48,20 @@ function uniqueMatchPercent(bridges, totalRequests) {
|
|
|
48
48
|
return Math.min(100, Math.max(0, pct));
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
function
|
|
51
|
+
function formatUncertainRequests(result, options) {
|
|
52
|
+
const list = result?.uncertainRequests || [];
|
|
53
|
+
if (list.length === 0) return [];
|
|
54
|
+
const lines = [];
|
|
55
|
+
lines.push(`Possible client requests (${list.length}) — request-shaped call with a path literal, receiver not recognized as an HTTP client:`);
|
|
56
|
+
const cap = options.all ? Infinity : 10;
|
|
57
|
+
for (const r of list.slice(0, cap)) {
|
|
58
|
+
lines.push(` ${r.file}:${r.line} ${r.receiver}.${r.method}(${JSON.stringify(r.path)}) in ${r.callerName}`);
|
|
59
|
+
}
|
|
60
|
+
if (list.length > cap) lines.push(` (+${list.length - cap} more — use --all)`);
|
|
61
|
+
return lines;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function formatRoutesAndRequests(routes, requests, meta, options, advisory = null, result = null) {
|
|
52
65
|
const lines = [];
|
|
53
66
|
const showServer = !options.clientOnly;
|
|
54
67
|
const showClient = !options.serverOnly;
|
|
@@ -84,7 +97,7 @@ function formatRoutesAndRequests(routes, requests, meta, options, advisory = nul
|
|
|
84
97
|
|
|
85
98
|
if (showClient) {
|
|
86
99
|
if (requests.length === 0) {
|
|
87
|
-
if (showServer) lines.push('
|
|
100
|
+
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
101
|
} else {
|
|
89
102
|
if (showServer) lines.push('');
|
|
90
103
|
lines.push(`Client Requests: ${requests.length}`);
|
|
@@ -109,6 +122,10 @@ function formatRoutesAndRequests(routes, requests, meta, options, advisory = nul
|
|
|
109
122
|
}
|
|
110
123
|
}
|
|
111
124
|
|
|
125
|
+
if (showClient) {
|
|
126
|
+
const uncertain = formatUncertainRequests(result, options);
|
|
127
|
+
if (uncertain.length > 0) lines.push('', ...uncertain);
|
|
128
|
+
}
|
|
112
129
|
const routeAdvisory = advisoryLine(advisory);
|
|
113
130
|
if (routeAdvisory) lines.push('', routeAdvisory);
|
|
114
131
|
return lines.join('\n').trimEnd();
|
|
@@ -230,6 +247,10 @@ function formatEndpointsJson(result, options = {}) {
|
|
|
230
247
|
data: {
|
|
231
248
|
routes: routes.map(trimRoute),
|
|
232
249
|
requests: requests.map(trimReq),
|
|
250
|
+
uncertainRequests: (result.uncertainRequests || []).map(r => ({
|
|
251
|
+
receiver: r.receiver, method: r.method, path: r.path,
|
|
252
|
+
file: r.file, line: r.line, callerName: r.callerName, reason: r.reason,
|
|
253
|
+
})),
|
|
233
254
|
// In unmatched-only mode, the matched bridges array is suppressed
|
|
234
255
|
// — consumers that want both should not pass --unmatched.
|
|
235
256
|
bridges: unmatchedOnly ? [] : bridges.map(trimBridge),
|
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/core/project.js
CHANGED
|
@@ -17,7 +17,7 @@ const { detectLanguage, getParser, getLanguageAdapter, safeParse, langTraits, PA
|
|
|
17
17
|
const { validateFileIR } = require('./ir');
|
|
18
18
|
const { createFileEntryFromIR, populateFileEntryFromIR } = require('./index-ir');
|
|
19
19
|
const { getTokenTypeAtPosition } = require('../languages/utils');
|
|
20
|
-
const { escapeRegExp, NON_CALLABLE_TYPES, codeUnitCompare } = require('./shared');
|
|
20
|
+
const { escapeRegExp, NON_CALLABLE_TYPES, codeUnitCompare, literalNameRegex } = require('./shared');
|
|
21
21
|
const stacktrace = require('./stacktrace');
|
|
22
22
|
const indexCache = require('./cache');
|
|
23
23
|
const deadcodeModule = require('./deadcode');
|
|
@@ -1523,7 +1523,7 @@ class ProjectIndex {
|
|
|
1523
1523
|
|
|
1524
1524
|
// Detailed path: full AST-based counting (original algorithm)
|
|
1525
1525
|
// Note: no 'g' flag - we only need to test for presence per line
|
|
1526
|
-
const regex =
|
|
1526
|
+
const regex = literalNameRegex(name);
|
|
1527
1527
|
|
|
1528
1528
|
// Get files that could reference this symbol:
|
|
1529
1529
|
// 1. The file where it's defined
|
|
@@ -1941,6 +1941,25 @@ class ProjectIndex {
|
|
|
1941
1941
|
}
|
|
1942
1942
|
}
|
|
1943
1943
|
|
|
1944
|
+
/**
|
|
1945
|
+
* True when the AST node at (line, column) is an identifier token — code
|
|
1946
|
+
* the usage scan saw (and may have deliberately dropped), as opposed to
|
|
1947
|
+
* JSX children, HTML markup, or other non-code text (fix #350).
|
|
1948
|
+
*/
|
|
1949
|
+
isIdentifierAtPosition(content, lineNum, column, filePath) {
|
|
1950
|
+
const language = detectLanguage(filePath, this.root);
|
|
1951
|
+
if (!language) return false;
|
|
1952
|
+
try {
|
|
1953
|
+
const tree = this._getParsedTree(filePath, content, language) ||
|
|
1954
|
+
safeParse(getParser(language), content);
|
|
1955
|
+
if (!tree) return false;
|
|
1956
|
+
const node = tree.rootNode.descendantForPosition({ row: lineNum - 1, column });
|
|
1957
|
+
return !!node && /identifier|^name$|^word$/.test(node.type);
|
|
1958
|
+
} catch (e) {
|
|
1959
|
+
return false;
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1944
1963
|
/**
|
|
1945
1964
|
* Check if a position in code is inside a comment or string using AST
|
|
1946
1965
|
* @param {string} content - File content
|
package/core/search.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
'use strict';
|
|
9
9
|
|
|
10
10
|
const path = require('path');
|
|
11
|
-
const { escapeRegExp, codeUnitCompare, inlineTestRanges, lineInRanges, classDispatchNames, CALLABLE_SYMBOL_KINDS } = require('./shared');
|
|
11
|
+
const { escapeRegExp, codeUnitCompare, inlineTestRanges, lineInRanges, classDispatchNames, CALLABLE_SYMBOL_KINDS, literalNameRegex } = require('./shared');
|
|
12
12
|
const { isTestFile } = require('./discovery');
|
|
13
13
|
const { detectLanguage, getParser, getLanguageAdapter, langTraits } = require('../languages');
|
|
14
14
|
const { getCachedCalls } = require('./callers');
|
|
@@ -49,11 +49,6 @@ function matchesSubstring(text, pattern, caseSensitive) {
|
|
|
49
49
|
return text.toLowerCase().includes(pattern.toLowerCase());
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
function literalNameRegex(name) {
|
|
53
|
-
return new RegExp(
|
|
54
|
-
`(?<![A-Za-z0-9_$])${escapeRegExp(name)}(?![A-Za-z0-9_$])`,
|
|
55
|
-
);
|
|
56
|
-
}
|
|
57
52
|
|
|
58
53
|
/**
|
|
59
54
|
* Complete the AST-classified usage inventory with literal-name lines that
|
|
@@ -82,14 +77,21 @@ function appendTextComplements(index, {
|
|
|
82
77
|
const lineNum = idx + 1;
|
|
83
78
|
const commentOrString = index.isCommentOrStringAtPosition(
|
|
84
79
|
content, lineNum, match.index, filePath);
|
|
85
|
-
|
|
80
|
+
// A literal match that no AST identifier record claimed and that is
|
|
81
|
+
// not inside a comment or string is non-code text: JSX children,
|
|
82
|
+
// HTML markup and attributes, regex bodies. The ACCOUNT counts these
|
|
83
|
+
// lines as other-text; the escape hatch must list them too. A line
|
|
84
|
+
// the AST scan classified and then deliberately dropped (Rust enum
|
|
85
|
+
// variants against a struct pin, #234) is code, not text: skip it.
|
|
86
|
+
if (!commentOrString &&
|
|
87
|
+
index.isIdentifierAtPosition(content, lineNum, match.index, filePath)) continue;
|
|
86
88
|
const usage = {
|
|
87
89
|
file: filePath,
|
|
88
90
|
relativePath: fileEntry.relativePath,
|
|
89
91
|
line: lineNum,
|
|
90
92
|
content: line,
|
|
91
93
|
usageType: 'text',
|
|
92
|
-
textKind: 'comment-or-string',
|
|
94
|
+
textKind: commentOrString ? 'comment-or-string' : 'markup-or-text',
|
|
93
95
|
isDefinition: false,
|
|
94
96
|
};
|
|
95
97
|
if (context > 0) {
|
|
@@ -397,7 +399,7 @@ function usages(index, name, options = {}) {
|
|
|
397
399
|
}
|
|
398
400
|
|
|
399
401
|
// Fallback to regex-based detection
|
|
400
|
-
const regex =
|
|
402
|
+
const regex = literalNameRegex(name);
|
|
401
403
|
lines.forEach((line, idx) => {
|
|
402
404
|
const lineNum = idx + 1;
|
|
403
405
|
|
package/core/shared.js
CHANGED
|
@@ -118,6 +118,18 @@ function addTestExclusions(exclude) {
|
|
|
118
118
|
/**
|
|
119
119
|
* Escape special regex characters
|
|
120
120
|
*/
|
|
121
|
+
/**
|
|
122
|
+
* Whole-identifier match for a symbol name. `\b` is ASCII-only in JS, so
|
|
123
|
+
* `hit` matched inside `hitΔ` while every compiler and ripgrep treat Δ as an
|
|
124
|
+
* identifier character. Letters, digits, `_` and `$` on either side break a
|
|
125
|
+
* match; anything else (punctuation, whitespace, line edges) is a boundary.
|
|
126
|
+
*/
|
|
127
|
+
function literalNameRegex(name, flags = '') {
|
|
128
|
+
return new RegExp(
|
|
129
|
+
`(?<![\\p{L}\\p{N}_$])${escapeRegExp(name)}(?![\\p{L}\\p{N}_$])`,
|
|
130
|
+
flags.includes('u') ? flags : flags + 'u');
|
|
131
|
+
}
|
|
132
|
+
|
|
121
133
|
function escapeRegExp(text) {
|
|
122
134
|
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
123
135
|
}
|
|
@@ -463,7 +475,7 @@ function maskBlockComments(content, language) {
|
|
|
463
475
|
return out.join('');
|
|
464
476
|
}
|
|
465
477
|
|
|
466
|
-
module.exports = {
|
|
478
|
+
module.exports = { literalNameRegex,
|
|
467
479
|
pickBestDefinition,
|
|
468
480
|
addTestExclusions,
|
|
469
481
|
escapeRegExp,
|
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([
|
|
@@ -3115,6 +3140,7 @@ function findImportsInCode(code, parser) {
|
|
|
3115
3140
|
const deferral = importDeferral(node);
|
|
3116
3141
|
let modulePath = '';
|
|
3117
3142
|
const names = [];
|
|
3143
|
+
const renames = [];
|
|
3118
3144
|
|
|
3119
3145
|
for (let i = 0; i < node.namedChildCount; i++) {
|
|
3120
3146
|
const child = node.namedChild(i);
|
|
@@ -3133,6 +3159,7 @@ function findImportsInCode(code, parser) {
|
|
|
3133
3159
|
if (nameNode && aliasNode && aliasNode.text !== nameNode.text) {
|
|
3134
3160
|
if (!importAliases) importAliases = [];
|
|
3135
3161
|
importAliases.push({ original: nameNode.text, local: aliasNode.text });
|
|
3162
|
+
renames.push({ original: nameNode.text, local: aliasNode.text });
|
|
3136
3163
|
}
|
|
3137
3164
|
} else if (child.type === 'wildcard_import') {
|
|
3138
3165
|
names.push('*');
|
|
@@ -3146,6 +3173,7 @@ function findImportsInCode(code, parser) {
|
|
|
3146
3173
|
names,
|
|
3147
3174
|
type: isRelative ? 'relative' : 'from',
|
|
3148
3175
|
line,
|
|
3176
|
+
...(renames.length > 0 && { renames }),
|
|
3149
3177
|
...(deferral && { deferred: true, deferredReason: deferral })
|
|
3150
3178
|
});
|
|
3151
3179
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ucn",
|
|
3
|
-
"version": "5.3.
|
|
3
|
+
"version": "5.3.2",
|
|
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",
|