@archora/core 1.1.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/LICENSE +201 -0
- package/README.md +62 -0
- package/package.json +36 -0
- package/src/README.md +4 -0
- package/src/analyzer/__tests__/__snapshots__/referenceSnapshot.test.ts.snap +145 -0
- package/src/analyzer/__tests__/_paths.ts +8 -0
- package/src/analyzer/__tests__/analyze.test.ts +522 -0
- package/src/analyzer/__tests__/archDebt.test.ts +111 -0
- package/src/analyzer/__tests__/asyncLifecycleRisk.test.ts +122 -0
- package/src/analyzer/__tests__/browserFsAccessFileSource.test.ts +97 -0
- package/src/analyzer/__tests__/bundle.test.ts +191 -0
- package/src/analyzer/__tests__/classify.test.ts +99 -0
- package/src/analyzer/__tests__/contracts.test.ts +372 -0
- package/src/analyzer/__tests__/crossSourceConsistency.test.ts +317 -0
- package/src/analyzer/__tests__/cyclePatterns.test.ts +132 -0
- package/src/analyzer/__tests__/cycles.test.ts +74 -0
- package/src/analyzer/__tests__/detect.test.ts +62 -0
- package/src/analyzer/__tests__/discover.test.ts +68 -0
- package/src/analyzer/__tests__/displayId.test.ts +30 -0
- package/src/analyzer/__tests__/feedbackArcSet.test.ts +168 -0
- package/src/analyzer/__tests__/inMemoryFileSource.test.ts +34 -0
- package/src/analyzer/__tests__/incremental.test.ts +154 -0
- package/src/analyzer/__tests__/layers.test.ts +87 -0
- package/src/analyzer/__tests__/layersOverrides.test.ts +120 -0
- package/src/analyzer/__tests__/memoryRisk.test.ts +132 -0
- package/src/analyzer/__tests__/metrics.test.ts +59 -0
- package/src/analyzer/__tests__/parserRegistry.test.ts +54 -0
- package/src/analyzer/__tests__/parsers.test.ts +187 -0
- package/src/analyzer/__tests__/reactParser.test.ts +93 -0
- package/src/analyzer/__tests__/recommendations.test.ts +171 -0
- package/src/analyzer/__tests__/referenceSnapshot.test.ts +63 -0
- package/src/analyzer/__tests__/resolve.test.ts +294 -0
- package/src/analyzer/__tests__/rsc.test.ts +130 -0
- package/src/analyzer/__tests__/signals.test.ts +316 -0
- package/src/analyzer/__tests__/suggestContracts.test.ts +108 -0
- package/src/analyzer/__tests__/svelteParser.test.ts +108 -0
- package/src/analyzer/__tests__/typeOnlyCandidates.test.ts +163 -0
- package/src/analyzer/__tests__/vueAutoImport.test.ts +177 -0
- package/src/analyzer/archDebt.ts +68 -0
- package/src/analyzer/asyncLifecycleRisk.ts +234 -0
- package/src/analyzer/buildGraph.ts +683 -0
- package/src/analyzer/bundle/analyzeBundle.ts +147 -0
- package/src/analyzer/bundle/index.ts +12 -0
- package/src/analyzer/bundle/parseStats.ts +152 -0
- package/src/analyzer/bundle/types.ts +85 -0
- package/src/analyzer/classify.ts +54 -0
- package/src/analyzer/contracts.ts +265 -0
- package/src/analyzer/cyclePatterns.ts +138 -0
- package/src/analyzer/cycles.ts +98 -0
- package/src/analyzer/detect.ts +34 -0
- package/src/analyzer/discover.ts +131 -0
- package/src/analyzer/displayId.ts +21 -0
- package/src/analyzer/entryPoints.ts +136 -0
- package/src/analyzer/feedbackArcSet.ts +332 -0
- package/src/analyzer/fileSource.ts +8 -0
- package/src/analyzer/hotZones.ts +17 -0
- package/src/analyzer/incremental.ts +455 -0
- package/src/analyzer/index.ts +444 -0
- package/src/analyzer/layers.ts +183 -0
- package/src/analyzer/loadAliases.ts +288 -0
- package/src/analyzer/memoryRisk.ts +345 -0
- package/src/analyzer/metrics.ts +156 -0
- package/src/analyzer/parsers/index.ts +62 -0
- package/src/analyzer/parsers/reactParser.ts +24 -0
- package/src/analyzer/parsers/svelteParser.ts +46 -0
- package/src/analyzer/parsers/tsParser.ts +364 -0
- package/src/analyzer/parsers/vueParser.ts +109 -0
- package/src/analyzer/recommendations.ts +432 -0
- package/src/analyzer/resolve.ts +315 -0
- package/src/analyzer/rsc.ts +120 -0
- package/src/analyzer/signals.ts +684 -0
- package/src/analyzer/sources/browserFsAccessFileSource.ts +132 -0
- package/src/analyzer/sources/inMemoryFileSource.ts +24 -0
- package/src/analyzer/sources/nodeFsFileSource.ts +93 -0
- package/src/analyzer/sources/tauriFileSource.ts +68 -0
- package/src/analyzer/suggestContracts.ts +214 -0
- package/src/analyzer/typeOnlyCandidates.ts +233 -0
- package/src/analyzer/types.ts +537 -0
- package/src/cache/__tests__/cache.test.ts +316 -0
- package/src/cache/index.ts +432 -0
- package/src/codegen/__tests__/applyTypeOnlyFix.integration.test.ts +62 -0
- package/src/codegen/__tests__/applyTypeOnlyFix.test.ts +176 -0
- package/src/codegen/__tests__/configSnippets.test.ts +230 -0
- package/src/codegen/applyTypeOnlyFix.ts +344 -0
- package/src/codegen/configSnippets.ts +172 -0
- package/src/codegen/initConfig.ts +223 -0
- package/src/config/__tests__/frontScopeConfig.test.ts +187 -0
- package/src/config/frontScopeConfig.ts +830 -0
- package/src/diff/__tests__/diffScans.test.ts +103 -0
- package/src/diff/diffScans.ts +61 -0
- package/src/diff/index.ts +2 -0
- package/src/diff/types.ts +39 -0
- package/src/git/__tests__/computeChurn.test.ts +113 -0
- package/src/git/__tests__/computeTemporalCoupling.test.ts +125 -0
- package/src/git/__tests__/parseGitLog.test.ts +120 -0
- package/src/git/computeChurn.ts +111 -0
- package/src/git/computeTemporalCoupling.ts +114 -0
- package/src/git/index.ts +24 -0
- package/src/git/parseGitLog.ts +124 -0
- package/src/git/readGitHistory.ts +130 -0
- package/src/git/types.ts +119 -0
- package/src/index.ts +137 -0
- package/src/report/__tests__/buildFixPlan.test.ts +357 -0
- package/src/report/__tests__/buildJsonReport.test.ts +34 -0
- package/src/report/buildFixPlan.ts +481 -0
- package/src/report/buildJsonReport.ts +27 -0
- package/src/search/__tests__/parseQuery.test.ts +67 -0
- package/src/search/__tests__/search.test.ts +172 -0
- package/src/search/index.ts +281 -0
- package/src/search/parseQuery.ts +75 -0
- package/src/views/__tests__/analyzerViews.test.ts +558 -0
- package/src/views/analyzerViews.ts +1294 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// search() работает на полностью искусственном ScanResult — нам важна
|
|
2
|
+
// логика ранкера и префикс-фильтров, а не парсер. Минимально-валидный
|
|
3
|
+
// fixture-builder ниже соблюдает форму типов, но не претендует на
|
|
4
|
+
// семантическую полноту (нет цикл-/метрик-ссылок).
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from 'vitest';
|
|
7
|
+
import type {
|
|
8
|
+
ScanResult,
|
|
9
|
+
ModuleNode,
|
|
10
|
+
DependencyEdge,
|
|
11
|
+
ModuleId,
|
|
12
|
+
ModuleKind,
|
|
13
|
+
} from '../../analyzer/types';
|
|
14
|
+
import { search } from '../index';
|
|
15
|
+
|
|
16
|
+
interface ModuleSpec {
|
|
17
|
+
id: ModuleId;
|
|
18
|
+
kind?: ModuleKind;
|
|
19
|
+
exports?: string[];
|
|
20
|
+
imports?: { to?: string; specifier: string }[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function buildScan(modules: ModuleSpec[]): ScanResult {
|
|
24
|
+
const moduleNodes: ModuleNode[] = modules.map((m) => ({
|
|
25
|
+
id: m.id,
|
|
26
|
+
absPath: m.id,
|
|
27
|
+
kind: m.kind ?? 'unknown',
|
|
28
|
+
language: 'ts',
|
|
29
|
+
loc: 1,
|
|
30
|
+
exports: m.exports ?? [],
|
|
31
|
+
isInfra: false,
|
|
32
|
+
}));
|
|
33
|
+
const edges: DependencyEdge[] = [];
|
|
34
|
+
for (const m of modules) {
|
|
35
|
+
for (const i of m.imports ?? []) {
|
|
36
|
+
edges.push({
|
|
37
|
+
from: m.id,
|
|
38
|
+
to: i.to ?? '',
|
|
39
|
+
kind: 'static',
|
|
40
|
+
specifier: i.specifier,
|
|
41
|
+
resolved: Boolean(i.to),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const metrics = Object.fromEntries(
|
|
46
|
+
moduleNodes.map((m) => [
|
|
47
|
+
m.id,
|
|
48
|
+
{
|
|
49
|
+
fanIn: 0,
|
|
50
|
+
fanOut: 0,
|
|
51
|
+
instability: 0,
|
|
52
|
+
depth: 0,
|
|
53
|
+
inCycle: false,
|
|
54
|
+
couplingScore: 0,
|
|
55
|
+
hotnessScore: 0,
|
|
56
|
+
},
|
|
57
|
+
]),
|
|
58
|
+
);
|
|
59
|
+
return {
|
|
60
|
+
project: {
|
|
61
|
+
id: 'p',
|
|
62
|
+
name: 'p',
|
|
63
|
+
rootPath: '/p',
|
|
64
|
+
detectedFramework: 'unknown',
|
|
65
|
+
},
|
|
66
|
+
modules: moduleNodes,
|
|
67
|
+
edges,
|
|
68
|
+
cycles: [],
|
|
69
|
+
metrics,
|
|
70
|
+
hotZones: [],
|
|
71
|
+
layerViolations: [],
|
|
72
|
+
archDebt: {
|
|
73
|
+
score: 0,
|
|
74
|
+
grade: 'A',
|
|
75
|
+
breakdown: { cycles: 0, layerViolations: 0, hotZones: 0, coupling: 0 },
|
|
76
|
+
},
|
|
77
|
+
recommendations: [],
|
|
78
|
+
contractViolations: [],
|
|
79
|
+
scannedAt: new Date().toISOString(),
|
|
80
|
+
durationMs: 0,
|
|
81
|
+
warnings: [],
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const FIXTURE = buildScan([
|
|
86
|
+
{ id: 'src/features/auth/composables/useAuth.ts', kind: 'composable', exports: ['useAuth'] },
|
|
87
|
+
{ id: 'src/features/auth/AuthForm.vue', kind: 'component', exports: ['default'] },
|
|
88
|
+
{
|
|
89
|
+
id: 'src/features/checkout/CheckoutForm.vue',
|
|
90
|
+
kind: 'component',
|
|
91
|
+
exports: ['default'],
|
|
92
|
+
imports: [{ specifier: 'react-query' }, { specifier: '@/features/auth' }],
|
|
93
|
+
},
|
|
94
|
+
{ id: 'src/shared/lib/api.ts', kind: 'util', exports: ['api', 'apiClient'] },
|
|
95
|
+
{ id: 'src/stores/userStore.ts', kind: 'store', exports: ['useUserStore'] },
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
describe('search', () => {
|
|
99
|
+
it('пустой query → пустая выдача', () => {
|
|
100
|
+
expect(search(FIXTURE, '')).toEqual([]);
|
|
101
|
+
expect(search(FIXTURE, ' ')).toEqual([]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('export:useAuth находит composable', () => {
|
|
105
|
+
const r = search(FIXTURE, 'export:useAuth');
|
|
106
|
+
expect(r.map((x) => x.id)).toEqual(['src/features/auth/composables/useAuth.ts']);
|
|
107
|
+
expect(r[0]!.matched).toContain('export');
|
|
108
|
+
expect(r[0]!.highlights.export).toContain('useAuth');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('import:react-query находит модули с этим specifier', () => {
|
|
112
|
+
const r = search(FIXTURE, 'import:react-query');
|
|
113
|
+
expect(r.map((x) => x.id)).toEqual(['src/features/checkout/CheckoutForm.vue']);
|
|
114
|
+
expect(r[0]!.matched).toContain('import');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('kind:store фильтрует по типу', () => {
|
|
118
|
+
const r = search(FIXTURE, 'kind:store');
|
|
119
|
+
expect(r.map((x) => x.id)).toEqual(['src/stores/userStore.ts']);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('path:features ищет substring в path (case-insensitive)', () => {
|
|
123
|
+
const r = search(FIXTURE, 'path:Features');
|
|
124
|
+
const ids = r.map((x) => x.id);
|
|
125
|
+
expect(ids).toContain('src/features/auth/composables/useAuth.ts');
|
|
126
|
+
expect(ids).toContain('src/features/checkout/CheckoutForm.vue');
|
|
127
|
+
expect(ids).not.toContain('src/shared/lib/api.ts');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('AND-пересечение: kind:component path:auth', () => {
|
|
131
|
+
const r = search(FIXTURE, 'kind:component path:auth');
|
|
132
|
+
expect(r.map((x) => x.id)).toEqual(['src/features/auth/AuthForm.vue']);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('OR внутри ключа: kind:component kind:composable', () => {
|
|
136
|
+
const r = search(FIXTURE, 'kind:component kind:composable');
|
|
137
|
+
const ids = new Set(r.map((x) => x.id));
|
|
138
|
+
expect(ids.has('src/features/auth/AuthForm.vue')).toBe(true);
|
|
139
|
+
expect(ids.has('src/features/auth/composables/useAuth.ts')).toBe(true);
|
|
140
|
+
expect(ids.has('src/stores/userStore.ts')).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('free-text без префикса: ранкер находит совпадения по path и exports', () => {
|
|
144
|
+
const r = search(FIXTURE, 'useAuth');
|
|
145
|
+
// useAuth.ts должен быть на верху (точное совпадение exports + basename)
|
|
146
|
+
expect(r[0]!.id).toBe('src/features/auth/composables/useAuth.ts');
|
|
147
|
+
expect(r[0]!.score).toBeGreaterThan(0.5);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('точное совпадение basename ранжируется выше substring-совпадения', () => {
|
|
151
|
+
const r = search(FIXTURE, 'useAuth');
|
|
152
|
+
// useAuth.ts — basename match; AuthForm.vue — только path-substring
|
|
153
|
+
expect(r[0]!.id).toBe('src/features/auth/composables/useAuth.ts');
|
|
154
|
+
const formIdx = r.findIndex((x) => x.id === 'src/features/auth/AuthForm.vue');
|
|
155
|
+
if (formIdx > -1) expect(formIdx).toBeGreaterThan(0);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('limit обрезает выдачу', () => {
|
|
159
|
+
const r = search(FIXTURE, 'path:src', { limit: 2 });
|
|
160
|
+
expect(r).toHaveLength(2);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('неизвестный kind не падает, просто не находит', () => {
|
|
164
|
+
expect(search(FIXTURE, 'kind:nonsense')).toEqual([]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('SearchResult.matched может содержать несколько критериев одновременно', () => {
|
|
168
|
+
// useAuth: совпадает по export (через export:) и по path (через free-text)
|
|
169
|
+
const r = search(FIXTURE, 'export:useAuth auth');
|
|
170
|
+
expect(r[0]!.matched).toEqual(expect.arrayContaining(['export', 'path']));
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// Глобальный поиск по `ScanResult` — без чтения файловой системы, только
|
|
2
|
+
// по структурированным метаданным (modules + edges). Стратегия:
|
|
3
|
+
//
|
|
4
|
+
// 1. Парсим query (`parseQuery`).
|
|
5
|
+
// 2. Для каждого префикса набираем set'ы ID-кандидатов; в финальный набор
|
|
6
|
+
// попадают только модули, прошедшие пересечение всех префиксов (AND).
|
|
7
|
+
// 3. Поверх отфильтрованных модулей применяем free-text ранкинг
|
|
8
|
+
// (substring-match по path и exports) и сортируем по итоговому скору.
|
|
9
|
+
// 4. Возвращаем разноимённые результаты с типом матча — UI рендерит pill-чипы
|
|
10
|
+
// «Path / Export / Import / Kind», поэтому каждому SearchResult нужно знать,
|
|
11
|
+
// почему он попал в выдачу.
|
|
12
|
+
//
|
|
13
|
+
// Не делаем: ripgrep по содержимому файлов, fuzzy-substring (типа
|
|
14
|
+
// fzf-style scoring) — для path → modules.length обычно сотни, обычный
|
|
15
|
+
// substring достаточен. Усложним, если появится сигнал «слишком шумно».
|
|
16
|
+
|
|
17
|
+
import type { ScanResult, ModuleId, ModuleKind } from '../analyzer/types';
|
|
18
|
+
import { parseQuery, isEmpty, type ParsedQuery, type SearchPrefix } from './parseQuery';
|
|
19
|
+
|
|
20
|
+
export type { SearchPrefix, ParsedQuery } from './parseQuery';
|
|
21
|
+
|
|
22
|
+
export type MatchKind = 'path' | 'export' | 'import' | 'kind';
|
|
23
|
+
|
|
24
|
+
export interface SearchResult {
|
|
25
|
+
/** Module id (relative path). */
|
|
26
|
+
id: ModuleId;
|
|
27
|
+
/** Какие критерии сработали — UI использует для подсветки чипов. */
|
|
28
|
+
matched: MatchKind[];
|
|
29
|
+
/** Score 0..1; 1 = идеальное соответствие, 0 = только prefix-фильтр прошёл. */
|
|
30
|
+
score: number;
|
|
31
|
+
/**
|
|
32
|
+
* Подсветочный hint для каждого совпавшего критерия — конкретные значения
|
|
33
|
+
* (имя экспорта, specifier импорта, kind). Используется панелью результатов
|
|
34
|
+
* для пояснения «почему этот модуль вылез на этот запрос».
|
|
35
|
+
*/
|
|
36
|
+
highlights: Partial<Record<MatchKind, string[]>>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SearchOptions {
|
|
40
|
+
/** Ограничить размер выдачи. По умолчанию 50 — типовой UI-overlay. */
|
|
41
|
+
limit?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const DEFAULT_LIMIT = 50;
|
|
45
|
+
|
|
46
|
+
export function search(
|
|
47
|
+
scan: ScanResult,
|
|
48
|
+
query: string,
|
|
49
|
+
options: SearchOptions = {},
|
|
50
|
+
): SearchResult[] {
|
|
51
|
+
const parsed = parseQuery(query);
|
|
52
|
+
if (isEmpty(parsed)) return [];
|
|
53
|
+
|
|
54
|
+
const limit = options.limit ?? DEFAULT_LIMIT;
|
|
55
|
+
const moduleIds = new Set(scan.modules.map((m) => m.id));
|
|
56
|
+
|
|
57
|
+
// 1. Префиксные фильтры выдают `Map<id, MatchKind[]> + highlights` —
|
|
58
|
+
// если префикс есть, попасть в выдачу можно только пройдя его.
|
|
59
|
+
const prefixHits = collectPrefixHits(scan, parsed);
|
|
60
|
+
|
|
61
|
+
// Если есть префиксы — оставляем только модули, попавшие во все указанные.
|
|
62
|
+
const eligibleIds = applyPrefixIntersection(moduleIds, prefixHits, parsed.prefixes);
|
|
63
|
+
|
|
64
|
+
// 2. Free-text ранкинг. Если free пуст и префиксы есть — отдаём eligible
|
|
65
|
+
// как есть со скором 0.5 (нейтральный). Если free есть — считаем скор и
|
|
66
|
+
// отбрасываем модули, у которых ни один free-токен не сматчился, КОГДА
|
|
67
|
+
// префиксов нет; иначе free снижает скор, но не отсекает.
|
|
68
|
+
const results: SearchResult[] = [];
|
|
69
|
+
for (const id of eligibleIds) {
|
|
70
|
+
const module = scan.modules.find((m) => m.id === id);
|
|
71
|
+
if (!module) continue;
|
|
72
|
+
const matched: MatchKind[] = [];
|
|
73
|
+
const highlights: Partial<Record<MatchKind, string[]>> = {};
|
|
74
|
+
|
|
75
|
+
// соберём подтверждения от префикс-хитов
|
|
76
|
+
for (const kind of ['path', 'export', 'import', 'kind'] as const) {
|
|
77
|
+
const hit = prefixHits[kind].get(id);
|
|
78
|
+
if (hit) {
|
|
79
|
+
matched.push(kind);
|
|
80
|
+
highlights[kind] = hit;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let freeScore = 0;
|
|
85
|
+
if (parsed.free.length > 0) {
|
|
86
|
+
const f = scoreFreeText(module.id, module.exports, parsed.free);
|
|
87
|
+
freeScore = f.score;
|
|
88
|
+
if (f.score > 0) {
|
|
89
|
+
if (f.matchedPath && !matched.includes('path')) {
|
|
90
|
+
matched.push('path');
|
|
91
|
+
(highlights.path ??= []).push(...f.matchedPathTerms);
|
|
92
|
+
}
|
|
93
|
+
if (f.matchedExports.length > 0 && !matched.includes('export')) {
|
|
94
|
+
matched.push('export');
|
|
95
|
+
(highlights.export ??= []).push(...f.matchedExports);
|
|
96
|
+
}
|
|
97
|
+
} else if (matched.length === 0) {
|
|
98
|
+
// Нет ни префикс-матча, ни free-матча: модуль вообще не попал.
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const score = parsed.free.length === 0 ? 0.5 : freeScore || 0.5;
|
|
104
|
+
results.push({ id, matched, score, highlights });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 3. Сортировка: больший score → раньше; tiebreak по короче-путь → раньше
|
|
108
|
+
// (короткие пути обычно ближе к корню фичи, чем глубокие имплементации).
|
|
109
|
+
results.sort((a, b) => {
|
|
110
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
111
|
+
return a.id.length - b.id.length;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
return results.slice(0, limit);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
interface PrefixHits {
|
|
120
|
+
path: Map<ModuleId, string[]>;
|
|
121
|
+
export: Map<ModuleId, string[]>;
|
|
122
|
+
import: Map<ModuleId, string[]>;
|
|
123
|
+
kind: Map<ModuleId, string[]>;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function collectPrefixHits(scan: ScanResult, q: ParsedQuery): PrefixHits {
|
|
127
|
+
const out: PrefixHits = {
|
|
128
|
+
path: new Map(),
|
|
129
|
+
export: new Map(),
|
|
130
|
+
import: new Map(),
|
|
131
|
+
kind: new Map(),
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
if (q.prefixes.path) {
|
|
135
|
+
const needles = q.prefixes.path.map((s) => s.toLowerCase());
|
|
136
|
+
for (const m of scan.modules) {
|
|
137
|
+
const id = m.id.toLowerCase();
|
|
138
|
+
const matched = needles.filter((n) => id.includes(n));
|
|
139
|
+
if (matched.length > 0) out.path.set(m.id, matched);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (q.prefixes.export) {
|
|
144
|
+
const needles = q.prefixes.export;
|
|
145
|
+
for (const m of scan.modules) {
|
|
146
|
+
const matched: string[] = [];
|
|
147
|
+
for (const e of m.exports) {
|
|
148
|
+
for (const n of needles) {
|
|
149
|
+
if (e === n || e.includes(n)) {
|
|
150
|
+
matched.push(e);
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (matched.length > 0) out.export.set(m.id, matched);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (q.prefixes.import) {
|
|
160
|
+
// edges.specifier — оригинальная строка импорта; non-resolved тоже учитываем
|
|
161
|
+
const needles = q.prefixes.import;
|
|
162
|
+
const matchedSpecifiersByModule = new Map<ModuleId, Set<string>>();
|
|
163
|
+
for (const e of scan.edges) {
|
|
164
|
+
for (const n of needles) {
|
|
165
|
+
const spec = e.specifier;
|
|
166
|
+
if (spec === n || spec.includes(n)) {
|
|
167
|
+
let s = matchedSpecifiersByModule.get(e.from);
|
|
168
|
+
if (!s) {
|
|
169
|
+
s = new Set();
|
|
170
|
+
matchedSpecifiersByModule.set(e.from, s);
|
|
171
|
+
}
|
|
172
|
+
s.add(spec);
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
for (const [id, set] of matchedSpecifiersByModule) {
|
|
178
|
+
out.import.set(id, [...set]);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (q.prefixes.kind) {
|
|
183
|
+
const needles = new Set(q.prefixes.kind.map((s) => s.toLowerCase() as ModuleKind));
|
|
184
|
+
for (const m of scan.modules) {
|
|
185
|
+
if (needles.has(m.kind)) out.kind.set(m.id, [m.kind]);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function applyPrefixIntersection(
|
|
193
|
+
all: Set<ModuleId>,
|
|
194
|
+
hits: PrefixHits,
|
|
195
|
+
prefixes: ParsedQuery['prefixes'],
|
|
196
|
+
): Set<ModuleId> {
|
|
197
|
+
let result = all;
|
|
198
|
+
for (const key of ['path', 'export', 'import', 'kind'] as const) {
|
|
199
|
+
if (!prefixes[key as SearchPrefix]) continue;
|
|
200
|
+
const subset = hits[key];
|
|
201
|
+
const next = new Set<ModuleId>();
|
|
202
|
+
for (const id of result) if (subset.has(id)) next.add(id);
|
|
203
|
+
result = next;
|
|
204
|
+
}
|
|
205
|
+
return result;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
interface FreeTextMatch {
|
|
209
|
+
score: number;
|
|
210
|
+
matchedPath: boolean;
|
|
211
|
+
matchedPathTerms: string[];
|
|
212
|
+
matchedExports: string[];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Простой substring-ранкер. Не fuzzy: для типичной задачи «найди useAuth»
|
|
217
|
+
* подстрока даёт стабильные результаты без шумовых матчей.
|
|
218
|
+
*
|
|
219
|
+
* Скоринг:
|
|
220
|
+
* - Точное совпадение exports[i] === term: +0.5
|
|
221
|
+
* - exports[i] contains term: +0.25
|
|
222
|
+
* - basename(path) === term: +0.4
|
|
223
|
+
* - path contains term: +0.2 (но в score раз — чтобы три совпадения не
|
|
224
|
+
* делали скор > 1)
|
|
225
|
+
* Затем нормализуем в 0..1 по числу term'ов.
|
|
226
|
+
*/
|
|
227
|
+
function scoreFreeText(id: ModuleId, exports: readonly string[], terms: string[]): FreeTextMatch {
|
|
228
|
+
let score = 0;
|
|
229
|
+
let matchedPath = false;
|
|
230
|
+
const matchedPathTerms: string[] = [];
|
|
231
|
+
const matchedExports = new Set<string>();
|
|
232
|
+
const idLower = id.toLowerCase();
|
|
233
|
+
const baseName = basenameLower(id);
|
|
234
|
+
|
|
235
|
+
for (const rawTerm of terms) {
|
|
236
|
+
const term = rawTerm.toLowerCase();
|
|
237
|
+
let bestForTerm = 0;
|
|
238
|
+
|
|
239
|
+
for (const e of exports) {
|
|
240
|
+
const eLower = e.toLowerCase();
|
|
241
|
+
if (eLower === term) {
|
|
242
|
+
bestForTerm = Math.max(bestForTerm, 0.5);
|
|
243
|
+
matchedExports.add(e);
|
|
244
|
+
} else if (eLower.includes(term)) {
|
|
245
|
+
bestForTerm = Math.max(bestForTerm, 0.25);
|
|
246
|
+
matchedExports.add(e);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (baseName === term) {
|
|
251
|
+
bestForTerm = Math.max(bestForTerm, 0.4);
|
|
252
|
+
matchedPath = true;
|
|
253
|
+
matchedPathTerms.push(rawTerm);
|
|
254
|
+
} else if (idLower.includes(term)) {
|
|
255
|
+
bestForTerm = Math.max(bestForTerm, 0.2);
|
|
256
|
+
matchedPath = true;
|
|
257
|
+
matchedPathTerms.push(rawTerm);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
score += bestForTerm;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Нормализуем: максимум 0.5 за term — top score 1.0 при N term'ов.
|
|
264
|
+
const normalized = terms.length > 0 ? Math.min(1, score / (terms.length * 0.5)) : 0;
|
|
265
|
+
return {
|
|
266
|
+
score: normalized,
|
|
267
|
+
matchedPath,
|
|
268
|
+
matchedPathTerms: [...new Set(matchedPathTerms)],
|
|
269
|
+
matchedExports: [...matchedExports],
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function basenameLower(p: string): string {
|
|
274
|
+
const i = p.lastIndexOf('/');
|
|
275
|
+
const base = i === -1 ? p : p.slice(i + 1);
|
|
276
|
+
// strip extension for path-equality match
|
|
277
|
+
const dot = base.lastIndexOf('.');
|
|
278
|
+
return (dot === -1 ? base : base.slice(0, dot)).toLowerCase();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export { parseQuery, isEmpty } from './parseQuery';
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Парсер query-строки для глобального поиска. Поддерживает префиксы
|
|
2
|
+
// `path:`, `export:`, `import:`, `kind:`, разделяемые пробелами; всё, что
|
|
3
|
+
// без префикса — free-text токены, которые ранкер использует одновременно
|
|
4
|
+
// по path и exports.
|
|
5
|
+
//
|
|
6
|
+
// Несколько префиксов в одной строке = AND-пересечение
|
|
7
|
+
// (`kind:component path:auth` → component-модули, у которых path содержит auth).
|
|
8
|
+
// Несколько одинаковых префиксов = OR внутри ключа
|
|
9
|
+
// (`kind:component kind:composable` → component ИЛИ composable).
|
|
10
|
+
//
|
|
11
|
+
// Значения префиксов могут содержать кавычки, чтобы пробелы не дробили токен:
|
|
12
|
+
// `path:"src/feature x"`. Кавычки внутри значения не поддерживаются — это
|
|
13
|
+
// query-строка, а не shell-парсер.
|
|
14
|
+
|
|
15
|
+
export type SearchPrefix = 'path' | 'export' | 'import' | 'kind';
|
|
16
|
+
|
|
17
|
+
export interface ParsedQuery {
|
|
18
|
+
/** Свободные токены без префикса. Пустой массив, если все — префиксированы. */
|
|
19
|
+
free: string[];
|
|
20
|
+
/** Значения, сгруппированные по ключу. Пустой объект → нет префиксов. */
|
|
21
|
+
prefixes: Partial<Record<SearchPrefix, string[]>>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const PREFIXES: ReadonlySet<string> = new Set(['path', 'export', 'import', 'kind']);
|
|
25
|
+
|
|
26
|
+
export function parseQuery(input: string): ParsedQuery {
|
|
27
|
+
const result: ParsedQuery = { free: [], prefixes: {} };
|
|
28
|
+
const tokens = tokenize(input);
|
|
29
|
+
for (const tok of tokens) {
|
|
30
|
+
const colon = tok.indexOf(':');
|
|
31
|
+
if (colon > 0) {
|
|
32
|
+
const key = tok.slice(0, colon).toLowerCase();
|
|
33
|
+
const value = tok.slice(colon + 1);
|
|
34
|
+
if (PREFIXES.has(key) && value.length > 0) {
|
|
35
|
+
const k = key as SearchPrefix;
|
|
36
|
+
const bucket = (result.prefixes[k] ??= []);
|
|
37
|
+
bucket.push(value);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (tok.length > 0) result.free.push(tok);
|
|
42
|
+
}
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Tokenize while respecting double-quoted spans so users can search for
|
|
48
|
+
* paths/exports containing spaces (rare but real, e.g. fixture names).
|
|
49
|
+
*/
|
|
50
|
+
function tokenize(input: string): string[] {
|
|
51
|
+
const out: string[] = [];
|
|
52
|
+
let buf = '';
|
|
53
|
+
let inQuotes = false;
|
|
54
|
+
for (let i = 0; i < input.length; i++) {
|
|
55
|
+
const ch = input[i]!;
|
|
56
|
+
if (ch === '"') {
|
|
57
|
+
inQuotes = !inQuotes;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (!inQuotes && (ch === ' ' || ch === '\t' || ch === '\n')) {
|
|
61
|
+
if (buf.length > 0) {
|
|
62
|
+
out.push(buf);
|
|
63
|
+
buf = '';
|
|
64
|
+
}
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
buf += ch;
|
|
68
|
+
}
|
|
69
|
+
if (buf.length > 0) out.push(buf);
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function isEmpty(q: ParsedQuery): boolean {
|
|
74
|
+
return q.free.length === 0 && Object.keys(q.prefixes).length === 0;
|
|
75
|
+
}
|