ucn 5.0.2 → 5.0.3
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/core/analysis.js +9 -1
- package/core/bridge.js +252 -35
- package/core/cache.js +1 -1
- package/core/check.js +13 -1
- package/core/execute.js +20 -1
- package/core/output/analysis-ext.js +5 -0
- package/core/output/refactoring.js +8 -1
- package/core/public-command.js +5 -2
- package/core/verify.js +171 -33
- package/languages/index.js +15 -0
- package/languages/utils.js +26 -0
- package/package.json +1 -1
package/core/analysis.js
CHANGED
|
@@ -1768,6 +1768,8 @@ function diffImpact(index, options = {}) {
|
|
|
1768
1768
|
if (!diffText || !diffText.trim()) {
|
|
1769
1769
|
return {
|
|
1770
1770
|
base: staged ? '(staged)' : base,
|
|
1771
|
+
changedPaths: 0,
|
|
1772
|
+
nonSourcePaths: 0,
|
|
1771
1773
|
functions: [],
|
|
1772
1774
|
moduleLevelChanges: [],
|
|
1773
1775
|
newFunctions: [],
|
|
@@ -1803,9 +1805,13 @@ function diffImpact(index, options = {}) {
|
|
|
1803
1805
|
let totalCallSites = 0;
|
|
1804
1806
|
let totalUnverifiedSites = 0;
|
|
1805
1807
|
|
|
1808
|
+
// fix #283: count changed paths the analysis can't see (docs, config,
|
|
1809
|
+
// unsupported languages) so "no changes to analyze" can say why.
|
|
1810
|
+
let nonSourcePaths = 0;
|
|
1811
|
+
|
|
1806
1812
|
for (const change of changes) {
|
|
1807
1813
|
const lang = detectLanguage(change.filePath);
|
|
1808
|
-
if (!lang) continue;
|
|
1814
|
+
if (!lang) { nonSourcePaths++; continue; }
|
|
1809
1815
|
|
|
1810
1816
|
const fileEntry = index.files.get(change.filePath);
|
|
1811
1817
|
|
|
@@ -2172,6 +2178,8 @@ function diffImpact(index, options = {}) {
|
|
|
2172
2178
|
|
|
2173
2179
|
return {
|
|
2174
2180
|
base: staged ? '(staged)' : base,
|
|
2181
|
+
changedPaths: changes.length,
|
|
2182
|
+
nonSourcePaths,
|
|
2175
2183
|
functions,
|
|
2176
2184
|
moduleLevelChanges,
|
|
2177
2185
|
newFunctions,
|
package/core/bridge.js
CHANGED
|
@@ -8,10 +8,13 @@
|
|
|
8
8
|
* server route — across language boundaries.
|
|
9
9
|
*
|
|
10
10
|
* REUSES the call cache (getCachedCalls) and AST-derived symbol metadata
|
|
11
|
-
* (decoratorsWithArgs/annotationsWithArgs/attributesWithArgs).
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* (decoratorsWithArgs/annotationsWithArgs/attributesWithArgs). File I/O is
|
|
12
|
+
* index-driven; the one re-parse is the router-mount scan (fix #282), which
|
|
13
|
+
* AST-parses only the files whose call cache mentions a router factory or an
|
|
14
|
+
* include/mount call — keyword arguments (`prefix=`) and mount references
|
|
15
|
+
* aren't in the call cache. Extraction results are cached lazily on
|
|
16
|
+
* `index._endpointsCache` and invalidated on rebuild via the same mechanism
|
|
17
|
+
* as `_reachableSymbols`.
|
|
15
18
|
*
|
|
16
19
|
* Output shape:
|
|
17
20
|
* serverRoutes: [{ method, path, normalizedPath, handler, file, line, framework, raw }]
|
|
@@ -26,6 +29,7 @@ const fs = require('fs');
|
|
|
26
29
|
const { codeUnitCompare } = require('./shared');
|
|
27
30
|
const path = require('path');
|
|
28
31
|
const { getCachedCalls } = require('./callers');
|
|
32
|
+
const { getParser, safeParse } = require('../languages');
|
|
29
33
|
|
|
30
34
|
// ============================================================================
|
|
31
35
|
// HTTP METHOD CONSTANTS
|
|
@@ -86,6 +90,57 @@ function joinRoutePath(prefix, sub) {
|
|
|
86
90
|
return p + '/' + s;
|
|
87
91
|
}
|
|
88
92
|
|
|
93
|
+
/** Join two PREFIX fragments (fix #282). Unlike joinRoutePath, two empty
|
|
94
|
+
* fragments compose to '' — a router with no mount prefix anywhere must not
|
|
95
|
+
* gain a spurious '/'. */
|
|
96
|
+
function joinPrefixes(a, b) {
|
|
97
|
+
if (!a) return b || '';
|
|
98
|
+
if (!b) return a;
|
|
99
|
+
return a.replace(/\/+$/, '') + '/' + b.replace(/^\/+/, '');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Compose mount-prefix chains transitively (fix #282). A router's full
|
|
104
|
+
* prefix = (each mounter's full prefix + the mount-call prefix) + its own
|
|
105
|
+
* constructor prefix — FastAPI/Flask include semantics; Express has no
|
|
106
|
+
* constructor prefix so `ctorPrefixes` is simply empty there.
|
|
107
|
+
*
|
|
108
|
+
* @param {Map<string, Array<{mounterKey: string|null, prefix: string, overridesCtor?: boolean}>>} edges
|
|
109
|
+
* targetKey -> incoming mounts (mounterKey null = unresolvable mounter,
|
|
110
|
+
* treated as a root). `overridesCtor` models Flask's register_blueprint
|
|
111
|
+
* semantics: an explicit url_prefix REPLACES the blueprint's own prefix
|
|
112
|
+
* (FastAPI's include_router prefix composes with it instead).
|
|
113
|
+
* @param {Map<string, string>} ctorPrefixes - key -> constructor prefix
|
|
114
|
+
* @returns {Map<string, string[]>} key -> composed full prefixes (sorted)
|
|
115
|
+
*/
|
|
116
|
+
function composeMountPrefixes(edges, ctorPrefixes) {
|
|
117
|
+
const memo = new Map();
|
|
118
|
+
const resolve = (key, depth, stack) => {
|
|
119
|
+
if (key == null) return [''];
|
|
120
|
+
if (memo.has(key)) return memo.get(key);
|
|
121
|
+
if (depth > 8 || stack.has(key)) return [''];
|
|
122
|
+
stack.add(key);
|
|
123
|
+
const own = ctorPrefixes.get(key) || '';
|
|
124
|
+
const incoming = edges.get(key) || [];
|
|
125
|
+
const full = incoming.length === 0 ? [own]
|
|
126
|
+
: incoming.flatMap(e => resolve(e.mounterKey, depth + 1, stack)
|
|
127
|
+
.map(parentFull => joinPrefixes(joinPrefixes(parentFull, e.prefix),
|
|
128
|
+
e.overridesCtor ? '' : own)));
|
|
129
|
+
const out = [...new Set(full.length ? full : [own])].sort(codeUnitCompare);
|
|
130
|
+
stack.delete(key);
|
|
131
|
+
memo.set(key, out);
|
|
132
|
+
return out;
|
|
133
|
+
};
|
|
134
|
+
// Only router keys are exposed — mounter-only keys (the app object) are
|
|
135
|
+
// resolved for composition but would shadow group-prefix fallbacks if kept.
|
|
136
|
+
const routerKeys = new Set([...edges.keys(), ...ctorPrefixes.keys()]);
|
|
137
|
+
const out = new Map();
|
|
138
|
+
for (const key of [...routerKeys].sort(codeUnitCompare)) {
|
|
139
|
+
out.set(key, resolve(key, 0, new Set()));
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
89
144
|
// ============================================================================
|
|
90
145
|
// FRAMEWORK PATTERNS
|
|
91
146
|
// ============================================================================
|
|
@@ -281,6 +336,7 @@ function extractServerRoutes(index) {
|
|
|
281
336
|
|
|
282
337
|
const routes = [];
|
|
283
338
|
const mountedPrefixes = collectProjectRouterMounts(index);
|
|
339
|
+
const pythonMounts = collectPythonRouterMounts(index);
|
|
284
340
|
const pythonReceiverFrameworks = new Map();
|
|
285
341
|
for (const [filePath, entry] of index.files) {
|
|
286
342
|
if (entry.language !== 'python') continue;
|
|
@@ -332,18 +388,26 @@ function extractServerRoutes(index) {
|
|
|
332
388
|
const declRoutes = collectMethodRoutes(sym, lang, classPrefix, fileEntry,
|
|
333
389
|
pythonReceiverFrameworks.get(sym.file));
|
|
334
390
|
for (const r of declRoutes) {
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
391
|
+
// fix #282: FastAPI/Flask routers serve under their composed
|
|
392
|
+
// mount prefixes (APIRouter(prefix=) + include_router(prefix=)).
|
|
393
|
+
const prefixes = (lang === 'python' && r.receiver &&
|
|
394
|
+
pythonMounts.get(`${sym.file}:${r.receiver}`)) || [''];
|
|
395
|
+
for (const prefix of prefixes) {
|
|
396
|
+
const fullPath = prefix ? joinRoutePath(prefix, r.path) : r.path;
|
|
397
|
+
routes.push({
|
|
398
|
+
method: r.method,
|
|
399
|
+
path: fullPath,
|
|
400
|
+
normalizedPath: normalizePath(fullPath),
|
|
401
|
+
handler: sym.name,
|
|
402
|
+
file: sym.relativePath || sym.file,
|
|
403
|
+
absoluteFile: sym.file,
|
|
404
|
+
line: sym.startLine,
|
|
405
|
+
framework: r.framework,
|
|
406
|
+
classPrefix: classPrefix || undefined,
|
|
407
|
+
...(prefix && { mountPrefix: prefix }),
|
|
408
|
+
raw: r.raw || `${r.method} ${fullPath}`,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
347
411
|
}
|
|
348
412
|
}
|
|
349
413
|
}
|
|
@@ -476,6 +540,9 @@ function collectMethodRoutes(sym, lang, classPrefix, fileEntry = null,
|
|
|
476
540
|
method: r.method,
|
|
477
541
|
path: r.path,
|
|
478
542
|
framework: r.framework,
|
|
543
|
+
// fix #282: the decorator's receiver variable keys the
|
|
544
|
+
// composed mount-prefix lookup in extractServerRoutes.
|
|
545
|
+
receiver: r.receiver,
|
|
479
546
|
});
|
|
480
547
|
}
|
|
481
548
|
}
|
|
@@ -587,10 +654,10 @@ function parsePythonDecoratorFull(raw, fileEntry = null, receiverFramework = nul
|
|
|
587
654
|
const methods = methodsMatch[1].split(',').map(s => s.trim().replace(/['"]/g, '').toUpperCase()).filter(Boolean);
|
|
588
655
|
// Caller will receive ONE entry; we return GET if methods empty, else first.
|
|
589
656
|
if (methods.length > 0) {
|
|
590
|
-
return { method: methods[0], path: pathStr, framework: 'flask' };
|
|
657
|
+
return { method: methods[0], path: pathStr, framework: 'flask', receiver: m[1] };
|
|
591
658
|
}
|
|
592
659
|
}
|
|
593
|
-
return { method: 'GET', path: pathStr, framework: 'flask' };
|
|
660
|
+
return { method: 'GET', path: pathStr, framework: 'flask', receiver: m[1] };
|
|
594
661
|
}
|
|
595
662
|
if (['get','post','put','delete','patch','options','head'].includes(verb)) {
|
|
596
663
|
const modules = (fileEntry?.imports || []).map(value =>
|
|
@@ -601,43 +668,193 @@ function parsePythonDecoratorFull(raw, fileEntry = null, receiverFramework = nul
|
|
|
601
668
|
? 'flask' : modules.some(module => /^fastapi\b/.test(module)) &&
|
|
602
669
|
!modules.some(module => /^flask\b/.test(module))
|
|
603
670
|
? 'fastapi' : 'unknown-python');
|
|
604
|
-
return { method: verb.toUpperCase(), path: pathStr, framework };
|
|
671
|
+
return { method: verb.toUpperCase(), path: pathStr, framework, receiver: m[1] };
|
|
672
|
+
}
|
|
673
|
+
return null;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// ── Python router mounts (fix #282) ─────────────────────────────────────────
|
|
677
|
+
// FastAPI: `router = APIRouter(prefix="/api")` + `app.include_router(r, prefix="/v2")`.
|
|
678
|
+
// Flask: `bp = Blueprint(..., url_prefix="/api")` + `app.register_blueprint(bp, url_prefix="/v2")`.
|
|
679
|
+
// Neither prefix mechanism is in the call cache (both are keyword arguments,
|
|
680
|
+
// and the mounted router is a non-string argument), so every route rendered
|
|
681
|
+
// with its bare decorator path — /list for the real /api/things/list — and
|
|
682
|
+
// --bridge matched nothing on a prefixed application.
|
|
683
|
+
const PY_ROUTER_FACTORIES = new Set(['APIRouter', 'Blueprint']);
|
|
684
|
+
const PY_INCLUDE_METHODS = new Set(['include_router', 'register_blueprint']);
|
|
685
|
+
const PY_PREFIX_KWARGS = new Set(['prefix', 'url_prefix']);
|
|
686
|
+
|
|
687
|
+
/** Unquote a Python string literal node ("/api", '/api', r"/api"). */
|
|
688
|
+
function pyStringText(node) {
|
|
689
|
+
if (!node || node.type !== 'string') return null;
|
|
690
|
+
const m = node.text.match(/^[rbuf]{0,2}(['"])([\s\S]*)\1$/i);
|
|
691
|
+
return m ? m[2] : null;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/** First `prefix=`/`url_prefix=` string keyword argument of a call node. */
|
|
695
|
+
function pyPrefixKwarg(argsNode) {
|
|
696
|
+
if (!argsNode) return null;
|
|
697
|
+
for (let i = 0; i < argsNode.namedChildCount; i++) {
|
|
698
|
+
const arg = argsNode.namedChild(i);
|
|
699
|
+
if (arg.type !== 'keyword_argument') continue;
|
|
700
|
+
const nameNode = arg.childForFieldName('name') || arg.namedChild(0);
|
|
701
|
+
if (!nameNode || !PY_PREFIX_KWARGS.has(nameNode.text)) continue;
|
|
702
|
+
const valueNode = arg.childForFieldName('value') || arg.namedChild(1);
|
|
703
|
+
return pyStringText(valueNode);
|
|
605
704
|
}
|
|
606
705
|
return null;
|
|
607
706
|
}
|
|
608
707
|
|
|
609
|
-
/**
|
|
708
|
+
/**
|
|
709
|
+
* Map Python router variables to their composed mount prefixes.
|
|
710
|
+
* Returns Map `${absFile}:${routerVar}` -> [full prefix strings].
|
|
711
|
+
* Only files whose call cache mentions a router factory or include call are
|
|
712
|
+
* AST-parsed; unresolvable mount references contribute no edge (the route
|
|
713
|
+
* keeps its bare path — conservative under the advisory contract).
|
|
714
|
+
*/
|
|
715
|
+
function collectPythonRouterMounts(index) {
|
|
716
|
+
const relevant = [];
|
|
717
|
+
for (const [filePath, entry] of index.files) {
|
|
718
|
+
if (entry.language !== 'python') continue;
|
|
719
|
+
const calls = getCachedCalls(index, filePath) || [];
|
|
720
|
+
if (calls.some(c => (PY_ROUTER_FACTORIES.has(c.name) && c.assignedTo) ||
|
|
721
|
+
PY_INCLUDE_METHODS.has(c.name))) {
|
|
722
|
+
relevant.push([filePath, entry]);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
if (relevant.length === 0) return new Map();
|
|
726
|
+
let parser;
|
|
727
|
+
try { parser = getParser('python'); } catch (e) { return new Map(); }
|
|
728
|
+
if (!parser) return new Map();
|
|
729
|
+
|
|
730
|
+
const ctorPrefixes = new Map();
|
|
731
|
+
const edges = new Map();
|
|
732
|
+
for (const [filePath, entry] of relevant) {
|
|
733
|
+
let tree = null;
|
|
734
|
+
try {
|
|
735
|
+
tree = safeParse(parser, fs.readFileSync(filePath, 'utf8'));
|
|
736
|
+
} catch (e) { /* unreadable/unparseable → no prefixes from this file */ }
|
|
737
|
+
if (!tree) continue;
|
|
738
|
+
const visit = (node) => {
|
|
739
|
+
if (node.type === 'call') visitPyRouterCall(node, filePath, entry, index, ctorPrefixes, edges);
|
|
740
|
+
for (let i = 0; i < node.namedChildCount; i++) visit(node.namedChild(i));
|
|
741
|
+
};
|
|
742
|
+
visit(tree.rootNode);
|
|
743
|
+
}
|
|
744
|
+
return composeMountPrefixes(edges, ctorPrefixes);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function visitPyRouterCall(callNode, filePath, entry, index, ctorPrefixes, edges) {
|
|
748
|
+
const fn = callNode.childForFieldName('function');
|
|
749
|
+
if (!fn) return;
|
|
750
|
+
|
|
751
|
+
// `<var> = APIRouter(prefix="/api")` — constructor prefix.
|
|
752
|
+
if (fn.type === 'identifier' && PY_ROUTER_FACTORIES.has(fn.text)) {
|
|
753
|
+
const parent = callNode.parent;
|
|
754
|
+
if (!parent || parent.type !== 'assignment') return;
|
|
755
|
+
const left = parent.childForFieldName('left');
|
|
756
|
+
if (!left || left.type !== 'identifier') return;
|
|
757
|
+
const prefix = pyPrefixKwarg(callNode.childForFieldName('arguments'));
|
|
758
|
+
if (prefix) ctorPrefixes.set(`${filePath}:${left.text}`, prefix);
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// `<recv>.include_router(<ref>, prefix="/v2")` — mount edge.
|
|
763
|
+
if (fn.type !== 'attribute') return;
|
|
764
|
+
const attrNode = fn.childForFieldName('attribute');
|
|
765
|
+
if (!attrNode || !PY_INCLUDE_METHODS.has(attrNode.text)) return;
|
|
766
|
+
const recvNode = fn.childForFieldName('object');
|
|
767
|
+
// Non-identifier receivers (self.app, factories) can't be keyed — treat
|
|
768
|
+
// as a root mounter so the edge prefix still applies.
|
|
769
|
+
const mounterKey = recvNode && recvNode.type === 'identifier'
|
|
770
|
+
? `${filePath}:${recvNode.text}` : null;
|
|
771
|
+
const argsNode = callNode.childForFieldName('arguments');
|
|
772
|
+
if (!argsNode) return;
|
|
773
|
+
let refNode = null;
|
|
774
|
+
for (let i = 0; i < argsNode.namedChildCount; i++) {
|
|
775
|
+
const arg = argsNode.namedChild(i);
|
|
776
|
+
if (arg.type === 'keyword_argument' || arg.type.includes('comment')) continue;
|
|
777
|
+
refNode = arg;
|
|
778
|
+
break;
|
|
779
|
+
}
|
|
780
|
+
if (!refNode) return;
|
|
781
|
+
const explicitPrefix = pyPrefixKwarg(argsNode);
|
|
782
|
+
const prefix = explicitPrefix || '';
|
|
783
|
+
// Flask: register_blueprint's url_prefix REPLACES the blueprint's own
|
|
784
|
+
// url_prefix; FastAPI's include_router prefix composes with it.
|
|
785
|
+
const overridesCtor = explicitPrefix != null && attrNode.text === 'register_blueprint';
|
|
786
|
+
|
|
787
|
+
let targetKey = null;
|
|
788
|
+
if (refNode.type === 'attribute') {
|
|
789
|
+
// `app.include_router(mod.router)` — module attribute.
|
|
790
|
+
const obj = refNode.childForFieldName('object');
|
|
791
|
+
const attr = refNode.childForFieldName('attribute');
|
|
792
|
+
if (obj && obj.type === 'identifier' && attr) {
|
|
793
|
+
const binding = (entry.importBindings || []).find(b => b.name === obj.text);
|
|
794
|
+
const rel = binding && entry.moduleResolved?.[binding.module];
|
|
795
|
+
if (rel) targetKey = `${path.join(index.root, rel)}:${attr.text}`;
|
|
796
|
+
}
|
|
797
|
+
} else if (refNode.type === 'identifier') {
|
|
798
|
+
// Bare name: alias-aware from-import first, then same-file variable.
|
|
799
|
+
const aliasEntry = (entry.importAliases || []).find(a => a.local === refNode.text);
|
|
800
|
+
const original = aliasEntry ? aliasEntry.original : refNode.text;
|
|
801
|
+
const binding = (entry.importBindings || []).find(b =>
|
|
802
|
+
b.name === original && entry.moduleResolved?.[b.module]);
|
|
803
|
+
targetKey = binding
|
|
804
|
+
? `${path.join(index.root, entry.moduleResolved[binding.module])}:${original}`
|
|
805
|
+
: `${filePath}:${refNode.text}`;
|
|
806
|
+
}
|
|
807
|
+
if (!targetKey) return;
|
|
808
|
+
const list = edges.get(targetKey) || [];
|
|
809
|
+
list.push({ mounterKey, prefix, ...(overridesCtor && { overridesCtor }) });
|
|
810
|
+
edges.set(targetKey, list);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/** Map exported router receiver variables to their literal project mounts.
|
|
814
|
+
* fix #282: same-file mounts (`app.use('/api', localRouter)`), NAMED-export
|
|
815
|
+
* routers, and transitive composition (`app.use('/api', parent)` +
|
|
816
|
+
* `parent.use('/sub', child)` → child serves under /api/sub). */
|
|
610
817
|
function collectProjectRouterMounts(index) {
|
|
611
|
-
const
|
|
818
|
+
const edges = new Map();
|
|
612
819
|
for (const [filePath, fileEntry] of index.files) {
|
|
613
820
|
if (!['javascript', 'typescript', 'tsx'].includes(fileEntry.language)) continue;
|
|
614
821
|
const calls = getCachedCalls(index, filePath) || [];
|
|
822
|
+
const localRouters = collectRouterReceivers(calls, fileEntry.language);
|
|
615
823
|
for (const call of calls) {
|
|
616
824
|
if (call.name !== 'use' || !call.receiver || !call.firstStringArg ||
|
|
617
825
|
(call.argCount != null && call.argCount < 2)) continue;
|
|
826
|
+
const mounterKey = `${filePath}:${call.receiver}`;
|
|
618
827
|
const refs = calls.filter(candidate => candidate.line === call.line &&
|
|
619
828
|
candidate !== call && (candidate.isFunctionReference || candidate.isPotentialCallback));
|
|
620
829
|
for (const ref of refs) {
|
|
830
|
+
const targetKeys = [];
|
|
621
831
|
const binding = (fileEntry.importBindings || []).find(item => item.name === ref.name);
|
|
622
832
|
const rel = binding && fileEntry.moduleResolved?.[binding.module];
|
|
623
|
-
if (
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
833
|
+
if (rel) {
|
|
834
|
+
const targetFile = path.join(index.root, rel);
|
|
835
|
+
const targetEntry = index.files.get(targetFile);
|
|
836
|
+
if (targetEntry) {
|
|
837
|
+
const exportedReceivers = (targetEntry.exportDetails || [])
|
|
838
|
+
.filter(exp => exp.type === 'module.exports' || exp.isDefault ||
|
|
839
|
+
exp.kind === 'default' || exp.type === 'export-default' ||
|
|
840
|
+
exp.name === ref.name)
|
|
841
|
+
.map(exp => exp.localName || exp.name).filter(Boolean);
|
|
842
|
+
for (const receiver of exportedReceivers) {
|
|
843
|
+
targetKeys.push(`${targetFile}:${receiver}`);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
} else if (localRouters.has(ref.name)) {
|
|
847
|
+
targetKeys.push(`${filePath}:${ref.name}`);
|
|
848
|
+
}
|
|
849
|
+
for (const key of targetKeys) {
|
|
850
|
+
const list = edges.get(key) || [];
|
|
851
|
+
list.push({ mounterKey, prefix: call.firstStringArg });
|
|
852
|
+
edges.set(key, list);
|
|
636
853
|
}
|
|
637
854
|
}
|
|
638
855
|
}
|
|
639
856
|
}
|
|
640
|
-
return
|
|
857
|
+
return composeMountPrefixes(edges, new Map());
|
|
641
858
|
}
|
|
642
859
|
|
|
643
860
|
/**
|
package/core/cache.js
CHANGED
|
@@ -599,7 +599,7 @@ function clearAllCaches() {
|
|
|
599
599
|
// v166: C++ nested aliases persist their lexical owner ranges, and `auto`
|
|
600
600
|
// return functions persist a unanimously inferred local concrete type. v165
|
|
601
601
|
// was used during prerelease development before both fields were complete.
|
|
602
|
-
const CACHE_FORMAT_VERSION =
|
|
602
|
+
const CACHE_FORMAT_VERSION = 167;
|
|
603
603
|
|
|
604
604
|
/**
|
|
605
605
|
* Save index to cache file
|
package/core/check.js
CHANGED
|
@@ -80,13 +80,25 @@ function check(index, options = {}) {
|
|
|
80
80
|
];
|
|
81
81
|
|
|
82
82
|
if (!dr || (modified.length === 0 && added.length === 0 && deleted.length === 0)) {
|
|
83
|
+
// fix #283: say what the diff actually contained. "no changes
|
|
84
|
+
// detected" with three changed files reads as a false negative in a
|
|
85
|
+
// pre-commit hook — the truth is the changes are outside what the
|
|
86
|
+
// symbol analysis can see.
|
|
87
|
+
const changedPaths = dr?.changedPaths || 0;
|
|
88
|
+
const nonSourcePaths = dr?.nonSourcePaths || 0;
|
|
89
|
+
let reason = 'no changes detected';
|
|
90
|
+
if (changedPaths > 0 && nonSourcePaths === changedPaths) {
|
|
91
|
+
reason = `${changedPaths} changed path(s), all outside supported source files`;
|
|
92
|
+
} else if (changedPaths > 0) {
|
|
93
|
+
reason = 'no callable-symbol changes in the diff';
|
|
94
|
+
}
|
|
83
95
|
return {
|
|
84
96
|
base: options.base || 'HEAD',
|
|
85
97
|
staged: !!options.staged,
|
|
86
98
|
ok: true,
|
|
87
99
|
status: 'clean',
|
|
88
100
|
empty: true,
|
|
89
|
-
reason
|
|
101
|
+
reason,
|
|
90
102
|
};
|
|
91
103
|
}
|
|
92
104
|
|
package/core/execute.js
CHANGED
|
@@ -65,6 +65,15 @@ function editDistance(a, b) {
|
|
|
65
65
|
|
|
66
66
|
function symbolNotFound(index, name, label = 'Symbol') {
|
|
67
67
|
const query = String(name || '');
|
|
68
|
+
// Malformed handle (fix #283): `path:name` is a handle missing its line
|
|
69
|
+
// segment, not a symbol whose name contains a colon (identifiers can't).
|
|
70
|
+
// Saying "not found" here steered users into concluding the index missed
|
|
71
|
+
// the symbol. Single colon only — Rust `Type::method` paths never match.
|
|
72
|
+
const handleish = query.match(/^([^:]+):([A-Za-z_$][\w$]*)$/);
|
|
73
|
+
if (handleish && /[/\\]|\.[A-Za-z0-9]{1,8}$/.test(handleish[1])) {
|
|
74
|
+
return `Malformed handle "${query}": expected file:line:name. ` +
|
|
75
|
+
`Run find "${handleish[2]}" to get the exact handle.`;
|
|
76
|
+
}
|
|
68
77
|
const lower = query.toLowerCase();
|
|
69
78
|
let best = null;
|
|
70
79
|
for (const candidate of index.symbols.keys()) {
|
|
@@ -822,7 +831,13 @@ const HANDLERS = {
|
|
|
822
831
|
impact: (index, p) => {
|
|
823
832
|
// Public v5 overload: no symbol means Git-diff impact. This replaces
|
|
824
833
|
// the separate diff-impact command without changing the symbol path.
|
|
825
|
-
|
|
834
|
+
// An EXPLICIT empty string is an error, not diff mode (fix #283): a
|
|
835
|
+
// pipeline whose handle extraction produced "" would otherwise get a
|
|
836
|
+
// diff answer indistinguishable from "this symbol has no callers".
|
|
837
|
+
if (typeof p.name === 'string' && p.name.trim() === '') {
|
|
838
|
+
return { ok: false, error: 'Empty symbol target. Omit the argument entirely for Git-diff impact, or pass a symbol name/handle.' };
|
|
839
|
+
}
|
|
840
|
+
if (p.name == null) {
|
|
826
841
|
const response = HANDLERS.diffImpact(index, { ...p });
|
|
827
842
|
if (response.ok) addMode(response.result, 'diff');
|
|
828
843
|
return response;
|
|
@@ -1078,6 +1093,10 @@ const HANDLERS = {
|
|
|
1078
1093
|
check: (index, p) => {
|
|
1079
1094
|
// Public v5 overload: a symbol target performs the former verify
|
|
1080
1095
|
// operation; a target-less invocation remains the diff/precommit check.
|
|
1096
|
+
// An EXPLICIT empty string is an error, not diff mode (fix #283).
|
|
1097
|
+
if (typeof p.name === 'string' && p.name.trim() === '') {
|
|
1098
|
+
return { ok: false, error: 'Empty symbol target. Omit the argument entirely for the pre-commit diff check, or pass a symbol name/handle.' };
|
|
1099
|
+
}
|
|
1081
1100
|
if (p.name && String(p.name).trim()) {
|
|
1082
1101
|
const response = HANDLERS.verify(index, { ...p });
|
|
1083
1102
|
if (response.ok) addMode(response.result, 'symbol');
|
|
@@ -210,6 +210,11 @@ function formatDiffImpact(result, options = {}) {
|
|
|
210
210
|
parts.push(`${s.totalCallSites || 0} call sites across ${s.affectedFiles || 0} files`);
|
|
211
211
|
if (s.unverifiedCallSites > 0) parts.push(`${s.unverifiedCallSites} unverified`);
|
|
212
212
|
lines.push(parts.join(', '));
|
|
213
|
+
// fix #283: changed paths outside supported source are invisible to the
|
|
214
|
+
// symbol analysis — disclose instead of silently narrowing the diff.
|
|
215
|
+
if (result.nonSourcePaths > 0) {
|
|
216
|
+
lines.push(`Note: ${result.nonSourcePaths} changed path(s) outside supported source files not analyzed.`);
|
|
217
|
+
}
|
|
213
218
|
lines.push('');
|
|
214
219
|
|
|
215
220
|
// Modified functions
|
|
@@ -274,7 +274,13 @@ function formatVerify(result, options = {}) {
|
|
|
274
274
|
const flags = _formatPatternFlags(m.patterns);
|
|
275
275
|
lines.push(` ${m.file}:${m.line}${flags}`);
|
|
276
276
|
lines.push(` ${m.expression}`);
|
|
277
|
-
|
|
277
|
+
// fix #281: keyword-binding problems carry their own sentence —
|
|
278
|
+
// the raw count fits, so "Expected N, got N" would be misleading.
|
|
279
|
+
if (m.problem) {
|
|
280
|
+
lines.push(` ${m.problem}: [${m.args?.join(', ') || ''}]`);
|
|
281
|
+
} else {
|
|
282
|
+
lines.push(` Expected ${m.expected}, got ${m.actual}: [${m.args?.join(', ') || ''}]`);
|
|
283
|
+
}
|
|
278
284
|
}
|
|
279
285
|
}
|
|
280
286
|
|
|
@@ -343,6 +349,7 @@ function formatVerifyJson(result) {
|
|
|
343
349
|
expression: m.expression,
|
|
344
350
|
expected: m.expected,
|
|
345
351
|
actual: m.actual,
|
|
352
|
+
...(m.problem && { problem: m.problem }),
|
|
346
353
|
args: m.args || [],
|
|
347
354
|
patterns: m.patterns,
|
|
348
355
|
})),
|
package/core/public-command.js
CHANGED
|
@@ -31,10 +31,13 @@ function buildPublicParams(command, arg, params = {}) {
|
|
|
31
31
|
switch (command) {
|
|
32
32
|
case 'search': return { ...clean, term: arg || clean.term };
|
|
33
33
|
case 'source': return parseSourceTarget(arg || clean.name, clean);
|
|
34
|
-
|
|
34
|
+
// fix #283: an EXPLICIT empty-string arg must reach the handler (which
|
|
35
|
+
// rejects it) instead of silently selecting diff mode — `arg ? ...`
|
|
36
|
+
// made `impact ""` indistinguishable from `impact`.
|
|
37
|
+
case 'impact': return { ...clean, ...(arg != null ? { name: arg } : {}) };
|
|
35
38
|
case 'deps': return { ...clean, file: arg || clean.file };
|
|
36
39
|
case 'api': return { ...clean, file: arg || clean.file };
|
|
37
|
-
case 'check': return { ...clean, ...(arg ? { name: arg } : {}) };
|
|
40
|
+
case 'check': return { ...clean, ...(arg != null ? { name: arg } : {}) };
|
|
38
41
|
case 'stacktrace': return { ...clean, stack: clean.stack || arg };
|
|
39
42
|
default: return clean;
|
|
40
43
|
}
|
package/core/verify.js
CHANGED
|
@@ -413,13 +413,28 @@ function formatTypedSignature(def, overrides = {}) {
|
|
|
413
413
|
const ps = overrides.paramsStructured != null ? overrides.paramsStructured : def.paramsStructured;
|
|
414
414
|
if (Array.isArray(ps)) {
|
|
415
415
|
const paramTypes = def.paramTypes || {};
|
|
416
|
-
|
|
416
|
+
// Python binding-position markers (fix #281): re-render the bare `*`
|
|
417
|
+
// before the first keyword-only param (unless `*args` already plays
|
|
418
|
+
// that role) and the `/` after the last positional-only param, so the
|
|
419
|
+
// displayed signature matches the source contract.
|
|
420
|
+
const lastPosOnly = ps.reduce(
|
|
421
|
+
(acc, p, i) => (p && p.positionalOnly ? i : acc), -1);
|
|
422
|
+
let starShown = false;
|
|
423
|
+
const parts2 = [];
|
|
424
|
+
ps.forEach((p, i) => {
|
|
417
425
|
// Apply paramTypes mapping when paramsStructured doesn't carry types
|
|
418
426
|
const merged = { ...p };
|
|
419
427
|
if (!merged.type && paramTypes[p.name]) merged.type = paramTypes[p.name];
|
|
420
|
-
|
|
428
|
+
if (p && p.rest && /^\*(?!\*)/.test(String(p.name))) starShown = true;
|
|
429
|
+
if (!starShown && p && p.keywordOnly) {
|
|
430
|
+
parts2.push('*');
|
|
431
|
+
starShown = true;
|
|
432
|
+
}
|
|
433
|
+
const tok = formatTypedParam(merged);
|
|
434
|
+
if (tok) parts2.push(tok);
|
|
435
|
+
if (i === lastPosOnly) parts2.push('/');
|
|
421
436
|
});
|
|
422
|
-
parts.push(`(${parts2.
|
|
437
|
+
parts.push(`(${parts2.join(', ')})`);
|
|
423
438
|
} else if (def.params !== undefined) {
|
|
424
439
|
parts.push(`(${def.params})`);
|
|
425
440
|
}
|
|
@@ -874,6 +889,28 @@ function analyzeCallSite(index, call, funcName, occurrence = 0) {
|
|
|
874
889
|
args.push(argNode.text.trim());
|
|
875
890
|
}
|
|
876
891
|
|
|
892
|
+
// Python argument structure (fix #281): keyword arguments bind by
|
|
893
|
+
// NAME, and `*seq` / `**map` unpacking makes the argument count
|
|
894
|
+
// non-static. Both were invisible before — keyword args counted as
|
|
895
|
+
// positional slots and unpacking fell through to a hard mismatch.
|
|
896
|
+
const keywordArgNames = [];
|
|
897
|
+
let unpackingArgs = 0;
|
|
898
|
+
let pyPositional = 0;
|
|
899
|
+
if (language === 'python') {
|
|
900
|
+
for (let i = 0; i < argsNode.namedChildCount; i++) {
|
|
901
|
+
const argNode = argsNode.namedChild(i);
|
|
902
|
+
if (argNode.type.includes('comment')) continue;
|
|
903
|
+
if (argNode.type === 'keyword_argument') {
|
|
904
|
+
const nameNode = argNode.childForFieldName('name') || argNode.namedChild(0);
|
|
905
|
+
if (nameNode) keywordArgNames.push(nameNode.text);
|
|
906
|
+
} else if (argNode.type === 'list_splat' || argNode.type === 'dictionary_splat') {
|
|
907
|
+
unpackingArgs++;
|
|
908
|
+
} else {
|
|
909
|
+
pyPositional++;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
877
914
|
// Function.prototype indirection has a precise, AST-visible argument
|
|
878
915
|
// mapping. `fn.call(thisArg, a, b)` invokes fn(a, b). `fn.apply`
|
|
879
916
|
// is countable only when its argument array is a literal. `bind`
|
|
@@ -922,7 +959,9 @@ function analyzeCallSite(index, call, funcName, occurrence = 0) {
|
|
|
922
959
|
return {
|
|
923
960
|
args,
|
|
924
961
|
argCount: args.length,
|
|
925
|
-
hasSpread: args.some(a => a.startsWith('...')),
|
|
962
|
+
hasSpread: args.some(a => a.startsWith('...')) || unpackingArgs > 0,
|
|
963
|
+
...(unpackingArgs > 0 && { unpackingSpread: true }),
|
|
964
|
+
...(language === 'python' && { positionalCount: pyPositional, keywordArgNames }),
|
|
926
965
|
hasVariable: args.some(a => /^[a-zA-Z_]\w*$/.test(a)),
|
|
927
966
|
isMethodCall,
|
|
928
967
|
...(indirectKind && { indirectKind }),
|
|
@@ -1129,6 +1168,70 @@ function identifyCallPatterns(callSites, funcName) {
|
|
|
1129
1168
|
return patterns;
|
|
1130
1169
|
}
|
|
1131
1170
|
|
|
1171
|
+
// Decorators that provably keep the declared call interface. Any OTHER
|
|
1172
|
+
// decorator may reshape the signature (click/celery/functools partials), so
|
|
1173
|
+
// keyword-binding violations against the declared parameters route to the
|
|
1174
|
+
// UNCERTAIN band instead of hard mismatches (fix #281 — the #205 "decorators
|
|
1175
|
+
// reshape signatures" rule applied to verify's claim).
|
|
1176
|
+
const SIGNATURE_PRESERVING_DECORATORS = new Set([
|
|
1177
|
+
'staticmethod', 'classmethod', 'abstractmethod', 'override', 'final',
|
|
1178
|
+
]);
|
|
1179
|
+
|
|
1180
|
+
/**
|
|
1181
|
+
* Bind a keyword-argument call against structured parameters (fix #281).
|
|
1182
|
+
* Mirrors the interpreter's rules: positional args fill non-keyword-only
|
|
1183
|
+
* slots in order, each keyword arg must name a known non-positional-only
|
|
1184
|
+
* parameter (unless `**kwargs` absorbs it), and every required parameter
|
|
1185
|
+
* must end up bound. Returns problem strings (empty = binds cleanly).
|
|
1186
|
+
* Callers gate on the `keywordArguments` trait — languages without named
|
|
1187
|
+
* arguments allow short calls, so required-coverage would false-flag.
|
|
1188
|
+
*/
|
|
1189
|
+
function bindKeywordCall(params, analysis) {
|
|
1190
|
+
const problems = [];
|
|
1191
|
+
const isListRest = p => p.rest && /^\*(?!\*)/.test(String(p.name));
|
|
1192
|
+
const isDictRest = p => p.rest && /^\*\*/.test(String(p.name));
|
|
1193
|
+
const slots = params.filter(p => !p.rest && !p.keywordOnly);
|
|
1194
|
+
const hasListRest = params.some(isListRest);
|
|
1195
|
+
const hasDictRest = params.some(isDictRest);
|
|
1196
|
+
const positional = analysis.positionalCount != null
|
|
1197
|
+
? analysis.positionalCount : analysis.argCount;
|
|
1198
|
+
const tooManyPositional = positional > slots.length && !hasListRest;
|
|
1199
|
+
if (tooManyPositional) {
|
|
1200
|
+
problems.push(`takes ${slots.length} positional argument(s) but ` +
|
|
1201
|
+
`${positional} ${positional === 1 ? 'was' : 'were'} given`);
|
|
1202
|
+
}
|
|
1203
|
+
const bound = new Set(
|
|
1204
|
+
slots.slice(0, Math.min(positional, slots.length)).map(p => p.name));
|
|
1205
|
+
for (const kw of analysis.keywordArgNames || []) {
|
|
1206
|
+
const param = params.find(p => !p.rest && p.name === kw);
|
|
1207
|
+
if (!param) {
|
|
1208
|
+
if (!hasDictRest) problems.push(`unexpected keyword argument '${kw}'`);
|
|
1209
|
+
} else if (param.positionalOnly) {
|
|
1210
|
+
// With **kwargs the name is absorbed there — but then the
|
|
1211
|
+
// positional-only parameter itself stays unbound (missing check).
|
|
1212
|
+
if (!hasDictRest) {
|
|
1213
|
+
problems.push(`'${kw}' is positional-only and cannot be passed by keyword`);
|
|
1214
|
+
}
|
|
1215
|
+
} else if (bound.has(kw)) {
|
|
1216
|
+
problems.push(`got multiple values for argument '${kw}'`);
|
|
1217
|
+
} else {
|
|
1218
|
+
bound.add(kw);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
// Skip required-coverage when positionals already overflowed — the extra
|
|
1222
|
+
// positionals were almost certainly aimed at the unbound keyword-only
|
|
1223
|
+
// params, and the interpreter reports only the positional error too.
|
|
1224
|
+
if (!tooManyPositional) {
|
|
1225
|
+
const missing = params
|
|
1226
|
+
.filter(p => !p.rest && !p.optional && p.default === undefined && !bound.has(p.name))
|
|
1227
|
+
.map(p => `'${p.name}'`);
|
|
1228
|
+
if (missing.length > 0) {
|
|
1229
|
+
problems.push(`missing required argument(s): ${missing.join(', ')}`);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
return problems;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1132
1235
|
/**
|
|
1133
1236
|
* Verify that all call sites match a function's signature
|
|
1134
1237
|
* @param {object} index - ProjectIndex instance
|
|
@@ -1216,6 +1319,18 @@ function verify(index, name, options = {}) {
|
|
|
1216
1319
|
|
|
1217
1320
|
const defIsMethod = !!(def.isMethod || def.type === 'method' || def.className);
|
|
1218
1321
|
|
|
1322
|
+
// fix #281: keyword-argument binding validation. Applies only where the
|
|
1323
|
+
// language binds by name (Python), the target has ONE parameter list
|
|
1324
|
+
// (overload groups keep the count-range check), and the arity isn't an
|
|
1325
|
+
// inherited-constructor unknown.
|
|
1326
|
+
const keywordBindable = langTraits(lang)?.keywordArguments === true &&
|
|
1327
|
+
rawParamLists.length === 1 && !inheritedCtorOnly;
|
|
1328
|
+
const decoratorReshapes = keywordBindable && Array.isArray(def.decorators) &&
|
|
1329
|
+
def.decorators.some(d => {
|
|
1330
|
+
const dName = String(d).replace(/^@/, '').split('(')[0].trim();
|
|
1331
|
+
return !SIGNATURE_PRESERVING_DECORATORS.has(dName.split('.').pop());
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1219
1334
|
// Helper: extract pattern flags (Feature A/B) from analyzeCallSite result.
|
|
1220
1335
|
// Reused so each valid/mismatch/uncertain entry carries the same shape.
|
|
1221
1336
|
function patternFlagsFrom(a) {
|
|
@@ -1261,7 +1376,9 @@ function verify(index, name, options = {}) {
|
|
|
1261
1376
|
file: call.relativePath,
|
|
1262
1377
|
line: call.line,
|
|
1263
1378
|
expression: call.content.trim(),
|
|
1264
|
-
reason:
|
|
1379
|
+
reason: analysis.unpackingSpread
|
|
1380
|
+
? 'Uses argument unpacking (*/**) — argument count is not static'
|
|
1381
|
+
: 'Uses spread operator',
|
|
1265
1382
|
patterns: patternFlagsFrom(analysis),
|
|
1266
1383
|
...carry,
|
|
1267
1384
|
});
|
|
@@ -1285,33 +1402,40 @@ function verify(index, name, options = {}) {
|
|
|
1285
1402
|
}
|
|
1286
1403
|
|
|
1287
1404
|
// Check if arg count is valid
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1405
|
+
const countOk = hasRest
|
|
1406
|
+
? argCount >= minArgs
|
|
1407
|
+
: (argCount >= minArgs && argCount <= expectedParamCount);
|
|
1408
|
+
if (!countOk) {
|
|
1409
|
+
mismatches.push({
|
|
1410
|
+
file: call.relativePath,
|
|
1411
|
+
line: call.line,
|
|
1412
|
+
expression: call.content.trim(),
|
|
1413
|
+
expected: hasRest
|
|
1414
|
+
? `at least ${minArgs} arg(s)`
|
|
1415
|
+
: (minArgs === expectedParamCount
|
|
1416
|
+
? `${expectedParamCount} arg(s)`
|
|
1417
|
+
: `${minArgs}-${expectedParamCount} arg(s)`),
|
|
1418
|
+
actual: argCount,
|
|
1419
|
+
args: analysis.args,
|
|
1420
|
+
patterns: patternFlagsFrom(analysis),
|
|
1421
|
+
...carry,
|
|
1422
|
+
});
|
|
1423
|
+
continue;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
// fix #281: the count fits — for keyword-binding languages, check the
|
|
1427
|
+
// NAME-level contract too (keyword-only slots, unknown keyword names,
|
|
1428
|
+
// required coverage). A reshaping decorator demotes violations to
|
|
1429
|
+
// UNCERTAIN: the declared parameters may not be the call interface.
|
|
1430
|
+
const bindingProblems = keywordBindable ? bindKeywordCall(params, analysis) : [];
|
|
1431
|
+
if (bindingProblems.length > 0) {
|
|
1432
|
+
if (decoratorReshapes) {
|
|
1433
|
+
uncertain.push({
|
|
1299
1434
|
file: call.relativePath,
|
|
1300
1435
|
line: call.line,
|
|
1301
1436
|
expression: call.content.trim(),
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
args: analysis.args,
|
|
1305
|
-
patterns: patternFlagsFrom(analysis),
|
|
1306
|
-
...carry,
|
|
1307
|
-
});
|
|
1308
|
-
}
|
|
1309
|
-
} else {
|
|
1310
|
-
// Without rest, need between minArgs and expectedParamCount
|
|
1311
|
-
if (argCount >= minArgs && argCount <= expectedParamCount) {
|
|
1312
|
-
valid.push({
|
|
1313
|
-
file: call.relativePath,
|
|
1314
|
-
line: call.line,
|
|
1437
|
+
reason: `Against the declared parameters: ${bindingProblems.join('; ')}. ` +
|
|
1438
|
+
'The definition is decorated, and the decorator may reshape the call interface.',
|
|
1315
1439
|
patterns: patternFlagsFrom(analysis),
|
|
1316
1440
|
...carry,
|
|
1317
1441
|
});
|
|
@@ -1320,16 +1444,27 @@ function verify(index, name, options = {}) {
|
|
|
1320
1444
|
file: call.relativePath,
|
|
1321
1445
|
line: call.line,
|
|
1322
1446
|
expression: call.content.trim(),
|
|
1323
|
-
expected:
|
|
1324
|
-
?
|
|
1325
|
-
:
|
|
1447
|
+
expected: hasRest
|
|
1448
|
+
? `at least ${minArgs} arg(s)`
|
|
1449
|
+
: (minArgs === expectedParamCount
|
|
1450
|
+
? `${expectedParamCount} arg(s)`
|
|
1451
|
+
: `${minArgs}-${expectedParamCount} arg(s)`),
|
|
1326
1452
|
actual: argCount,
|
|
1327
1453
|
args: analysis.args,
|
|
1454
|
+
problem: bindingProblems.join('; '),
|
|
1328
1455
|
patterns: patternFlagsFrom(analysis),
|
|
1329
1456
|
...carry,
|
|
1330
1457
|
});
|
|
1331
1458
|
}
|
|
1459
|
+
continue;
|
|
1332
1460
|
}
|
|
1461
|
+
|
|
1462
|
+
valid.push({
|
|
1463
|
+
file: call.relativePath,
|
|
1464
|
+
line: call.line,
|
|
1465
|
+
patterns: patternFlagsFrom(analysis),
|
|
1466
|
+
...carry,
|
|
1467
|
+
});
|
|
1333
1468
|
}
|
|
1334
1469
|
clearTreeCache(index);
|
|
1335
1470
|
|
|
@@ -1415,7 +1550,10 @@ function verify(index, name, options = {}) {
|
|
|
1415
1550
|
params: params.map(p => ({
|
|
1416
1551
|
name: p.name,
|
|
1417
1552
|
optional: p.optional || p.default !== undefined,
|
|
1418
|
-
hasDefault: p.default !== undefined
|
|
1553
|
+
hasDefault: p.default !== undefined,
|
|
1554
|
+
// fix #281: binding-position markers (Python `*` / `/`)
|
|
1555
|
+
...(p.keywordOnly && { keywordOnly: true }),
|
|
1556
|
+
...(p.positionalOnly && { positionalOnly: true }),
|
|
1419
1557
|
})),
|
|
1420
1558
|
// max: null = unbounded (rest param) — typed for JSON consumers;
|
|
1421
1559
|
// the text formatter renders it as `${min}+` (fix #230, was the
|
package/languages/index.js
CHANGED
|
@@ -55,6 +55,13 @@ const STRUCTURAL_TRAITS = {
|
|
|
55
55
|
// Whether `Type(...)` constructs a class without a `new` token. Python
|
|
56
56
|
// classes are ordinary callable objects; JS/TS classes require `new`.
|
|
57
57
|
classesCallableWithoutNew: false,
|
|
58
|
+
// Whether call sites bind arguments by parameter NAME (Python keyword
|
|
59
|
+
// arguments). Drives verify/check keyword binding validation (fix #281):
|
|
60
|
+
// keyword names checked against the signature, keyword-only/positional-only
|
|
61
|
+
// markers honored, required coverage enforced. Languages without named
|
|
62
|
+
// arguments must stay false — a JS call with fewer args than params is
|
|
63
|
+
// legal, so required-coverage checks would false-flag.
|
|
64
|
+
keywordArguments: false,
|
|
58
65
|
};
|
|
59
66
|
const NOMINAL_TRAITS = {
|
|
60
67
|
typeSystem: 'nominal',
|
|
@@ -64,6 +71,11 @@ const NOMINAL_TRAITS = {
|
|
|
64
71
|
exportVisibility: 'keyword',
|
|
65
72
|
hasDynamicImports: true,
|
|
66
73
|
testDirs: [],
|
|
74
|
+
// Call sites don't bind arguments by parameter name here (see
|
|
75
|
+
// STRUCTURAL_TRAITS.keywordArguments — Python overrides true). C# HAS
|
|
76
|
+
// named arguments but its parser doesn't record them yet; the trait stays
|
|
77
|
+
// false there until that family is measured (fix #281, classified-deferred).
|
|
78
|
+
keywordArguments: false,
|
|
67
79
|
// Go/Java/Rust have no default parameter values — see STRUCTURAL_TRAITS.
|
|
68
80
|
hasDefaultParams: false,
|
|
69
81
|
// Whether ANY instance method call can dynamically dispatch to a subtype
|
|
@@ -177,6 +189,9 @@ const LANGUAGES = {
|
|
|
177
189
|
// from-imports bind values only (`import * as ns` is parser-marked).
|
|
178
190
|
submoduleImports: true,
|
|
179
191
|
classesCallableWithoutNew: true,
|
|
192
|
+
// Call sites bind by parameter name (f(x=1)); signatures carry
|
|
193
|
+
// keyword-only (`*`) and positional-only (`/`) markers (fix #281).
|
|
194
|
+
keywordArguments: true,
|
|
180
195
|
testFileCandidates: (base, ext) => [`test_${base}.py`, `${base}_test.py`],
|
|
181
196
|
testDirs: ['tests'],
|
|
182
197
|
},
|
package/languages/utils.js
CHANGED
|
@@ -80,10 +80,29 @@ function parseStructuredParams(paramsNode, language) {
|
|
|
80
80
|
return params;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// Python binding-position markers (fix #281): a bare `*` makes every later
|
|
84
|
+
// param keyword-only, a bare `/` makes every earlier param positional-only,
|
|
85
|
+
// and `*args` plays the same separator role as `*`. Dropping them collapsed
|
|
86
|
+
// `f(a, *, b)` to `f(a, b)` and verify green-lit guaranteed TypeErrors.
|
|
87
|
+
let pyKeywordOnly = false;
|
|
88
|
+
|
|
83
89
|
for (let i = 0; i < paramsNode.namedChildCount; i++) {
|
|
84
90
|
const param = paramsNode.namedChild(i);
|
|
85
91
|
const paramInfo = {};
|
|
86
92
|
|
|
93
|
+
if (language === 'python') {
|
|
94
|
+
if (param.type === 'keyword_separator') {
|
|
95
|
+
pyKeywordOnly = true;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (param.type === 'positional_separator') {
|
|
99
|
+
for (const prev of params) {
|
|
100
|
+
if (!prev.rest) prev.positionalOnly = true;
|
|
101
|
+
}
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
87
106
|
// Different handling per language
|
|
88
107
|
if (language === 'javascript' || language === 'typescript' || language === 'tsx') {
|
|
89
108
|
parseJSParam(param, paramInfo);
|
|
@@ -98,6 +117,13 @@ function parseStructuredParams(paramsNode, language) {
|
|
|
98
117
|
}
|
|
99
118
|
|
|
100
119
|
if (paramInfo.name) {
|
|
120
|
+
if (language === 'python') {
|
|
121
|
+
if (pyKeywordOnly && !paramInfo.rest) paramInfo.keywordOnly = true;
|
|
122
|
+
// `*args` ends the positional section exactly like a bare `*`.
|
|
123
|
+
if (paramInfo.rest && /^\*(?!\*)/.test(String(paramInfo.name))) {
|
|
124
|
+
pyKeywordOnly = true;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
101
127
|
params.push(paramInfo);
|
|
102
128
|
// Go multi-name declarations: `a, b int` → expand additional params
|
|
103
129
|
if (paramInfo._additionalNames) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ucn",
|
|
3
|
-
"version": "5.0.
|
|
3
|
+
"version": "5.0.3",
|
|
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",
|