ucn 5.6.0 → 5.6.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 +4 -2
- package/.claude/skills/ucn/references/commands.md +1 -1
- package/CHANGELOG.md +11 -0
- package/README.md +5 -8
- package/core/analysis.js +3 -3
- package/core/cache.js +3 -1
- package/core/call-fit.js +130 -0
- package/core/callers.js +1 -1
- package/core/capture.js +40 -8
- package/core/change-kinds.js +221 -2
- package/core/check.js +48 -15
- package/core/command-contracts.js +2 -2
- package/core/deadcode.js +94 -23
- package/core/deletion.js +29 -9
- package/core/entrypoint-toml.js +110 -0
- package/core/index-ir.js +2 -2
- package/core/ir.js +1 -1
- package/core/outgoing-check.js +116 -0
- package/core/output/check.js +5 -2
- package/core/output/reporting.js +4 -0
- package/core/output/search.js +3 -0
- package/core/output/tracing.js +1 -1
- package/core/output-budget.js +29 -11
- package/core/python-binding-identity.js +707 -0
- package/core/reflection.js +99 -10
- package/core/registry.js +2 -2
- package/core/runtime-entrypoints.js +159 -0
- package/core/search.js +61 -38
- package/core/test-units.js +116 -42
- package/core/tracing.js +193 -635
- package/core/verify.js +37 -12
- package/languages/csharp.js +4 -4
- package/languages/go.js +1 -1
- package/languages/index.js +45 -1
- package/languages/java.js +4 -4
- package/languages/javascript.js +40 -6
- package/languages/python.js +49 -15
- package/languages/rust.js +126 -112
- package/languages/static-js.js +88 -0
- package/mcp/server.js +1 -1
- package/package.json +4 -3
package/core/reflection.js
CHANGED
|
@@ -24,8 +24,9 @@
|
|
|
24
24
|
* memoized on the index and reset with it.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
const { langTraits } = require('../languages');
|
|
27
|
+
const { langTraits, detectLanguage } = require('../languages');
|
|
28
28
|
const { sameNode } = require('../languages/utils');
|
|
29
|
+
const { pythonPossibleIdentities, pythonSourceName } = require('./python-binding-identity');
|
|
29
30
|
|
|
30
31
|
const WILD = '\u0000*';
|
|
31
32
|
const MODULE_RECEIVER = Object.freeze({ kind: 'module' });
|
|
@@ -153,7 +154,13 @@ function _stringLiteral(node) {
|
|
|
153
154
|
function stringAlternatives(node, ctx, depth = 0) {
|
|
154
155
|
node = _unwrap(node);
|
|
155
156
|
if (!node || depth > 12) return null;
|
|
156
|
-
if (STRING_NODES.has(node.type))
|
|
157
|
+
if (STRING_NODES.has(node.type)) {
|
|
158
|
+
const literal = ctx?.language === 'python'
|
|
159
|
+
? require('./runtime-entrypoints').pythonStringLiteral(node)
|
|
160
|
+
: ['javascript', 'typescript', 'tsx', 'html'].includes(ctx?.language)
|
|
161
|
+
? require('../languages/static-js').jsString(node) : null;
|
|
162
|
+
return literal != null ? [[literal]] : _stringLiteral(node);
|
|
163
|
+
}
|
|
157
164
|
switch (node.type) {
|
|
158
165
|
case 'concatenated_string': {
|
|
159
166
|
let acc = [[]];
|
|
@@ -835,7 +842,7 @@ function reflectionFactsInTree(tree, language, code = null, options = {}) {
|
|
|
835
842
|
const api = traits.reflectionApi;
|
|
836
843
|
if (!api || !tree) return facts;
|
|
837
844
|
const resolve = makeResolver();
|
|
838
|
-
const ctx = { formatCalls: api.formatCalls || null, resolve };
|
|
845
|
+
const ctx = { formatCalls: api.formatCalls || null, resolve, language };
|
|
839
846
|
const sites = facts.sites;
|
|
840
847
|
const push = (node, kind, nameNode, receiverNode, receiverFact) => {
|
|
841
848
|
const cls = classifyNameExpression(nameNode, ctx);
|
|
@@ -876,7 +883,10 @@ function reflectionFactsInTree(tree, language, code = null, options = {}) {
|
|
|
876
883
|
const { literalNameRegex } = require('./shared');
|
|
877
884
|
hintSources.push(...[...qualifiers.keys()].map(name => literalNameRegex(name).source));
|
|
878
885
|
}
|
|
879
|
-
|
|
886
|
+
// Aliases can call a reflective API without spelling that API at the call.
|
|
887
|
+
// Resolve the Python call ASTs whenever its binding vocabulary is present.
|
|
888
|
+
if (api.pythonBindingIdentity && code != null && !api.hintPattern.test(code.normalize('NFKC'))) return facts;
|
|
889
|
+
const useHints = !api.pythonBindingIdentity && code != null && hintSources.length > 0;
|
|
880
890
|
const callTypes = constructs.length > 0 ? [...CALL_NODE_TYPES, ...NEW_NODE_TYPES] : CALL_NODE_TYPES;
|
|
881
891
|
const types = [];
|
|
882
892
|
if (!useHints) {
|
|
@@ -888,7 +898,7 @@ function reflectionFactsInTree(tree, language, code = null, options = {}) {
|
|
|
888
898
|
// Qualified callees (`Reflect.get`, a package's functions) need the
|
|
889
899
|
// callee text of member-shaped calls; bare vocabularies (`getattr`)
|
|
890
900
|
// never do.
|
|
891
|
-
const wantQualified = qualifiers.size > 0 ||
|
|
901
|
+
const wantQualified = api.pythonBindingIdentity || qualifiers.size > 0 ||
|
|
892
902
|
[...(api.functions || []), ...unnamed, ...codeExecution].some(rule => rule.qualified);
|
|
893
903
|
const visitNode = node => {
|
|
894
904
|
if (node.type === 'subscript_expression') {
|
|
@@ -897,6 +907,28 @@ function reflectionFactsInTree(tree, language, code = null, options = {}) {
|
|
|
897
907
|
// counts; opaque keys stay the computed-dispatch disclosure.
|
|
898
908
|
const index = node.childForFieldName('index');
|
|
899
909
|
const inner = _unwrap(index);
|
|
910
|
+
const { jsStaticKey, jsConstValue } = require('../languages/static-js');
|
|
911
|
+
const staticName = jsStaticKey(inner);
|
|
912
|
+
if (staticName != null) {
|
|
913
|
+
// This is normal member syntax. The call extractor carries
|
|
914
|
+
// the exact receiver; reflection keeps value-position access live.
|
|
915
|
+
if (node.parent?.type !== 'call_expression' || !sameNode(node.parent.childForFieldName('function'), node)) {
|
|
916
|
+
sites.push({ kind: 'computed', name: staticName, patterns: [staticName], dynamic: false,
|
|
917
|
+
line: node.startPosition.row + 1, expression: node.text.slice(0, EXPRESSION_CAP),
|
|
918
|
+
receiver: classifyReceiver(node.childForFieldName('object'), language), membersOnly: true });
|
|
919
|
+
}
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
const object = node.childForFieldName('object');
|
|
923
|
+
const construction = object?.type === 'new_expression' ? object : jsConstValue(object, true);
|
|
924
|
+
const type = construction?.type === 'new_expression' ? construction.childForFieldName('constructor') : null;
|
|
925
|
+
if (type?.type === 'identifier' && node.parent?.type === 'call_expression' &&
|
|
926
|
+
sameNode(node.parent.childForFieldName('function'), node)) {
|
|
927
|
+
sites.push({ kind: 'computed', dynamic: true, dynamicMember: true,
|
|
928
|
+
receiver: { kind: 'type', name: type.text }, membersOnly: true,
|
|
929
|
+
line: node.startPosition.row + 1, expression: node.text.slice(0, EXPRESSION_CAP) });
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
900
932
|
// `registry[type](..)` with `type` a parameter of the enclosing
|
|
901
933
|
// callable dispatches by the name its callers pass (fix #428).
|
|
902
934
|
if (inner?.type === 'identifier' && node.parent?.type === 'call_expression' &&
|
|
@@ -930,15 +962,61 @@ function reflectionFactsInTree(tree, language, code = null, options = {}) {
|
|
|
930
962
|
}
|
|
931
963
|
const call = _calleeParts(node, wantMembers, wantQualified);
|
|
932
964
|
if (!call) return;
|
|
965
|
+
if (api.pythonBindingIdentity) {
|
|
966
|
+
const fn = _calleeNode(node.childForFieldName('function'));
|
|
967
|
+
const identities = pythonPossibleIdentities(fn, scopeMemo);
|
|
968
|
+
const args = _callArgs(node) || [];
|
|
969
|
+
const keyword = name => args.find(arg => arg.type === 'keyword_argument' &&
|
|
970
|
+
pythonSourceName(arg.childForFieldName('name')?.text) === name)?.childForFieldName('value');
|
|
971
|
+
for (const identity of identities) {
|
|
972
|
+
const isPatch = identity === 'unittest.mock.patch';
|
|
973
|
+
const isObjectPatch = identity === 'unittest.mock.patch.object';
|
|
974
|
+
const isMonkeyPatch = identity === 'pytest.MonkeyPatch.instance.setattr';
|
|
975
|
+
if (isPatch || isObjectPatch || isMonkeyPatch) {
|
|
976
|
+
const positional = args.filter(arg => arg.type !== 'keyword_argument');
|
|
977
|
+
const nameNode = positional[0] || keyword('target');
|
|
978
|
+
const optional = isPatch || isObjectPatch ? keyword('create')?.type === 'true'
|
|
979
|
+
: keyword('raising')?.type === 'false';
|
|
980
|
+
const memberNode = positional[1] || keyword(isObjectPatch ? 'attribute' : 'name');
|
|
981
|
+
const member = classifyNameExpression(memberNode, ctx);
|
|
982
|
+
if (!optional && (isObjectPatch || (isMonkeyPatch && !stringAlternatives(nameNode, ctx))) && member.name) {
|
|
983
|
+
facts.reflectionUses++;
|
|
984
|
+
push(node, 'runtime-member', memberNode, nameNode);
|
|
985
|
+
continue;
|
|
986
|
+
}
|
|
987
|
+
const alternatives = stringAlternatives(nameNode, ctx);
|
|
988
|
+
const target = alternatives?.length === 1 && !alternatives[0].includes(WILD)
|
|
989
|
+
? alternatives[0].join('') : null;
|
|
990
|
+
if (!optional && target?.includes('.')) {
|
|
991
|
+
const name = target.slice(target.lastIndexOf('.') + 1);
|
|
992
|
+
facts.reflectionUses++;
|
|
993
|
+
const spellings = new Set([name]);
|
|
994
|
+
// A private source member is spelled _Owner__member by
|
|
995
|
+
// runtime APIs. The final owner-aware matcher decides it;
|
|
996
|
+
// this index must first make its source spelling reachable.
|
|
997
|
+
for (let offset = 1; offset < name.length - 1; offset++) {
|
|
998
|
+
if (name[offset] === '_' && name[offset + 1] === '_') spellings.add(name.slice(offset));
|
|
999
|
+
}
|
|
1000
|
+
sites.push({ kind: 'runtime-target', name, patterns: [...spellings], dynamic: false,
|
|
1001
|
+
runtimeTarget: target, line: node.startPosition.row + 1,
|
|
1002
|
+
expression: String(node.text).slice(0, EXPRESSION_CAP) });
|
|
1003
|
+
}
|
|
1004
|
+
continue;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
call.possibleCallees = identities.filter(identity => identity.startsWith('builtins.')).map(identity => identity.slice(9));
|
|
1008
|
+
call.calleeText = call.possibleCallees[0] || '';
|
|
1009
|
+
}
|
|
933
1010
|
// Match on the callee spelling first; arguments are read only for a
|
|
934
1011
|
// vocabulary hit (most calls in a hinted file are unrelated).
|
|
935
1012
|
const calleeText = () => (call.calleeText ??= _calleeName(call.fn));
|
|
936
1013
|
for (const rule of api.functions || []) {
|
|
937
|
-
|
|
1014
|
+
const matched = (call.possibleCallees || [calleeText()]).find(name => rule.callee.test(name));
|
|
1015
|
+
if (!matched) continue;
|
|
938
1016
|
const args = _callArgs(node);
|
|
939
1017
|
if (args && args[rule.nameArg]) {
|
|
940
1018
|
facts.reflectionUses++;
|
|
941
|
-
push(node, rule.kind ||
|
|
1019
|
+
push(node, rule.kind || matched.split('.').pop(),
|
|
942
1020
|
args[rule.nameArg], args[rule.receiverArg]);
|
|
943
1021
|
}
|
|
944
1022
|
return;
|
|
@@ -1081,10 +1159,16 @@ function reflectionPatternIndex(index) {
|
|
|
1081
1159
|
let entryCount = 0;
|
|
1082
1160
|
const moduleScoped = new Map();
|
|
1083
1161
|
const keyParameterSites = [];
|
|
1162
|
+
const dynamicMembers = [];
|
|
1084
1163
|
for (const [file, sites] of byFile) {
|
|
1085
1164
|
const fe = index.files.get(file);
|
|
1086
1165
|
if (!fe) continue;
|
|
1087
1166
|
for (const site of sites) {
|
|
1167
|
+
if (site.dynamicMember) {
|
|
1168
|
+
dynamicMembers.push({ pattern: '*', file, relativePath: fe.relativePath,
|
|
1169
|
+
language: fe.language, family: _family(fe.language), site });
|
|
1170
|
+
continue;
|
|
1171
|
+
}
|
|
1088
1172
|
if (site.receiver?.kind === 'module') {
|
|
1089
1173
|
// Bounded to this file's top-level names: a fully dynamic
|
|
1090
1174
|
// name reaches every one of them.
|
|
@@ -1114,7 +1198,7 @@ function reflectionPatternIndex(index) {
|
|
|
1114
1198
|
}
|
|
1115
1199
|
}
|
|
1116
1200
|
const result = { byFragment, lengths: [...lengths].sort((a, b) => a - b), entryCount, moduleScoped,
|
|
1117
|
-
keyParameterSites, quoted: new Map(), keyNames: new Map() };
|
|
1201
|
+
keyParameterSites, dynamicMembers, quoted: new Map(), keyNames: new Map() };
|
|
1118
1202
|
index._reflectionPatternIndex = result;
|
|
1119
1203
|
return result;
|
|
1120
1204
|
}
|
|
@@ -1130,6 +1214,9 @@ function patternEntriesFor(index, name, language, file = null) {
|
|
|
1130
1214
|
}
|
|
1131
1215
|
}
|
|
1132
1216
|
const family = language ? _family(language) : null;
|
|
1217
|
+
for (const entry of pidx.dynamicMembers) {
|
|
1218
|
+
if (!family || entry.family === family) out.push(entry);
|
|
1219
|
+
}
|
|
1133
1220
|
for (const entry of pidx.keyParameterSites) {
|
|
1134
1221
|
if (family && entry.family !== family) continue;
|
|
1135
1222
|
if (_keyParameterPasses(index, pidx, entry, name)) out.push({ ...entry, pattern: name });
|
|
@@ -1190,6 +1277,7 @@ function _reachesKey(index, fromRef, targetKey, targetName) {
|
|
|
1190
1277
|
*/
|
|
1191
1278
|
function reflectionEntryReaches(index, entry, def) {
|
|
1192
1279
|
const site = entry.site;
|
|
1280
|
+
if (site.runtimeTarget) return require('./runtime-entrypoints').runtimeTargetReaches(index, def, site.runtimeTarget);
|
|
1193
1281
|
if (site.membersOnly && !def.className && !def.receiver) return false;
|
|
1194
1282
|
const recv = site.receiver;
|
|
1195
1283
|
if (!recv) return true;
|
|
@@ -1228,8 +1316,9 @@ function reflectionEntryReaches(index, entry, def) {
|
|
|
1228
1316
|
|
|
1229
1317
|
/** Reaching pattern entries for one definition (sorted file, line). */
|
|
1230
1318
|
function reflectionPatternReferences(index, def) {
|
|
1231
|
-
const lang = index.files.get(def.file)?.language;
|
|
1232
|
-
const
|
|
1319
|
+
const lang = index.files.get(def.file)?.language || detectLanguage(def.file, index.root);
|
|
1320
|
+
const runtimeName = langTraits(lang)?.reflectionApi?.pythonBindingIdentity ? pythonSourceName(def.name) : def.name;
|
|
1321
|
+
const entries = patternEntriesFor(index, runtimeName, lang, def.file)
|
|
1233
1322
|
.filter(entry => reflectionEntryReaches(index, entry, def));
|
|
1234
1323
|
entries.sort((a, b) => (a.relativePath < b.relativePath ? -1 : a.relativePath > b.relativePath ? 1 : 0) ||
|
|
1235
1324
|
a.site.line - b.site.line || (a.pattern < b.pattern ? -1 : a.pattern > b.pattern ? 1 : 0));
|
package/core/registry.js
CHANGED
|
@@ -160,7 +160,7 @@ const PARAMS = {
|
|
|
160
160
|
text: 'Comma-separated projection. {valueSets}. Omitted: {defaults}.',
|
|
161
161
|
},
|
|
162
162
|
exclude: { type: 'string', cli: ['--exclude', '--not'], repeat: true, text: 'Comma-separated patterns to exclude (e.g. "test,mock,vendor")' },
|
|
163
|
-
includeTests: { type: 'boolean', text: 'Include test files in
|
|
163
|
+
includeTests: { type: 'boolean', text: 'Include test files in the requested view. find includes test definitions by default; set false to omit them.' },
|
|
164
164
|
excludeTests: { type: 'boolean', text: 'Exclude test files: the default for entrypoints (include_tests=true lists them); usages and endpoints list test files unless exclude_tests=true.' },
|
|
165
165
|
includeMethods: {
|
|
166
166
|
type: 'boolean',
|
|
@@ -927,7 +927,7 @@ function generateCliParamReference() {
|
|
|
927
927
|
}
|
|
928
928
|
if (spec.defaults) parts.push(`default: ${defaultsText(param, 'cli').join('; ')}`);
|
|
929
929
|
if (parts.length === 0) continue;
|
|
930
|
-
const head = ` ${cliFlagsForParam(param)[0]}`.padEnd(19);
|
|
930
|
+
const head = ` ${cliFlagsForParam(param)[0]} `.padEnd(19);
|
|
931
931
|
lines.push(`${head}${parts[0]}`);
|
|
932
932
|
for (const part of parts.slice(1)) lines.push(`${' '.repeat(19)}${part}`);
|
|
933
933
|
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { pythonPossibleIdentities, pythonBoundValue, pythonSourceName } = require('./python-binding-identity');
|
|
6
|
+
const { entrypointToml } = require('./entrypoint-toml');
|
|
7
|
+
const OP_ENTRIES = new WeakMap();
|
|
8
|
+
|
|
9
|
+
/** A runtime module/member path names this declaration, including src layouts. */
|
|
10
|
+
function runtimeTargetReaches(index, symbol, target) {
|
|
11
|
+
const spelling = target.replace(':', '.').split('[')[0].trim();
|
|
12
|
+
const relative = path.relative(index.root, symbol.file).split(path.sep).join('/');
|
|
13
|
+
if (!relative.endsWith('.py')) return false;
|
|
14
|
+
const module = relative.slice(0, -3).replace(/\/__init__$/, '').replaceAll('/', '.');
|
|
15
|
+
const mangle = (name, owner) => owner && name.startsWith('__') && !name.endsWith('__') && owner.replace(/^_+/, '')
|
|
16
|
+
? `_${owner.replace(/^_+/, '')}${name}` : name;
|
|
17
|
+
let owners = symbol.runtimeOwnerPath;
|
|
18
|
+
let unknownOwner = false;
|
|
19
|
+
if (!Array.isArray(owners)) {
|
|
20
|
+
// Old/external records may omit nesting. Reconstruct only from a unique
|
|
21
|
+
// owner; when that owner is absent, preserve the possible suffix.
|
|
22
|
+
const candidates = (index.symbols.get(symbol.className) || []).filter(def => def.file === symbol.file && def.type === 'class');
|
|
23
|
+
if (symbol.className && candidates.length === 1) owners = [...(candidates[0].runtimeOwnerPath || []), symbol.className];
|
|
24
|
+
else { owners = symbol.className ? [symbol.className] : []; unknownOwner = !!symbol.className; }
|
|
25
|
+
}
|
|
26
|
+
const normalizedOwners = owners.map(pythonSourceName);
|
|
27
|
+
const member = [...normalizedOwners.map((name, position) => mangle(name, normalizedOwners[position - 1])),
|
|
28
|
+
mangle(pythonSourceName(symbol.name), normalizedOwners[normalizedOwners.length - 1])].join('.');
|
|
29
|
+
if (unknownOwner && spelling.endsWith(`.${mangle(pythonSourceName(symbol.name), pythonSourceName(symbol.className))}`)) {
|
|
30
|
+
const parts = module.split('.');
|
|
31
|
+
if (parts.some((_, position) => spelling.startsWith(`${parts.slice(position).join('.')}.`))) return true;
|
|
32
|
+
}
|
|
33
|
+
if (!spelling.endsWith(`.${member}`)) return false;
|
|
34
|
+
const namedModule = spelling.slice(0, -(member.length + 1));
|
|
35
|
+
return module === namedModule || module.endsWith(`.${namedModule}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function literal(node) {
|
|
39
|
+
node = pythonBoundValue(node);
|
|
40
|
+
if (!node || node.type !== 'string' || node.namedChildren.some(child => child.type === 'interpolation')) return null;
|
|
41
|
+
const raw = node.text;
|
|
42
|
+
const match = /^([rub]*)("""|'''|"|')([\s\S]*)\2$/i.exec(raw);
|
|
43
|
+
if (!match) return null;
|
|
44
|
+
if (/r/i.test(match[1])) return match[3];
|
|
45
|
+
let valid = true;
|
|
46
|
+
const value = match[3].replace(/\\(U[0-9a-fA-F]{8}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|[0-7]{1,3}|[\s\S])/g, (whole, escape) => {
|
|
47
|
+
if (/^[0-7]+$/.test(escape)) return String.fromCharCode(parseInt(escape, 8));
|
|
48
|
+
if (/^[uUx]/.test(escape)) {
|
|
49
|
+
const code = parseInt(escape.slice(1), 16);
|
|
50
|
+
if (code <= 0x10ffff) return String.fromCodePoint(code);
|
|
51
|
+
valid = false; return '';
|
|
52
|
+
}
|
|
53
|
+
const simple = { n: '\n', r: '\r', t: '\t', '"': '"', "'": "'", '\\': '\\', '\n': '' };
|
|
54
|
+
if (Object.hasOwn(simple, escape)) return simple[escape];
|
|
55
|
+
valid = false; return whole;
|
|
56
|
+
});
|
|
57
|
+
return valid ? value : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function targetOf(value) {
|
|
61
|
+
const candidate = value.includes('=') ? value.slice(value.lastIndexOf('=') + 1).trim() : value.trim();
|
|
62
|
+
return /^[\p{L}_][\p{L}\p{N}_.]*\s*:\s*[\p{L}_][\p{L}\p{N}_.]*(?:\s*\[[^\]]*\])?$/u.test(candidate)
|
|
63
|
+
? candidate.replace(/\s/g, '') : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Static packaging declarations, read as data; setup.py is never executed. */
|
|
67
|
+
function runtimeEntrypoints(index) {
|
|
68
|
+
const operation = index._opTreeCache;
|
|
69
|
+
if (operation && OP_ENTRIES.has(operation)) return OP_ENTRIES.get(operation);
|
|
70
|
+
const dirs = new Set();
|
|
71
|
+
for (const [file, entry] of index.files) {
|
|
72
|
+
if (entry.language !== 'python') continue;
|
|
73
|
+
for (let dir = path.dirname(file); dir === index.root || dir.startsWith(`${index.root}${path.sep}`); dir = path.dirname(dir)) {
|
|
74
|
+
dirs.add(dir);
|
|
75
|
+
if (dir === index.root) break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const entries = [];
|
|
79
|
+
const add = (value, file, line) => {
|
|
80
|
+
const target = targetOf(value);
|
|
81
|
+
entries.push(target ? { target, file, line } : { unknown: true, file, line });
|
|
82
|
+
};
|
|
83
|
+
for (const dir of [...dirs].sort()) {
|
|
84
|
+
for (const basename of ['pyproject.toml', 'setup.cfg']) {
|
|
85
|
+
const file = path.join(dir, basename);
|
|
86
|
+
let source;
|
|
87
|
+
try { source = fs.readFileSync(file, 'utf8'); } catch { continue; }
|
|
88
|
+
if (basename === 'pyproject.toml') {
|
|
89
|
+
for (const row of entrypointToml(source)) {
|
|
90
|
+
if (row.unknown) entries.push({ unknown: true, file, line: row.line });
|
|
91
|
+
else add(row.value, file, row.line);
|
|
92
|
+
}
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
let section = '';
|
|
96
|
+
const lines = source.split('\n');
|
|
97
|
+
for (let i = 0; i < lines.length; i++) {
|
|
98
|
+
const line = lines[i].trim();
|
|
99
|
+
const header = /^\[([^\]]+)\]/.exec(line);
|
|
100
|
+
if (header) { section = header[1]; continue; }
|
|
101
|
+
if (section === 'options.entry_points' && line && !line.startsWith('#') && !line.startsWith(';') &&
|
|
102
|
+
!/^[\w.-]+\s*=\s*$/.test(line)) add(line, file, i + 1);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const setup = path.join(dir, 'setup.py');
|
|
106
|
+
if (!index.files.has(setup)) continue;
|
|
107
|
+
let tree;
|
|
108
|
+
try { tree = index._getParsedTree(setup, index._readFile(setup), 'python'); } catch { continue; }
|
|
109
|
+
for (const call of tree.rootNode.descendantsOfType('call')) {
|
|
110
|
+
if (!pythonPossibleIdentities(call.childForFieldName('function')).some(name => ['setuptools.setup', 'distutils.core.setup'].includes(name))) continue;
|
|
111
|
+
const unknown = () => entries.push({ unknown: true, file: setup, line: call.startPosition.row + 1 });
|
|
112
|
+
const values = [];
|
|
113
|
+
const expanded = (node, depth = 0) => {
|
|
114
|
+
const value = pythonBoundValue(node);
|
|
115
|
+
if (depth > 8 || value?.type !== 'dictionary') { unknown(); return; }
|
|
116
|
+
for (const pair of value.namedChildren) {
|
|
117
|
+
if (pair.type === 'dictionary_splat') { expanded(pair.namedChild(0), depth + 1); continue; }
|
|
118
|
+
if (pair.type !== 'pair') { unknown(); continue; }
|
|
119
|
+
const key = literal(pair.childForFieldName('key'));
|
|
120
|
+
if (key == null) unknown();
|
|
121
|
+
else if (key === 'entry_points') values.push(pair.childForFieldName('value'));
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
for (const argument of call.childForFieldName('arguments')?.namedChildren || []) {
|
|
125
|
+
if (argument.type === 'keyword_argument' && pythonSourceName(argument.childForFieldName('name')?.text) === 'entry_points') values.push(argument.childForFieldName('value'));
|
|
126
|
+
else if (argument.type === 'dictionary_splat') expanded(argument.namedChild(0));
|
|
127
|
+
}
|
|
128
|
+
for (const expression of values) {
|
|
129
|
+
const value = pythonBoundValue(expression);
|
|
130
|
+
if (value?.type === 'none') continue;
|
|
131
|
+
// setuptools also accepts an INI string as entry_points.
|
|
132
|
+
const ini = literal(value);
|
|
133
|
+
if (ini != null) {
|
|
134
|
+
for (const line of ini.split('\n')) {
|
|
135
|
+
const row = line.trim();
|
|
136
|
+
if (row && !row.startsWith('[') && !row.startsWith('#')) add(row, setup, call.startPosition.row + 1);
|
|
137
|
+
}
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (value?.type !== 'dictionary') { unknown(); continue; }
|
|
141
|
+
for (const pair of value.namedChildren) {
|
|
142
|
+
if (pair.type !== 'pair' || literal(pair.childForFieldName('key')) == null) { unknown(); continue; }
|
|
143
|
+
const group = pythonBoundValue(pair.childForFieldName('value'));
|
|
144
|
+
const rows = ['list', 'tuple'].includes(group?.type) ? group.namedChildren : [group];
|
|
145
|
+
for (const row of rows) {
|
|
146
|
+
const text = literal(row);
|
|
147
|
+
if (text != null) {
|
|
148
|
+
for (const line of text.split('\n').filter(line => line.trim())) add(line, setup, row.startPosition.row + 1);
|
|
149
|
+
} else unknown();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (operation) OP_ENTRIES.set(operation, entries);
|
|
156
|
+
return entries;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = { runtimeEntrypoints, runtimeTargetReaches, pythonStringLiteral: literal };
|
package/core/search.js
CHANGED
|
@@ -18,7 +18,7 @@ const isSafeRegex = require('safe-regex2');
|
|
|
18
18
|
const { RE2JS } = require('re2js');
|
|
19
19
|
const { UcnError } = require('./errors');
|
|
20
20
|
const { paramValues } = require('./registry');
|
|
21
|
-
const { createTestModel, addTestCaseRows, linkFiles } = require('./test-units');
|
|
21
|
+
const { createTestModel, addTestCaseRows, linkFiles, testSite, testSiteKey } = require('./test-units');
|
|
22
22
|
|
|
23
23
|
// Keep the established search-input contract for the canonical catastrophic
|
|
24
24
|
// nested-single-atom family even though the RE2-compatible engine could run it
|
|
@@ -208,14 +208,18 @@ function _applyFindFilters(index, matches, options) {
|
|
|
208
208
|
|
|
209
209
|
// Rank cheaply before adjudicating callers. A broad inventory must never
|
|
210
210
|
// pay for pinned caller resolution on rows the caller will not receive.
|
|
211
|
+
const definitionOrder = (a, b) =>
|
|
212
|
+
(b._fuzzyScore || 0) - (a._fuzzyScore || 0) ||
|
|
213
|
+
Number(isTestFile(a.relativePath || '', a.language)) -
|
|
214
|
+
Number(isTestFile(b.relativePath || '', b.language)) ||
|
|
215
|
+
codeUnitCompare(a.relativePath || '', b.relativePath || '') ||
|
|
216
|
+
a.startLine - b.startLine || codeUnitCompare(a.name, b.name);
|
|
211
217
|
const ranked = filtered.map(symbol => ({
|
|
212
218
|
symbol, counts: index.countSymbolUsages(symbol),
|
|
213
219
|
}));
|
|
214
220
|
if (options.limit > 0 && ranked.length > options.limit) {
|
|
215
221
|
ranked.sort((a, b) => b.counts.total - a.counts.total ||
|
|
216
|
-
|
|
217
|
-
a.symbol.startLine - b.symbol.startLine ||
|
|
218
|
-
codeUnitCompare(a.symbol.name, b.symbol.name));
|
|
222
|
+
definitionOrder(a.symbol, b.symbol));
|
|
219
223
|
}
|
|
220
224
|
const survivors = options.limit > 0 ? ranked.slice(0, options.limit) : ranked;
|
|
221
225
|
const withCounts = survivors.map(({ symbol: m, counts }) => {
|
|
@@ -257,7 +261,7 @@ function _applyFindFilters(index, matches, options) {
|
|
|
257
261
|
});
|
|
258
262
|
|
|
259
263
|
// Sort by usage count (most-used first)
|
|
260
|
-
withCounts.sort((a, b) => b.usageCount - a.usageCount);
|
|
264
|
+
withCounts.sort((a, b) => b.usageCount - a.usageCount || definitionOrder(a, b));
|
|
261
265
|
|
|
262
266
|
return withCounts;
|
|
263
267
|
}
|
|
@@ -1328,7 +1332,7 @@ function tests(index, nameOrFile, options = {}) {
|
|
|
1328
1332
|
let sourceFileFilter = null;
|
|
1329
1333
|
let targetDefs = [];
|
|
1330
1334
|
if (options.file && !isFilePath) {
|
|
1331
|
-
targetDefs = index.find(nameOrFile, { exact: true, file: options.file, className: options.className });
|
|
1335
|
+
targetDefs = index.find(nameOrFile, { exact: true, file: options.file, className: options.className, skipCounts: true });
|
|
1332
1336
|
if (targetDefs.length > 0) {
|
|
1333
1337
|
sourceFileFilter = _buildSourceFileImporters(index, targetDefs);
|
|
1334
1338
|
}
|
|
@@ -1336,12 +1340,11 @@ function tests(index, nameOrFile, options = {}) {
|
|
|
1336
1340
|
// The execute handler validates before calling, so this path means
|
|
1337
1341
|
// the file matched but no exact symbol — fall through gracefully.
|
|
1338
1342
|
} else if (!isFilePath) {
|
|
1339
|
-
targetDefs = index.find(nameOrFile, { exact: true, className: options.className });
|
|
1343
|
+
targetDefs = index.find(nameOrFile, { exact: true, className: options.className, skipCounts: true });
|
|
1340
1344
|
}
|
|
1341
1345
|
// A pinned definition among same-name ones (an overload, a constructor
|
|
1342
1346
|
// beside its class and siblings, fix #430): calls are this definition's
|
|
1343
1347
|
// only when the caller engine attributes them to it.
|
|
1344
|
-
const pinnedFamily = !isFilePath && options.definition && targetDefs.length > 1;
|
|
1345
1348
|
if (!isFilePath && options.definition) targetDefs = [options.definition];
|
|
1346
1349
|
|
|
1347
1350
|
// Find all test files
|
|
@@ -1381,6 +1384,30 @@ function tests(index, nameOrFile, options = {}) {
|
|
|
1381
1384
|
const searchTerm = isFilePath
|
|
1382
1385
|
? path.basename(nameOrFile, path.extname(nameOrFile))
|
|
1383
1386
|
: nameOrFile;
|
|
1387
|
+
const callerSites = [];
|
|
1388
|
+
const excludedCallableLines = new Map();
|
|
1389
|
+
const globallyUniqueTarget = !isFilePath && targetDefs.length === 1;
|
|
1390
|
+
if (!isFilePath) for (const def of targetDefs) {
|
|
1391
|
+
const raw = index.findCallers(searchTerm, {
|
|
1392
|
+
targetDefinitions: [def], collectAccount: true, unverifiedEnrichLimit: Infinity,
|
|
1393
|
+
});
|
|
1394
|
+
callerSites.push(
|
|
1395
|
+
...raw.map(site => ({ ...site, _testEvidenceTier: 'confirmed' })),
|
|
1396
|
+
...(globallyUniqueTarget ? (raw.unverifiedEntries || []).map(site => ({
|
|
1397
|
+
...site, _testEvidenceTier: 'unverified',
|
|
1398
|
+
})) : []),
|
|
1399
|
+
);
|
|
1400
|
+
const counted = new Set();
|
|
1401
|
+
for (const site of raw.accountRaw?.excludedEntries || []) {
|
|
1402
|
+
const key = `${site.file}:${site.line}`;
|
|
1403
|
+
if (counted.has(key)) continue;
|
|
1404
|
+
counted.add(key);
|
|
1405
|
+
let lines = excludedCallableLines.get(site.file);
|
|
1406
|
+
if (!lines) { lines = new Map(); excludedCallableLines.set(site.file, lines); }
|
|
1407
|
+
lines.set(site.line, (lines.get(site.line) || 0) + 1);
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
const acceptedCallableLines = new Set(callerSites.map(site => `${site.file}:${site.line}`));
|
|
1384
1411
|
|
|
1385
1412
|
// File-path target: module specifiers are string literals, so the
|
|
1386
1413
|
// basename usage scan is blind to import-linked tests in JS/TS
|
|
@@ -1420,7 +1447,7 @@ function tests(index, nameOrFile, options = {}) {
|
|
|
1420
1447
|
targetDefinitions: [def],
|
|
1421
1448
|
collectAccount: true,
|
|
1422
1449
|
});
|
|
1423
|
-
const globallyUnique = index.
|
|
1450
|
+
const globallyUnique = index.symbols.get(def.name)?.length === 1;
|
|
1424
1451
|
const sites = [
|
|
1425
1452
|
...raw.map(site => ({ ...site, _testEvidenceTier: 'confirmed' })),
|
|
1426
1453
|
...(globallyUnique ? (raw.unverifiedEntries || []).map(site => ({
|
|
@@ -1558,7 +1585,18 @@ function tests(index, nameOrFile, options = {}) {
|
|
|
1558
1585
|
// own definition (fix #246).
|
|
1559
1586
|
if (localShadow && !usage.receiver && usage.usageType !== 'import') continue;
|
|
1560
1587
|
|
|
1561
|
-
|
|
1588
|
+
// File eligibility only discovers candidates. A call's
|
|
1589
|
+
// spelling does not establish which declaration it reaches;
|
|
1590
|
+
// exact-target caller evidence below owns every call row.
|
|
1591
|
+
if (targetDefs.length > 0 && usage.usageType === 'call') continue;
|
|
1592
|
+
const classBodyProof = !usage.receiver && usage.usageType === 'reference' &&
|
|
1593
|
+
classBodyReferenceTargets(index, testPath, entry.language, usage, targetDefs);
|
|
1594
|
+
// Method/function values are caller candidates too. A file
|
|
1595
|
+
// importing any sibling cannot resurrect a reference the
|
|
1596
|
+
// engine attributed to another declaration.
|
|
1597
|
+
if (usage.usageType !== 'import' &&
|
|
1598
|
+
excludedCallableLines.get(testPath)?.get(usage.line) === targetDefs.length &&
|
|
1599
|
+
!acceptedCallableLines.has(`${testPath}:${usage.line}`) && !classBodyProof) continue;
|
|
1562
1600
|
// A line is claimed only by a usage that is recorded: two
|
|
1563
1601
|
// same-kind tokens on one line (`a.m = b.m`) are judged
|
|
1564
1602
|
// separately, so a rejected first token never hides a
|
|
@@ -1644,6 +1682,7 @@ function tests(index, nameOrFile, options = {}) {
|
|
|
1644
1682
|
const nmUsages = index._getCachedUsages(testPath, nm) || [];
|
|
1645
1683
|
for (const u of nmUsages) {
|
|
1646
1684
|
if (u.usageType === 'definition' || u.usageType === 'import') continue;
|
|
1685
|
+
if (u.usageType === 'call') continue; // exact file-target evidence below
|
|
1647
1686
|
if (testRanges && !lineInRanges(u.line, testRanges)) continue;
|
|
1648
1687
|
const lineKey = `${u.line}:${u.usageType}:${nm}`;
|
|
1649
1688
|
if (seenLines.has(lineKey)) continue;
|
|
@@ -1667,9 +1706,9 @@ function tests(index, nameOrFile, options = {}) {
|
|
|
1667
1706
|
const matchType = site._testEvidenceTier === 'confirmed'
|
|
1668
1707
|
? 'call'
|
|
1669
1708
|
: 'unverified-call';
|
|
1670
|
-
if (matches.some(m => m
|
|
1709
|
+
if (matches.some(m => testSiteKey(m) === testSiteKey(site) && m.matchType === matchType)) continue;
|
|
1671
1710
|
matches.push({
|
|
1672
|
-
|
|
1711
|
+
...testSite(site),
|
|
1673
1712
|
content: (site.content || index.getLineContent(testPath, site.line) || '').trim(),
|
|
1674
1713
|
matchType,
|
|
1675
1714
|
evidenceTier: site._testEvidenceTier,
|
|
@@ -1712,35 +1751,18 @@ function tests(index, nameOrFile, options = {}) {
|
|
|
1712
1751
|
}
|
|
1713
1752
|
}
|
|
1714
1753
|
|
|
1715
|
-
//
|
|
1716
|
-
//
|
|
1717
|
-
//
|
|
1718
|
-
//
|
|
1719
|
-
// miss calls that `about`/`impact` already know about. For a globally
|
|
1754
|
+
// Callable test links use the caller engine's exact-target decisions,
|
|
1755
|
+
// including aliases and function values that do not spell the target's
|
|
1756
|
+
// name. The literal scan contributes imports/type/string references,
|
|
1757
|
+
// never an independent call identity. For a globally
|
|
1720
1758
|
// unique callable name, also retain account-unverified sites under the
|
|
1721
1759
|
// explicit `unverified-call` label: independent compiler/LSP evaluation
|
|
1722
1760
|
// shows these contain real interface/trait dispatch that a test-discovery
|
|
1723
1761
|
// command must not silently drop. Repeated names never receive that broad
|
|
1724
1762
|
// supplement because it would leak sibling classes/files.
|
|
1725
1763
|
if (!isFilePath && targetDefs.length > 0) {
|
|
1726
|
-
const callerSites = [];
|
|
1727
1764
|
// A pinned definition's unverified sites were adjudicated against it
|
|
1728
1765
|
// alone, like a globally unique name's (fix #430).
|
|
1729
|
-
const globallyUniqueTarget = !!options.definition || index.find(searchTerm, { exact: true }).length === 1;
|
|
1730
|
-
for (const def of targetDefs) {
|
|
1731
|
-
const raw = index.findCallers(searchTerm, {
|
|
1732
|
-
targetDefinitions: [def],
|
|
1733
|
-
collectAccount: true,
|
|
1734
|
-
});
|
|
1735
|
-
callerSites.push(
|
|
1736
|
-
...raw.map(c => ({ ...c, _testEvidenceTier: 'confirmed' })),
|
|
1737
|
-
...(globallyUniqueTarget ? (raw.unverifiedEntries || []).map(c => ({
|
|
1738
|
-
...c,
|
|
1739
|
-
_testEvidenceTier: 'unverified',
|
|
1740
|
-
})) : []),
|
|
1741
|
-
);
|
|
1742
|
-
}
|
|
1743
|
-
|
|
1744
1766
|
for (const site of callerSites) {
|
|
1745
1767
|
const info = testInfoOf(site.file);
|
|
1746
1768
|
if (!info) continue;
|
|
@@ -1771,12 +1793,12 @@ function tests(index, nameOrFile, options = {}) {
|
|
|
1771
1793
|
fileResult = { file: info.entry.relativePath, matches: [] };
|
|
1772
1794
|
results.push(fileResult);
|
|
1773
1795
|
}
|
|
1774
|
-
if (fileResult.matches.some(m => m
|
|
1796
|
+
if (fileResult.matches.some(m => testSiteKey(m) === testSiteKey(site) &&
|
|
1775
1797
|
['call', 'unverified-call'].includes(m.matchType))) continue;
|
|
1776
1798
|
fileResult.matches = fileResult.matches.filter(m =>
|
|
1777
1799
|
!(m.line === site.line && m.matchType === 'test-case'));
|
|
1778
1800
|
fileResult.matches.push({
|
|
1779
|
-
|
|
1801
|
+
...testSite(site),
|
|
1780
1802
|
content: (site.content || index.getLineContent(site.file, site.line) || '').trim(),
|
|
1781
1803
|
matchType: site._testEvidenceTier === 'confirmed' ? 'call' : 'unverified-call',
|
|
1782
1804
|
evidenceTier: site._testEvidenceTier,
|
|
@@ -1871,7 +1893,7 @@ function tests(index, nameOrFile, options = {}) {
|
|
|
1871
1893
|
titleNeedsMatch: !!className,
|
|
1872
1894
|
});
|
|
1873
1895
|
linesByFile.set(testPath,
|
|
1874
|
-
fileResult.matches.filter(match => match.matchType !== 'import')
|
|
1896
|
+
fileResult.matches.filter(match => match.matchType !== 'import'));
|
|
1875
1897
|
}
|
|
1876
1898
|
// A test a class inherits runs where the class is: the file of a class
|
|
1877
1899
|
// that inherits a linked test is a result of its own, with the test.
|
|
@@ -2261,9 +2283,10 @@ function _deduplicateMatches(matches) {
|
|
|
2261
2283
|
const byLine = new Map();
|
|
2262
2284
|
const priority = { 'test-case': 5, 'call': 4, 'import': 3, 'string-ref': 2, 'reference': 1 };
|
|
2263
2285
|
for (const m of matches) {
|
|
2264
|
-
const
|
|
2286
|
+
const key = testSiteKey(m);
|
|
2287
|
+
const existing = byLine.get(key);
|
|
2265
2288
|
if (!existing || (priority[m.matchType] || 0) > (priority[existing.matchType] || 0)) {
|
|
2266
|
-
byLine.set(
|
|
2289
|
+
byLine.set(key, m);
|
|
2267
2290
|
}
|
|
2268
2291
|
}
|
|
2269
2292
|
return [...byLine.values()].sort((a, b) => a.line - b.line);
|