ucn 5.5.3 → 5.6.0
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 +35 -12
- package/.claude/skills/ucn/references/commands.md +18 -14
- package/.claude/skills/ucn/references/resolution-rules.md +4 -0
- package/.claude/skills/ucn/references/trust-contract.md +4 -4
- package/CHANGELOG.md +474 -0
- package/README.md +69 -29
- package/cli/flags.js +184 -0
- package/cli/index.js +137 -324
- package/core/account.js +44 -87
- package/core/analysis.js +273 -107
- package/core/ast-analysis.js +2 -0
- package/core/bridge.js +14 -0
- package/core/cache.js +101 -16
- package/core/callers.js +352 -70
- package/core/capture.js +331 -0
- package/core/change-kinds.js +888 -0
- package/core/check.js +1117 -139
- package/core/command-contracts.js +7 -6
- package/core/constructed-class.js +1195 -0
- package/core/contract-membership.js +142 -0
- package/core/deadcode.js +362 -84
- package/core/deletion.js +401 -0
- package/core/discovery.js +104 -9
- package/core/entrypoints.js +12 -6
- package/core/evidence-bands.js +68 -0
- package/core/evidence-bands.json +44 -0
- package/core/execute.js +172 -161
- package/core/hot-summary.js +241 -0
- package/core/index-ir.js +3 -1
- package/core/ir.js +5 -1
- package/core/member-signatures.js +789 -0
- package/core/output/analysis-ext.js +20 -10
- package/core/output/analysis.js +98 -60
- package/core/output/check.js +98 -37
- package/core/output/doctor.js +34 -1
- package/core/output/find.js +2 -1
- package/core/output/lines.js +63 -3
- package/core/output/public.js +29 -11
- package/core/output/refactoring.js +32 -0
- package/core/output/reporting.js +15 -2
- package/core/output/search.js +11 -2
- package/core/output/tracing.js +35 -7
- package/core/output-budget.js +124 -57
- package/core/parallel-build.js +3 -3
- package/core/project.js +106 -23
- package/core/provenance-type.js +12 -5
- package/core/public-command.js +35 -9
- package/core/python-modules.js +6 -0
- package/core/reflection.js +151 -23
- package/core/registry.js +623 -72
- package/core/reporting.js +69 -34
- package/core/review-items.js +75 -0
- package/core/search.js +89 -128
- package/core/shared.js +0 -45
- package/core/snapshot.js +350 -0
- package/core/source-universe.js +707 -0
- package/core/test-units.js +3624 -0
- package/core/tracing.js +150 -96
- package/core/verify.js +27 -11
- package/languages/adapter.js +8 -3
- package/languages/c-family.js +12 -0
- package/languages/csharp.js +634 -0
- package/languages/go.js +120 -0
- package/languages/index.js +422 -4
- package/languages/java.js +712 -0
- package/languages/javascript.js +1460 -2
- package/languages/lexical-scope.js +68 -0
- package/languages/python.js +1672 -1
- package/languages/rust.js +91 -0
- package/mcp/server.js +44 -110
- package/package.json +10 -5
package/core/reporting.js
CHANGED
|
@@ -11,6 +11,7 @@ const fs = require('fs');
|
|
|
11
11
|
const { codeUnitCompare, CALLABLE_SYMBOL_KINDS } = require('./shared');
|
|
12
12
|
const path = require('path');
|
|
13
13
|
const { isTestFile } = require('./discovery');
|
|
14
|
+
const { paramFact } = require('./registry');
|
|
14
15
|
const { summarizeCommandTrust } = require('./trust-matrix');
|
|
15
16
|
const { projectComputedDispatch } = require('./ast-analysis');
|
|
16
17
|
const { langTraits } = require('../languages');
|
|
@@ -123,8 +124,37 @@ function getStats(index, options = {}) {
|
|
|
123
124
|
// positive integer, 0 (show nothing), or undefined (default 10).
|
|
124
125
|
const top = options.top === 0
|
|
125
126
|
? 0
|
|
126
|
-
: ((options.top != null && Number(options.top) > 0) ? Number(options.top) :
|
|
127
|
+
: ((options.top != null && Number(options.top) > 0) ? Number(options.top) : paramFact('top', 'hot'));
|
|
127
128
|
const FUNCTION_TYPES = CALLABLE_SYMBOL_KINDS;
|
|
129
|
+
const maxRefine = Number.isInteger(options.maxRefine) && options.maxRefine > 0
|
|
130
|
+
? options.maxRefine : Infinity;
|
|
131
|
+
// Refinement work is bounded by call records, not wall time, so the
|
|
132
|
+
// answer is the same on every run and machine (fix #382). One
|
|
133
|
+
// refinement scans the call records of its name in the countable
|
|
134
|
+
// files; `workBudget` caps their sum. Exact answers on real projects
|
|
135
|
+
// stay far below it; a very large repository (kubernetes: ~68M
|
|
136
|
+
// records under its candidate ceilings) stops and says so.
|
|
137
|
+
const workBudget = Number.isFinite(options.workBudget) && options.workBudget > 0
|
|
138
|
+
? options.workBudget : STATS_HOT_WORK_BUDGET;
|
|
139
|
+
|
|
140
|
+
// The ranking is a function of the index and of these options, so an
|
|
141
|
+
// answer already computed for this index state is the answer
|
|
142
|
+
// (core/hot-summary.js). Nothing below runs for it.
|
|
143
|
+
const hotSummary = require('./hot-summary');
|
|
144
|
+
const summaryKey = hotSummary.keyOf({
|
|
145
|
+
top, productionCallsOnly: options.productionCallsOnly, maxRefine, workBudget,
|
|
146
|
+
file: options.file, in: options.in,
|
|
147
|
+
exclude: Array.isArray(options.exclude)
|
|
148
|
+
? options.exclude
|
|
149
|
+
: (options.exclude
|
|
150
|
+
? String(options.exclude).split(',').map(s => s.trim()).filter(Boolean)
|
|
151
|
+
: []),
|
|
152
|
+
});
|
|
153
|
+
const keptSummary = hotSummary.get(index, summaryKey);
|
|
154
|
+
if (keptSummary) {
|
|
155
|
+
stats.hot = keptSummary;
|
|
156
|
+
return stats;
|
|
157
|
+
}
|
|
128
158
|
|
|
129
159
|
// Ensure the calls cache is fully populated before counting.
|
|
130
160
|
// First-time stats --hot may need to parse files to extract calls;
|
|
@@ -374,16 +404,6 @@ function getStats(index, options = {}) {
|
|
|
374
404
|
}
|
|
375
405
|
}
|
|
376
406
|
}
|
|
377
|
-
const maxRefine = Number.isInteger(options.maxRefine) && options.maxRefine > 0
|
|
378
|
-
? options.maxRefine : Infinity;
|
|
379
|
-
// Refinement work is bounded by call records, not wall time, so the
|
|
380
|
-
// answer is the same on every run and machine (fix #382). One
|
|
381
|
-
// refinement scans the call records of its name in the countable
|
|
382
|
-
// files; `workBudget` caps their sum. Exact answers on real projects
|
|
383
|
-
// stay far below it; a very large repository (kubernetes: ~68M
|
|
384
|
-
// records under its candidate ceilings) stops and says so.
|
|
385
|
-
const workBudget = Number.isFinite(options.workBudget) && options.workBudget > 0
|
|
386
|
-
? options.workBudget : STATS_HOT_WORK_BUDGET;
|
|
387
407
|
const workOf = candidate => recordsByName.get(candidate.name) || 0;
|
|
388
408
|
// Ceilings in descending order: the early stop fires as soon as no
|
|
389
409
|
// unseen ceiling can reach the current Nth count, so exactly the
|
|
@@ -510,7 +530,7 @@ function getStats(index, options = {}) {
|
|
|
510
530
|
(a.startLine || 0) - (b.startLine || 0)
|
|
511
531
|
);
|
|
512
532
|
|
|
513
|
-
stats.hot = {
|
|
533
|
+
stats.hot = hotSummary.put(index, summaryKey, {
|
|
514
534
|
top,
|
|
515
535
|
total: refined === candidates.length ? hotList.length : candidates.length,
|
|
516
536
|
totalKind: refined === candidates.length ? 'confirmed' : 'raw-call-candidates',
|
|
@@ -523,7 +543,7 @@ function getStats(index, options = {}) {
|
|
|
523
543
|
note: refined === candidates.length
|
|
524
544
|
? 'Counts are confirmed caller-engine edges pinned to each displayed definition; unverified dispatch is excluded.'
|
|
525
545
|
: `Displayed counts are exact confirmed caller-engine edges; ${candidates.length} raw candidates were bounded and ${refined} required exact refinement.`,
|
|
526
|
-
};
|
|
546
|
+
});
|
|
527
547
|
}
|
|
528
548
|
|
|
529
549
|
return stats;
|
|
@@ -747,11 +767,6 @@ function doctor(index, options = {}) {
|
|
|
747
767
|
skippedSources: { count: 0, fileCount: 0, files: [], reasons: {} },
|
|
748
768
|
};
|
|
749
769
|
|
|
750
|
-
// Reflection/eval signals come from the shared text-blind-spot counter
|
|
751
|
-
// (core/shared.js) — the SAME routine detectCompleteness uses for the about
|
|
752
|
-
// footer, so the two never drift (field-report #2). Occurrence counts.
|
|
753
|
-
const { hasTextBlindspots, countTextBlindspots } = lazySharedModule();
|
|
754
|
-
|
|
755
770
|
const computedDispatch = projectComputedDispatch(index);
|
|
756
771
|
for (const [filePath, fe] of index.files) {
|
|
757
772
|
fileCounts.total++;
|
|
@@ -781,14 +796,12 @@ function doctor(index, options = {}) {
|
|
|
781
796
|
if (fe.parseError) recordBlind(blindSpots.parseFailures, 1);
|
|
782
797
|
if (fe.parseRecovery) recordBlind(blindSpots.parseRecoveries, 1);
|
|
783
798
|
|
|
784
|
-
//
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
} catch (e) { /* ignore read errors */ }
|
|
791
|
-
}
|
|
799
|
+
// Code execution and reflection: call sites of each language's
|
|
800
|
+
// vocabulary (langTraits().reflectionApi), counted in the file's
|
|
801
|
+
// tree when it was indexed. The completeness footer reads the same
|
|
802
|
+
// two numbers, so the two never drift, and no file is read here.
|
|
803
|
+
recordBlind(blindSpots.evalCalls, fe.evalUses || 0);
|
|
804
|
+
recordBlind(blindSpots.reflection, fe.reflectionUses || 0);
|
|
792
805
|
}
|
|
793
806
|
|
|
794
807
|
// Files that failed before a fileEntry could be created are absent from
|
|
@@ -825,16 +838,30 @@ function doctor(index, options = {}) {
|
|
|
825
838
|
}
|
|
826
839
|
fileCounts.unsupported = blindSpots.unsupportedSources.fileCount;
|
|
827
840
|
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
841
|
+
// Source that is not indexed, from the one model every claim uses
|
|
842
|
+
// (core/source-universe.js): files searched as text only (a bundle, an
|
|
843
|
+
// excluded path, an oversized file; unsupported languages are counted
|
|
844
|
+
// above) and paths nothing can be read from. `unsearchable` is the part
|
|
845
|
+
// no name can be cleared against.
|
|
846
|
+
const sourceUniverse = require('./source-universe').universe(index);
|
|
847
|
+
let unsearchablePaths = 0;
|
|
848
|
+
const noteSkipped = (rel, reason) => {
|
|
849
|
+
if (!matchInFilter(rel)) return false;
|
|
831
850
|
const skipped = blindSpots.skippedSources;
|
|
832
851
|
skipped.count++;
|
|
833
852
|
skipped.fileCount++;
|
|
834
|
-
skipped.reasons[
|
|
835
|
-
(skipped.reasons[issue.reason || 'unknown'] || 0) + 1;
|
|
853
|
+
skipped.reasons[reason] = (skipped.reasons[reason] || 0) + 1;
|
|
836
854
|
if (skipped.files.length < BLINDSPOT_FILE_CAP) skipped.files.push(rel);
|
|
855
|
+
return true;
|
|
856
|
+
};
|
|
857
|
+
for (const entry of sourceUniverse.textOnly) {
|
|
858
|
+
if (entry.reason === 'unsupported-language') continue;
|
|
859
|
+
noteSkipped(entry.relativePath, entry.reason);
|
|
860
|
+
}
|
|
861
|
+
for (const entry of sourceUniverse.opaque) {
|
|
862
|
+
if (noteSkipped(entry.relativePath, entry.reason || 'unknown')) unsearchablePaths++;
|
|
837
863
|
}
|
|
864
|
+
blindSpots.skippedSources.unsearchable = unsearchablePaths;
|
|
838
865
|
fileCounts.skipped = blindSpots.skippedSources.fileCount;
|
|
839
866
|
|
|
840
867
|
// C/C++ token-pasting macro invocations whose expansion cannot be
|
|
@@ -910,7 +937,7 @@ function doctor(index, options = {}) {
|
|
|
910
937
|
if (blindSpots.skippedSources.count > 0) {
|
|
911
938
|
const reasons = Object.entries(blindSpots.skippedSources.reasons)
|
|
912
939
|
.map(([reason, count]) => `${reason} ${count}`).join(', ');
|
|
913
|
-
blindSignals.push(`${blindSpots.skippedSources.count} source
|
|
940
|
+
blindSignals.push(`${blindSpots.skippedSources.count} source path(s) not indexed: ${reasons}`);
|
|
914
941
|
}
|
|
915
942
|
|
|
916
943
|
// Trust is task-specific. A healthy index can be excellent for navigation
|
|
@@ -932,8 +959,10 @@ function doctor(index, options = {}) {
|
|
|
932
959
|
? `${blindSpots.parseRecoveries.count} file(s) required parser recovery; indexed results may be partial`
|
|
933
960
|
: cache.fresh === false ? 'index cache is stale'
|
|
934
961
|
: index.truncated ? `file discovery stopped at maxFiles=${index.truncated.maxFiles}`
|
|
962
|
+
: unsearchablePaths > 0
|
|
963
|
+
? `${unsearchablePaths} path(s) could not be searched; no caller account is complete until they can`
|
|
935
964
|
: blindSpots.skippedSources.count > 0
|
|
936
|
-
? `${blindSpots.skippedSources.count} source
|
|
965
|
+
? `${blindSpots.skippedSources.count} source file(s) are searched as text only; a name that occurs in them is listed, not analyzed`
|
|
937
966
|
: unsupportedCount > 0
|
|
938
967
|
? `${fileCounts.scanned} supported file(s) indexed; ${unsupportedCount} unsupported source file(s) require grep/ripgrep`
|
|
939
968
|
: 'fresh index; no parse failures';
|
|
@@ -965,8 +994,11 @@ function doctor(index, options = {}) {
|
|
|
965
994
|
: 'no known runtime blind spots detected; alias/dynamic completeness is not compiler-verified locally';
|
|
966
995
|
|
|
967
996
|
const navigationLevel = indexLevel;
|
|
997
|
+
// A path nothing can be read from leaves every caller account
|
|
998
|
+
// incomplete, which is what blocks `check`: the two must agree.
|
|
968
999
|
const hardIndexRisk = blindSpots.parseFailures.count > 0 ||
|
|
969
|
-
blindSpots.parseRecoveries.count > 0 || cache.fresh === false || !!index.truncated
|
|
1000
|
+
blindSpots.parseRecoveries.count > 0 || cache.fresh === false || !!index.truncated ||
|
|
1001
|
+
unsearchablePaths > 0;
|
|
970
1002
|
const refactorLevel = indexLevel === 'UNKNOWN' ? 'UNKNOWN'
|
|
971
1003
|
: indexLevel === 'UNSUPPORTED' ? 'UNSUPPORTED'
|
|
972
1004
|
: hardIndexRisk ? 'LOW'
|
|
@@ -1005,6 +1037,9 @@ function doctor(index, options = {}) {
|
|
|
1005
1037
|
symbols: totalSymbols,
|
|
1006
1038
|
languages: langs,
|
|
1007
1039
|
blindSpots,
|
|
1040
|
+
// What completeness claims range over: text-only, unsearchable and
|
|
1041
|
+
// declared-outside paths, stated here once.
|
|
1042
|
+
scope: require('./source-universe').scopeSummary(index),
|
|
1008
1043
|
evidenceProfile,
|
|
1009
1044
|
cache,
|
|
1010
1045
|
commandTrust: summarizeCommandTrust(),
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core/review-items.js - How much is there to look at?
|
|
3
|
+
*
|
|
4
|
+
* Unverified caller candidates are not all the same amount of work. A site
|
|
5
|
+
* the engine could not bind is one thing to check. A dispatch family (an
|
|
6
|
+
* interface call that may reach any implementation, a template the
|
|
7
|
+
* compiler instantiates) is ONE boundary with a known reason, however many
|
|
8
|
+
* sites it lists. A reader reviews the boundary, not each site.
|
|
9
|
+
*
|
|
10
|
+
* One counter, shared by the pre-commit check, the caller formatter and
|
|
11
|
+
* the oracle evaluation, so the three cannot disagree about the burden.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Why a caller candidate is unverified, as one of three classes:
|
|
18
|
+
* 'runtime-dispatch' (the implementation is selected at runtime through a
|
|
19
|
+
* named boundary), 'compile-time-dispatch' (the compiler selects it),
|
|
20
|
+
* 'actionable-ambiguity' (something a reader can settle). The class the
|
|
21
|
+
* engine recorded wins; a possible-dispatch site with a named boundary is
|
|
22
|
+
* runtime dispatch.
|
|
23
|
+
*/
|
|
24
|
+
function uncertaintyClassOf(site) {
|
|
25
|
+
return site.uncertaintyClass ||
|
|
26
|
+
(site.reason === 'possible-dispatch' && site.dispatchVia ? 'runtime-dispatch' : 'actionable-ambiguity');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Unverified entries by class: { actionable, runtime, compileTime }. */
|
|
30
|
+
function partitionUnverified(unverified) {
|
|
31
|
+
const parts = { actionable: [], runtime: [], compileTime: [] };
|
|
32
|
+
for (const site of Array.isArray(unverified) ? unverified : []) {
|
|
33
|
+
const kind = uncertaintyClassOf(site);
|
|
34
|
+
if (kind === 'runtime-dispatch') parts.runtime.push(site);
|
|
35
|
+
else if (kind === 'compile-time-dispatch') parts.compileTime.push(site);
|
|
36
|
+
else parts.actionable.push(site);
|
|
37
|
+
}
|
|
38
|
+
return parts;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Key of the family a runtime-dispatch site belongs to. */
|
|
42
|
+
function runtimeFamilyKey(site) {
|
|
43
|
+
return `${site.dispatchVia || ''}\0${site.dispatchCandidates ?? ''}\0` +
|
|
44
|
+
`${site.externalContract ? 'external' : 'project'}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Key of the family a compile-time-dispatch site belongs to. */
|
|
48
|
+
function compileTimeFamilyKey(site) {
|
|
49
|
+
return `${site.dispatchFamily || ''}\0${site.dispatchCandidates ?? ''}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {object[]} unverified - unverified caller entries
|
|
54
|
+
* @returns {{actionable, runtimeDispatchSites, runtimeDispatchGroups,
|
|
55
|
+
* compileTimeDispatchSites, compileTimeDispatchGroups, items}}
|
|
56
|
+
* `items` = actionable sites + one per dispatch family.
|
|
57
|
+
*/
|
|
58
|
+
function reviewItemsOf(unverified) {
|
|
59
|
+
const { actionable: open, runtime, compileTime } = partitionUnverified(unverified);
|
|
60
|
+
const actionable = open.length;
|
|
61
|
+
const runtimeDispatchGroups = new Set(runtime.map(runtimeFamilyKey)).size;
|
|
62
|
+
const compileTimeDispatchGroups = new Set(compileTime.map(compileTimeFamilyKey)).size;
|
|
63
|
+
return {
|
|
64
|
+
actionable,
|
|
65
|
+
runtimeDispatchSites: runtime.length,
|
|
66
|
+
runtimeDispatchGroups,
|
|
67
|
+
compileTimeDispatchSites: compileTime.length,
|
|
68
|
+
compileTimeDispatchGroups,
|
|
69
|
+
items: actionable + runtimeDispatchGroups + compileTimeDispatchGroups,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = {
|
|
74
|
+
reviewItemsOf, uncertaintyClassOf, partitionUnverified, runtimeFamilyKey, compileTimeFamilyKey,
|
|
75
|
+
};
|
package/core/search.js
CHANGED
|
@@ -17,6 +17,8 @@ const { isAccessorDefinition, findAccessorReferences } = require('./accessors');
|
|
|
17
17
|
const isSafeRegex = require('safe-regex2');
|
|
18
18
|
const { RE2JS } = require('re2js');
|
|
19
19
|
const { UcnError } = require('./errors');
|
|
20
|
+
const { paramValues } = require('./registry');
|
|
21
|
+
const { createTestModel, addTestCaseRows, linkFiles } = require('./test-units');
|
|
20
22
|
|
|
21
23
|
// Keep the established search-input contract for the canonical catastrophic
|
|
22
24
|
// nested-single-atom family even though the RE2-compatible engine could run it
|
|
@@ -36,11 +38,8 @@ function buildGlobMatcher(pattern, caseSensitive) {
|
|
|
36
38
|
return (name) => regex.test(name);
|
|
37
39
|
}
|
|
38
40
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
'state', 'field', 'constant', 'macro', 'variable',
|
|
42
|
-
'interface', 'enum', 'struct', 'trait', 'record', 'namespace',
|
|
43
|
-
]);
|
|
41
|
+
// The kinds a structural search takes: the parameter table's set.
|
|
42
|
+
const STRUCTURAL_TYPES = new Set(paramValues('type', 'search'));
|
|
44
43
|
|
|
45
44
|
/**
|
|
46
45
|
* Substring match. Case-insensitive by default.
|
|
@@ -1313,6 +1312,10 @@ function tests(index, nameOrFile, options = {}) {
|
|
|
1313
1312
|
index._beginOp();
|
|
1314
1313
|
try {
|
|
1315
1314
|
const results = [];
|
|
1315
|
+
const model = createTestModel(index);
|
|
1316
|
+
// Files where a title that names the term is a link (the term on a test
|
|
1317
|
+
// line is the project's symbol, not the file's own helper).
|
|
1318
|
+
const titled = new Set();
|
|
1316
1319
|
|
|
1317
1320
|
// Check if it's a file path
|
|
1318
1321
|
const isFilePath = nameOrFile.includes('/') || nameOrFile.includes('\\') ||
|
|
@@ -1358,6 +1361,23 @@ function tests(index, nameOrFile, options = {}) {
|
|
|
1358
1361
|
}
|
|
1359
1362
|
}
|
|
1360
1363
|
|
|
1364
|
+
// A file's name does not decide what a test executes: the body of a
|
|
1365
|
+
// test a class inherits stands where its base class is, and a runner
|
|
1366
|
+
// takes tests from files of any name. A call site the caller engine
|
|
1367
|
+
// finds in such a file is a test's where the test model says a test
|
|
1368
|
+
// executes that line (core/test-units.js): the file joins the test
|
|
1369
|
+
// files, test code within those lines.
|
|
1370
|
+
const testInfoByPath = new Map(testFiles.map(info => [info.path, info]));
|
|
1371
|
+
const testInfoOf = filePath => {
|
|
1372
|
+
if (testInfoByPath.has(filePath)) return testInfoByPath.get(filePath);
|
|
1373
|
+
const entry = index.files.get(filePath);
|
|
1374
|
+
const ranges = entry ? model.testRangesOf(filePath) : [];
|
|
1375
|
+
const info = ranges.length > 0 ? { path: filePath, entry, testRanges: ranges } : null;
|
|
1376
|
+
testInfoByPath.set(filePath, info);
|
|
1377
|
+
if (info) testFiles.push(info);
|
|
1378
|
+
return info;
|
|
1379
|
+
};
|
|
1380
|
+
|
|
1361
1381
|
const searchTerm = isFilePath
|
|
1362
1382
|
? path.basename(nameOrFile, path.extname(nameOrFile))
|
|
1363
1383
|
: nameOrFile;
|
|
@@ -1388,7 +1408,6 @@ function tests(index, nameOrFile, options = {}) {
|
|
|
1388
1408
|
// module-level coverage remain visible.
|
|
1389
1409
|
const fileTargetCallerSites = new Map(); // absolute test file → tiered sites
|
|
1390
1410
|
if (isFilePath && targetRels) {
|
|
1391
|
-
const testFileInfo = new Map(testFiles.map(info => [info.path, info]));
|
|
1392
1411
|
const fileTargetDefs = [];
|
|
1393
1412
|
for (const [, fe] of index.files) {
|
|
1394
1413
|
if (!targetRels.has(fe.relativePath)) continue;
|
|
@@ -1410,7 +1429,7 @@ function tests(index, nameOrFile, options = {}) {
|
|
|
1410
1429
|
})) : []),
|
|
1411
1430
|
];
|
|
1412
1431
|
for (const site of sites) {
|
|
1413
|
-
const info =
|
|
1432
|
+
const info = testInfoOf(site.file);
|
|
1414
1433
|
if (!info) continue;
|
|
1415
1434
|
if (info.testRanges && !lineInRanges(site.line, info.testRanges)) continue;
|
|
1416
1435
|
let rows = fileTargetCallerSites.get(site.file);
|
|
@@ -1659,12 +1678,11 @@ function tests(index, nameOrFile, options = {}) {
|
|
|
1659
1678
|
});
|
|
1660
1679
|
}
|
|
1661
1680
|
|
|
1662
|
-
//
|
|
1663
|
-
//
|
|
1664
|
-
// only anchor test cases to matches that survived the shadow.
|
|
1681
|
+
// Under a local same-name shadow the term in a test's title is
|
|
1682
|
+
// the file's OWN helper: titles link only where a match survived.
|
|
1665
1683
|
if (!isFilePath && sourceFileLinked &&
|
|
1666
1684
|
(!localShadow || matches.some(m => m.matchType !== 'import'))) {
|
|
1667
|
-
|
|
1685
|
+
titled.add(entry.relativePath);
|
|
1668
1686
|
}
|
|
1669
1687
|
|
|
1670
1688
|
// Deduplicate: if a line already has a 'call' or 'import', don't also add 'test-case'
|
|
@@ -1705,7 +1723,6 @@ function tests(index, nameOrFile, options = {}) {
|
|
|
1705
1723
|
// command must not silently drop. Repeated names never receive that broad
|
|
1706
1724
|
// supplement because it would leak sibling classes/files.
|
|
1707
1725
|
if (!isFilePath && targetDefs.length > 0) {
|
|
1708
|
-
const testInfo = new Map(testFiles.map(t => [t.path, t]));
|
|
1709
1726
|
const callerSites = [];
|
|
1710
1727
|
// A pinned definition's unverified sites were adjudicated against it
|
|
1711
1728
|
// alone, like a globally unique name's (fix #430).
|
|
@@ -1725,7 +1742,7 @@ function tests(index, nameOrFile, options = {}) {
|
|
|
1725
1742
|
}
|
|
1726
1743
|
|
|
1727
1744
|
for (const site of callerSites) {
|
|
1728
|
-
const info =
|
|
1745
|
+
const info = testInfoOf(site.file);
|
|
1729
1746
|
if (!info) continue;
|
|
1730
1747
|
if (info.testRanges && !lineInRanges(site.line, info.testRanges)) continue;
|
|
1731
1748
|
// Every site here was adjudicated against the exact target
|
|
@@ -1780,16 +1797,22 @@ function tests(index, nameOrFile, options = {}) {
|
|
|
1780
1797
|
const nestedTypeDefs = targetDefs.filter(def => def.enclosingType &&
|
|
1781
1798
|
['class', 'struct', 'interface', 'type', 'enum', 'trait', 'record', 'union'].includes(def.type));
|
|
1782
1799
|
if (accessorDefs.length > 0 || nestedTypeDefs.length > 0) {
|
|
1783
|
-
|
|
1800
|
+
// Reference-only tests have no call site to add their file to
|
|
1801
|
+
// testFiles above. Let the reference engines find their sites
|
|
1802
|
+
// first, then ask the same model which lines a test executes.
|
|
1803
|
+
// The index-only gate spares files that cannot hold test code;
|
|
1804
|
+
// filenames alone never remove a candidate.
|
|
1805
|
+
const referencePaths = new Set([...index.files.keys()].filter(filePath =>
|
|
1806
|
+
testInfoByPath.get(filePath) || model.mayHoldTests(filePath)));
|
|
1784
1807
|
const globallyUnique = index.find(searchTerm, { exact: true }).length === 1;
|
|
1785
1808
|
for (const def of [...accessorDefs, ...nestedTypeDefs]) {
|
|
1786
1809
|
const types = nestedTypeDefs.includes(def)
|
|
1787
|
-
? require('./analysis').findTypeReferences(index, searchTerm, def, { files:
|
|
1810
|
+
? require('./analysis').findTypeReferences(index, searchTerm, def, { files: referencePaths }) : null;
|
|
1788
1811
|
const typeSite = site => ({ ...site, absoluteFile: path.join(index.root, site.file) });
|
|
1789
1812
|
const refs = types ? {
|
|
1790
1813
|
confirmed: types.byFile.flatMap(group => group.sites).map(typeSite),
|
|
1791
1814
|
unverified: types.unverifiedSites.map(typeSite),
|
|
1792
|
-
} : findAccessorReferences(index, searchTerm, def, { files:
|
|
1815
|
+
} : findAccessorReferences(index, searchTerm, def, { files: referencePaths });
|
|
1793
1816
|
if (!refs) continue;
|
|
1794
1817
|
const rows = [
|
|
1795
1818
|
...refs.confirmed.map(r => ({ ...r, tierName: 'confirmed' })),
|
|
@@ -1797,7 +1820,7 @@ function tests(index, nameOrFile, options = {}) {
|
|
|
1797
1820
|
? refs.unverified.map(r => ({ ...r, tierName: 'unverified' })) : []),
|
|
1798
1821
|
];
|
|
1799
1822
|
for (const ref of rows) {
|
|
1800
|
-
const info =
|
|
1823
|
+
const info = testInfoOf(ref.absoluteFile);
|
|
1801
1824
|
if (!info) continue;
|
|
1802
1825
|
if (info.testRanges && !lineInRanges(ref.line, info.testRanges)) continue;
|
|
1803
1826
|
if (excludeArr.length > 0 &&
|
|
@@ -1835,6 +1858,55 @@ function tests(index, nameOrFile, options = {}) {
|
|
|
1835
1858
|
results.sort((a, b) => codeUnitCompare(a.file || '', b.file || ''));
|
|
1836
1859
|
}
|
|
1837
1860
|
|
|
1861
|
+
// The tests that hold the matches (core/test-units.js): one row per
|
|
1862
|
+
// test, then what the file is to its runner and how to run it.
|
|
1863
|
+
const absolute = new Map(testFiles.map(info => [info.entry.relativePath, info.path]));
|
|
1864
|
+
const linesByFile = new Map();
|
|
1865
|
+
for (const fileResult of results) {
|
|
1866
|
+
const testPath = absolute.get(fileResult.file);
|
|
1867
|
+
if (!testPath) continue;
|
|
1868
|
+
addTestCaseRows(model, index, testPath, isFilePath ? null : searchTerm, fileResult.matches, {
|
|
1869
|
+
counts: match => match.matchType !== 'import',
|
|
1870
|
+
titles: titled.has(fileResult.file),
|
|
1871
|
+
titleNeedsMatch: !!className,
|
|
1872
|
+
});
|
|
1873
|
+
linesByFile.set(testPath,
|
|
1874
|
+
fileResult.matches.filter(match => match.matchType !== 'import').map(match => match.line));
|
|
1875
|
+
}
|
|
1876
|
+
// A test a class inherits runs where the class is: the file of a class
|
|
1877
|
+
// that inherits a linked test is a result of its own, with the test.
|
|
1878
|
+
const byFile = new Map(results.map(fileResult => [fileResult.file, fileResult]));
|
|
1879
|
+
for (const [testPath, linked] of linkFiles(model, index, linesByFile)) {
|
|
1880
|
+
const relativePath = index.files.get(testPath).relativePath;
|
|
1881
|
+
let fileResult = byFile.get(relativePath);
|
|
1882
|
+
if (!fileResult) {
|
|
1883
|
+
fileResult = { file: relativePath, matches: [] };
|
|
1884
|
+
byFile.set(relativePath, fileResult);
|
|
1885
|
+
results.push(fileResult);
|
|
1886
|
+
}
|
|
1887
|
+
for (const unit of linked.units) {
|
|
1888
|
+
if (!unit.inheritedFrom) continue;
|
|
1889
|
+
fileResult.matches.push({
|
|
1890
|
+
line: unit.line,
|
|
1891
|
+
content: (index.getLineContent(testPath, unit.line) || '').trim(),
|
|
1892
|
+
matchType: 'inherited-test',
|
|
1893
|
+
name: unit.name,
|
|
1894
|
+
from: unit.inheritedFrom,
|
|
1895
|
+
});
|
|
1896
|
+
}
|
|
1897
|
+
fileResult.matches.sort((a, b) => (a.line || 0) - (b.line || 0) ||
|
|
1898
|
+
codeUnitCompare(a.matchType || '', b.matchType || '') || codeUnitCompare(a.name || '', b.name || ''));
|
|
1899
|
+
fileResult.role = linked.role;
|
|
1900
|
+
if (linked.runner) fileResult.runner = linked.runner;
|
|
1901
|
+
fileResult.tests = linked.tests;
|
|
1902
|
+
if (linked.moreTests) fileResult.moreTests = true;
|
|
1903
|
+
if (linked.conditionalTests) fileResult.conditionalTests = linked.conditionalTests;
|
|
1904
|
+
if (linked.units.length > 0) fileResult.units = linked.units;
|
|
1905
|
+
if (linked.run) fileResult.run = linked.run;
|
|
1906
|
+
else if (linked.runWithheld) fileResult.runWithheld = linked.runWithheld;
|
|
1907
|
+
}
|
|
1908
|
+
results.sort((a, b) => codeUnitCompare(a.file || '', b.file || ''));
|
|
1909
|
+
|
|
1838
1910
|
return results;
|
|
1839
1911
|
} finally { index._endOp(); }
|
|
1840
1912
|
}
|
|
@@ -2181,117 +2253,6 @@ function _receiverMatchesClass(usage, dispatchNames, instanceTypeMap, lineConten
|
|
|
2181
2253
|
return false;
|
|
2182
2254
|
}
|
|
2183
2255
|
|
|
2184
|
-
/**
|
|
2185
|
-
* Language-aware test-case detection.
|
|
2186
|
-
*
|
|
2187
|
-
* JS/TS: describe, it, test, spec calls with searchTerm in the description.
|
|
2188
|
-
* Go: TestXxx, BenchmarkXxx, ExampleXxx functions containing a usage of searchTerm.
|
|
2189
|
-
* Python: test_ functions or TestCase methods containing a usage of searchTerm.
|
|
2190
|
-
* Java: @Test-annotated methods containing a usage of searchTerm.
|
|
2191
|
-
* Rust: #[test]-attributed functions containing a usage of searchTerm.
|
|
2192
|
-
*/
|
|
2193
|
-
function _addTestCaseMatches(index, filePath, fileEntry, searchTerm, className, instanceTypeMap, matches) {
|
|
2194
|
-
const matchLines = new Set(matches.map(m => m.line));
|
|
2195
|
-
const lang = fileEntry.language;
|
|
2196
|
-
|
|
2197
|
-
if (lang === 'javascript' || lang === 'typescript' || lang === 'tsx') {
|
|
2198
|
-
// JS/TS: find describe/it/test/spec calls from getCachedCalls
|
|
2199
|
-
const calls = getCachedCalls(index, filePath);
|
|
2200
|
-
if (!calls) return;
|
|
2201
|
-
const testFrameworkCalls = new Set(['describe', 'it', 'test', 'spec']);
|
|
2202
|
-
// Word-boundary match: `describe("untargeted zone")` is not a test
|
|
2203
|
-
// case for `target` (fix #246 — raw substring matched mid-word).
|
|
2204
|
-
const termPattern = new RegExp('(^|[^\\w$])' + escapeRegExp(searchTerm) + '([^\\w$]|$)');
|
|
2205
|
-
for (const call of calls) {
|
|
2206
|
-
if (!testFrameworkCalls.has(call.name)) continue;
|
|
2207
|
-
const lineContent = index.getLineContent(filePath, call.line);
|
|
2208
|
-
// Check if searchTerm appears in the description string on this line
|
|
2209
|
-
if (termPattern.test(lineContent) && !matchLines.has(call.line)) {
|
|
2210
|
-
// className scoping: only add test-case if the test body has a
|
|
2211
|
-
// class-scoped match (call or class-receiver reference) — not just
|
|
2212
|
-
// any mention of className.
|
|
2213
|
-
if (className) {
|
|
2214
|
-
const endLine = _estimateTestBlockEnd(index, filePath, call.line);
|
|
2215
|
-
const hasClassScopedMatch = matches.some(m =>
|
|
2216
|
-
m.line >= call.line && m.line <= endLine &&
|
|
2217
|
-
m.matchType !== 'import'
|
|
2218
|
-
);
|
|
2219
|
-
if (!hasClassScopedMatch) continue;
|
|
2220
|
-
}
|
|
2221
|
-
matches.push({
|
|
2222
|
-
line: call.line,
|
|
2223
|
-
content: lineContent.trim(),
|
|
2224
|
-
matchType: 'test-case'
|
|
2225
|
-
});
|
|
2226
|
-
matchLines.add(call.line);
|
|
2227
|
-
}
|
|
2228
|
-
}
|
|
2229
|
-
} else {
|
|
2230
|
-
// Go/Python/Java/Rust: check if any AST usage falls within a test function's range
|
|
2231
|
-
if (!fileEntry.symbols) return;
|
|
2232
|
-
try {
|
|
2233
|
-
const langModule = getLanguageAdapter(lang);
|
|
2234
|
-
if (!langModule) return;
|
|
2235
|
-
// Prefer kinded predicate so fn main() and fn init() aren't classified
|
|
2236
|
-
// as test cases (BUG-CX). Fall back to isEntryPoint for compat.
|
|
2237
|
-
const classify = langModule.getEntryPointKind
|
|
2238
|
-
? (s) => langModule.getEntryPointKind(s) === 'test'
|
|
2239
|
-
: langModule.isEntryPoint;
|
|
2240
|
-
if (!classify) return;
|
|
2241
|
-
|
|
2242
|
-
// Find test symbols
|
|
2243
|
-
for (const symbol of fileEntry.symbols) {
|
|
2244
|
-
if (!classify(symbol)) continue;
|
|
2245
|
-
// Check if any non-import usage of searchTerm falls within this test function
|
|
2246
|
-
const usageInRange = matches.some(m =>
|
|
2247
|
-
m.line >= symbol.startLine && m.line <= symbol.endLine &&
|
|
2248
|
-
m.matchType !== 'import'
|
|
2249
|
-
);
|
|
2250
|
-
if (usageInRange && !matchLines.has(symbol.startLine)) {
|
|
2251
|
-
// className scoping: verify the test body has class-scoped matches
|
|
2252
|
-
if (className) {
|
|
2253
|
-
const hasClassScopedMatch = matches.some(m =>
|
|
2254
|
-
m.line >= symbol.startLine && m.line <= symbol.endLine &&
|
|
2255
|
-
m.matchType !== 'import'
|
|
2256
|
-
);
|
|
2257
|
-
if (!hasClassScopedMatch) continue;
|
|
2258
|
-
}
|
|
2259
|
-
const lineContent = index.getLineContent(filePath, symbol.startLine);
|
|
2260
|
-
matches.push({
|
|
2261
|
-
line: symbol.startLine,
|
|
2262
|
-
content: lineContent.trim(),
|
|
2263
|
-
matchType: 'test-case'
|
|
2264
|
-
});
|
|
2265
|
-
matchLines.add(symbol.startLine);
|
|
2266
|
-
}
|
|
2267
|
-
}
|
|
2268
|
-
} catch (e) {
|
|
2269
|
-
// Skip if language module unavailable
|
|
2270
|
-
}
|
|
2271
|
-
}
|
|
2272
|
-
}
|
|
2273
|
-
|
|
2274
|
-
/**
|
|
2275
|
-
* Estimate the end line of a test block (it/test/describe callback) by tracking
|
|
2276
|
-
* brace nesting from the start line.
|
|
2277
|
-
*/
|
|
2278
|
-
function _estimateTestBlockEnd(index, filePath, startLine) {
|
|
2279
|
-
const content = index._readFile(filePath);
|
|
2280
|
-
if (!content) return startLine + 5;
|
|
2281
|
-
const lines = content.split('\n');
|
|
2282
|
-
let depth = 0;
|
|
2283
|
-
let started = false;
|
|
2284
|
-
for (let i = startLine - 1; i < lines.length; i++) {
|
|
2285
|
-
const line = lines[i];
|
|
2286
|
-
for (const ch of line) {
|
|
2287
|
-
if (ch === '{' || ch === '(') { depth++; started = true; }
|
|
2288
|
-
else if (ch === '}' || ch === ')') { depth--; }
|
|
2289
|
-
}
|
|
2290
|
-
if (started && depth <= 0) return i + 1; // 1-based
|
|
2291
|
-
}
|
|
2292
|
-
return Math.min(startLine + 10, lines.length);
|
|
2293
|
-
}
|
|
2294
|
-
|
|
2295
2256
|
/**
|
|
2296
2257
|
* Deduplicate matches: prefer more specific matchTypes on the same line.
|
|
2297
2258
|
* Priority: test-case > call > import > string-ref > reference
|
package/core/shared.js
CHANGED
|
@@ -260,49 +260,6 @@ function isOverrideMarked(def) {
|
|
|
260
260
|
return false;
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
-
// Per-language text patterns for the "blind spots" UCN's AST can't follow:
|
|
264
|
-
// eval/exec-style code execution and reflection (dynamic attribute access /
|
|
265
|
-
// dynamic dispatch). ONE source of truth so doctor's trust scan and
|
|
266
|
-
// detectCompleteness's about-footer warning count identically (field-report #2:
|
|
267
|
-
// they used to diverge — doctor 497 reflection vs footer 194, eval 3 vs 2 —
|
|
268
|
-
// because each kept its own regex set). Dynamic imports are NOT here: those are
|
|
269
|
-
// structural (fileEntry.dynamicImports), the AST-accurate count both paths share.
|
|
270
|
-
// `new Function(...)` is categorized as eval (code execution), not reflection.
|
|
271
|
-
const BLINDSPOT_TEXT_PATTERNS = {
|
|
272
|
-
reflection: {
|
|
273
|
-
python: /\b(getattr|hasattr|setattr|__import__|importlib\.import_module)\s*\(/g,
|
|
274
|
-
javascript: /\bReflect\.\w+\s*\(/g,
|
|
275
|
-
typescript: /\bReflect\.\w+\s*\(/g,
|
|
276
|
-
go: /\breflect\.\w+\s*\(/g,
|
|
277
|
-
java: /\.getDeclaredMethod\b|\.getMethod\b|\.getDeclaredField\b|Class\.forName\b/g,
|
|
278
|
-
rust: /\bAny::downcast/g,
|
|
279
|
-
},
|
|
280
|
-
eval: {
|
|
281
|
-
python: /\b(eval|exec)\s*\(/g,
|
|
282
|
-
javascript: /\beval\s*\(|\bnew\s+Function\s*\(/g,
|
|
283
|
-
typescript: /\beval\s*\(|\bnew\s+Function\s*\(/g,
|
|
284
|
-
},
|
|
285
|
-
};
|
|
286
|
-
|
|
287
|
-
/** True when a language has any text-blind-spot pattern (so callers can skip the file read otherwise). */
|
|
288
|
-
function hasTextBlindspots(language) {
|
|
289
|
-
return !!(BLINDSPOT_TEXT_PATTERNS.reflection[language] || BLINDSPOT_TEXT_PATTERNS.eval[language]);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/**
|
|
293
|
-
* Count text-detected blind spots (eval/exec, reflection) in one file's source.
|
|
294
|
-
* Returns { eval, reflection } OCCURRENCE counts (global match). Shared by doctor
|
|
295
|
-
* and detectCompleteness so both report the same numbers (field-report #2).
|
|
296
|
-
*/
|
|
297
|
-
function countTextBlindspots(content, language) {
|
|
298
|
-
const reRe = BLINDSPOT_TEXT_PATTERNS.reflection[language];
|
|
299
|
-
const evRe = BLINDSPOT_TEXT_PATTERNS.eval[language];
|
|
300
|
-
return {
|
|
301
|
-
eval: evRe ? (content.match(evRe) || []).length : 0,
|
|
302
|
-
reflection: reRe ? (content.match(reRe) || []).length : 0,
|
|
303
|
-
};
|
|
304
|
-
}
|
|
305
|
-
|
|
306
263
|
/**
|
|
307
264
|
* Line ranges of INLINE test symbols in a source file — Rust #[test] fns and
|
|
308
265
|
* #[cfg(test)] module members. A production file promoted to "test file"
|
|
@@ -614,8 +571,6 @@ module.exports = { literalNameRegex,
|
|
|
614
571
|
looksLikeHandle,
|
|
615
572
|
isTestPath,
|
|
616
573
|
isOverrideMarked,
|
|
617
|
-
hasTextBlindspots,
|
|
618
|
-
countTextBlindspots,
|
|
619
574
|
isPathInsideRoot,
|
|
620
575
|
isMacroNamespaceDefinition,
|
|
621
576
|
};
|