@archora/core 1.1.0 → 1.3.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/README.md +5 -3
- package/package.json +1 -1
- package/src/analyzer/__tests__/analyze.test.ts +41 -0
- package/src/analyzer/__tests__/bundle.test.ts +99 -0
- package/src/analyzer/__tests__/incremental.test.ts +61 -13
- package/src/analyzer/__tests__/layerViolationAccuracy.test.ts +77 -0
- package/src/analyzer/__tests__/metrics.test.ts +39 -0
- package/src/analyzer/__tests__/nuxtComposableAutoImport.test.ts +109 -0
- package/src/analyzer/__tests__/reactParser.test.ts +22 -0
- package/src/analyzer/__tests__/resolve.test.ts +27 -0
- package/src/analyzer/__tests__/rsc.test.ts +71 -0
- package/src/analyzer/archDebt.ts +32 -9
- package/src/analyzer/buildGraph.ts +73 -2
- package/src/analyzer/bundle/analyzeBundle.ts +84 -1
- package/src/analyzer/bundle/types.ts +9 -1
- package/src/analyzer/incremental.ts +26 -9
- package/src/analyzer/index.ts +1 -0
- package/src/analyzer/loadAliases.ts +4 -4
- package/src/analyzer/metrics.ts +10 -1
- package/src/analyzer/parsers/svelteParser.ts +5 -0
- package/src/analyzer/parsers/tsParser.ts +11 -1
- package/src/analyzer/recommendations.ts +13 -3
- package/src/analyzer/resolve.ts +22 -14
- package/src/analyzer/rsc.ts +73 -9
- package/src/analyzer/sources/browserFsAccessFileSource.ts +1 -1
- package/src/analyzer/sources/nodeFsFileSource.ts +1 -1
- package/src/analyzer/sources/tauriFileSource.ts +2 -2
- package/src/analyzer/types.ts +5 -0
- package/src/git/__tests__/computeTemporalCoupling.test.ts +24 -0
- package/src/git/computeTemporalCoupling.ts +30 -3
- package/src/git/types.ts +14 -1
- package/src/search/__tests__/parseQuery.test.ts +13 -13
- package/src/search/__tests__/search.test.ts +19 -19
- package/src/search/index.ts +39 -39
- package/src/search/parseQuery.ts +13 -13
package/src/analyzer/rsc.ts
CHANGED
|
@@ -6,14 +6,15 @@
|
|
|
6
6
|
// conventions (Next app/, Nuxt server/, SvelteKit `+server.ts`/
|
|
7
7
|
// `*.server.*`).
|
|
8
8
|
// 2. `detectRscLeaks` - emits a `ContractViolation` (`kind: 'rsc-leak'`)
|
|
9
|
-
// for every
|
|
10
|
-
//
|
|
9
|
+
// for every client->server boundary crossing: a direct edge, and a
|
|
10
|
+
// transitive one (client -> shared chain -> server), the classic
|
|
11
|
+
// barrel/re-export leak that fails the Next build.
|
|
11
12
|
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
13
|
+
// Runtime classification precedence: directives (`'use client'` / `'use server'`)
|
|
14
|
+
// win, then the `server-only` / `client-only` packages (Next's own poison-package
|
|
15
|
+
// enforcement - an explicit, framework-independent runtime declaration), then
|
|
16
|
+
// framework folder conventions. Passing server-only *data* as props into a client
|
|
17
|
+
// component needs data-flow analysis and is out of scope (would be high-FP).
|
|
17
18
|
|
|
18
19
|
import type { ContractViolation } from './contracts';
|
|
19
20
|
import type { DependencyEdge, ModuleId, ModuleNode, ModuleRuntime, ParsedFile } from './types';
|
|
@@ -23,6 +24,10 @@ export interface ClassifyRuntimeInput {
|
|
|
23
24
|
relPath: string;
|
|
24
25
|
framework: Framework;
|
|
25
26
|
directives?: ParsedFile['directives'];
|
|
27
|
+
/** File imports the `server-only` package (Next's poison package — throws if bundled for the client). */
|
|
28
|
+
importsServerOnly?: boolean;
|
|
29
|
+
/** File imports the `client-only` package. */
|
|
30
|
+
importsClientOnly?: boolean;
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
export function classifyModuleRuntime(input: ClassifyRuntimeInput): ModuleRuntime {
|
|
@@ -32,9 +37,14 @@ export function classifyModuleRuntime(input: ClassifyRuntimeInput): ModuleRuntim
|
|
|
32
37
|
if (input.directives.includes('use client')) return 'client';
|
|
33
38
|
}
|
|
34
39
|
|
|
40
|
+
// 2. `server-only` / `client-only` packages — an explicit runtime declaration,
|
|
41
|
+
// framework-independent (this is Next RSC's own enforcement mechanism, not a convention).
|
|
42
|
+
if (input.importsServerOnly) return 'server';
|
|
43
|
+
if (input.importsClientOnly) return 'client';
|
|
44
|
+
|
|
35
45
|
const path = input.relPath.replace(/\\/gu, '/');
|
|
36
46
|
|
|
37
|
-
//
|
|
47
|
+
// 3. SvelteKit conventions (work for any framework value because users
|
|
38
48
|
// pin them by file name, not framework detection):
|
|
39
49
|
// - `+server.ts` / `+server.js` -> endpoint, server only
|
|
40
50
|
// - `*.server.ts` (and `+page.server.ts`, `+layout.server.ts`) -> server
|
|
@@ -45,7 +55,7 @@ export function classifyModuleRuntime(input: ClassifyRuntimeInput): ModuleRuntim
|
|
|
45
55
|
if (/\.client\.[mc]?[jt]sx?$/u.test(path)) return 'client';
|
|
46
56
|
if (/(^|\/)\+(?:page|layout)\.svelte$/u.test(path)) return 'client';
|
|
47
57
|
|
|
48
|
-
//
|
|
58
|
+
// 4. Framework-specific folder conventions.
|
|
49
59
|
if (input.framework === 'next') {
|
|
50
60
|
// Next App Router: `app/` files are server components by default.
|
|
51
61
|
// The 'use client' directive (handled above) opts a tree into client.
|
|
@@ -80,6 +90,7 @@ export function detectRscLeaks(input: DetectRscLeaksInput): ContractViolation[]
|
|
|
80
90
|
|
|
81
91
|
const out: ContractViolation[] = [];
|
|
82
92
|
let serial = 0;
|
|
93
|
+
const directLeaks = new Set<string>();
|
|
83
94
|
|
|
84
95
|
for (const e of input.edges) {
|
|
85
96
|
if (e.kind === 'type-only') continue;
|
|
@@ -88,6 +99,7 @@ export function detectRscLeaks(input: DetectRscLeaksInput): ContractViolation[]
|
|
|
88
99
|
if (!from || !to) continue;
|
|
89
100
|
const leak = classifyLeak(from.runtime ?? 'shared', to.runtime ?? 'shared');
|
|
90
101
|
if (!leak) continue;
|
|
102
|
+
directLeaks.add(`${from.id}->${to.id}`);
|
|
91
103
|
out.push({
|
|
92
104
|
id: `rsc-leak:${serial++}:${e.from}\u0001${e.to}`,
|
|
93
105
|
kind: 'rsc-leak',
|
|
@@ -98,6 +110,58 @@ export function detectRscLeaks(input: DetectRscLeaksInput): ContractViolation[]
|
|
|
98
110
|
edge: { from: from.id, to: to.id, specifier: e.specifier },
|
|
99
111
|
});
|
|
100
112
|
}
|
|
113
|
+
|
|
114
|
+
// Transitive leaks: a client module pulls server-only code through a chain of
|
|
115
|
+
// shared modules (the classic barrel/re-export leak — in Next this is a build
|
|
116
|
+
// error, since the shared chain plus the server code lands in the client bundle).
|
|
117
|
+
// Walk from each client module through shared intermediates only; sink = server.
|
|
118
|
+
// Direct client→server edges are already handled above.
|
|
119
|
+
const adj = new Map<ModuleId, ModuleId[]>();
|
|
120
|
+
for (const e of input.edges) {
|
|
121
|
+
if (e.kind === 'type-only') continue;
|
|
122
|
+
let bucket = adj.get(e.from);
|
|
123
|
+
if (!bucket) {
|
|
124
|
+
bucket = [];
|
|
125
|
+
adj.set(e.from, bucket);
|
|
126
|
+
}
|
|
127
|
+
bucket.push(e.to);
|
|
128
|
+
}
|
|
129
|
+
const rt = (id: ModuleId): ModuleRuntime => moduleById.get(id)?.runtime ?? 'shared';
|
|
130
|
+
const seenTransitive = new Set<string>();
|
|
131
|
+
for (const c of input.modules) {
|
|
132
|
+
if ((c.runtime ?? 'shared') !== 'client') continue;
|
|
133
|
+
const visitedShared = new Set<ModuleId>();
|
|
134
|
+
const queue: ModuleId[] = [];
|
|
135
|
+
for (const to of adj.get(c.id) ?? []) {
|
|
136
|
+
if (rt(to) === 'shared' && !visitedShared.has(to)) {
|
|
137
|
+
visitedShared.add(to);
|
|
138
|
+
queue.push(to);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
while (queue.length > 0) {
|
|
142
|
+
const node = queue.shift()!;
|
|
143
|
+
for (const to of adj.get(node) ?? []) {
|
|
144
|
+
const toRt = rt(to);
|
|
145
|
+
if (toRt === 'server') {
|
|
146
|
+
const key = `${c.id}->${to}`;
|
|
147
|
+
if (directLeaks.has(key) || seenTransitive.has(key)) continue;
|
|
148
|
+
seenTransitive.add(key);
|
|
149
|
+
out.push({
|
|
150
|
+
id: `rsc-leak:${serial++}:${key}`,
|
|
151
|
+
kind: 'rsc-leak',
|
|
152
|
+
ruleName: 'rsc-leak',
|
|
153
|
+
severity: 'error',
|
|
154
|
+
message: `client module "${c.id}" transitively imports server-only "${to}" through shared module(s)`,
|
|
155
|
+
modules: [c.id, to],
|
|
156
|
+
edge: { from: c.id, to, specifier: to },
|
|
157
|
+
});
|
|
158
|
+
} else if (toRt === 'shared' && !visitedShared.has(to)) {
|
|
159
|
+
visitedShared.add(to);
|
|
160
|
+
queue.push(to);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
101
165
|
return out;
|
|
102
166
|
}
|
|
103
167
|
|
|
@@ -8,7 +8,7 @@ export interface BrowserFsAccessFileSourceOptions {
|
|
|
8
8
|
rootHandle: FileSystemDirectoryHandle;
|
|
9
9
|
rootName?: string;
|
|
10
10
|
onProgress?: (visited: number) => void;
|
|
11
|
-
/**
|
|
11
|
+
/** If true (default) - skip test/storybook/cypress/playwright directories. */
|
|
12
12
|
skipTestLikeDirs?: boolean;
|
|
13
13
|
/** Read `.gitignore` from root and filter the listing. Default: true. */
|
|
14
14
|
respectGitignore?: boolean;
|
|
@@ -10,7 +10,7 @@ export interface NodeFsFileSourceOptions {
|
|
|
10
10
|
rootPath: string;
|
|
11
11
|
respectGitignore?: boolean;
|
|
12
12
|
extraIgnore?: string[];
|
|
13
|
-
/**
|
|
13
|
+
/** If true (default) - skip test/storybook/cypress/playwright directories. */
|
|
14
14
|
skipTestLikeDirs?: boolean;
|
|
15
15
|
}
|
|
16
16
|
|
|
@@ -8,9 +8,9 @@ export interface TauriFileSourceOptions {
|
|
|
8
8
|
rootPath: string;
|
|
9
9
|
rootName?: string;
|
|
10
10
|
invoke: TauriInvoke;
|
|
11
|
-
/**
|
|
11
|
+
/** Extra ignore globs (gitignore syntax), e.g. ['*.test.ts']. */
|
|
12
12
|
extraIgnoreGlobs?: string[];
|
|
13
|
-
/**
|
|
13
|
+
/** Hard limit on the size of a file to read, in bytes. Default 2 MB. */
|
|
14
14
|
maxFileBytes?: number;
|
|
15
15
|
}
|
|
16
16
|
|
package/src/analyzer/types.ts
CHANGED
|
@@ -401,6 +401,11 @@ export interface ParsedFile {
|
|
|
401
401
|
directives?: ('use server' | 'use client')[];
|
|
402
402
|
/** PascalCase component tags from <template>, resolved against the component registry in buildGraph. */
|
|
403
403
|
templateRefs?: string[];
|
|
404
|
+
/**
|
|
405
|
+
* Identifiers called as a function (`foo(...)`, not `obj.foo(...)`).
|
|
406
|
+
* Used to resolve Nuxt auto-import composables in buildGraph.
|
|
407
|
+
*/
|
|
408
|
+
callIdentifiers?: string[];
|
|
404
409
|
}
|
|
405
410
|
|
|
406
411
|
export type SignalSeverity = 'info' | 'low' | 'medium' | 'high' | 'critical';
|
|
@@ -122,4 +122,28 @@ describe('computeTemporalCoupling', () => {
|
|
|
122
122
|
expect(out[0]?.a).toBe('src/c.ts');
|
|
123
123
|
expect(out[1]?.hidden).toBe(false);
|
|
124
124
|
});
|
|
125
|
+
|
|
126
|
+
it('ranks a cross-boundary hidden coupling above an equally-strong same-folder one', () => {
|
|
127
|
+
const modules = ['features/auth/a.ts', 'entities/user/b.ts', 'shared/x.ts', 'shared/y.ts'].map(
|
|
128
|
+
mod,
|
|
129
|
+
);
|
|
130
|
+
const h = history([
|
|
131
|
+
commit('1', ['features/auth/a.ts', 'entities/user/b.ts']),
|
|
132
|
+
commit('2', ['features/auth/a.ts', 'entities/user/b.ts']),
|
|
133
|
+
commit('3', ['features/auth/a.ts', 'entities/user/b.ts']),
|
|
134
|
+
commit('4', ['shared/x.ts', 'shared/y.ts']),
|
|
135
|
+
commit('5', ['shared/x.ts', 'shared/y.ts']),
|
|
136
|
+
commit('6', ['shared/x.ts', 'shared/y.ts']),
|
|
137
|
+
]);
|
|
138
|
+
const out = computeTemporalCoupling({ modules, edges: NO_EDGES, history: h });
|
|
139
|
+
expect(out).toHaveLength(2);
|
|
140
|
+
// Both are hidden, score 1.0, 3 co-occurrences — only the boundary differs.
|
|
141
|
+
const cross = out.find((c) => c.a === 'entities/user/b.ts' || c.b === 'entities/user/b.ts');
|
|
142
|
+
const same = out.find((c) => c.a === 'shared/x.ts' && c.b === 'shared/y.ts');
|
|
143
|
+
expect(cross?.crossBoundary).toBe(true);
|
|
144
|
+
expect(same?.crossBoundary).toBe(false);
|
|
145
|
+
expect(cross!.risk).toBeGreaterThan(same!.risk);
|
|
146
|
+
expect(out[0]).toBe(cross); // cross-boundary ranks first
|
|
147
|
+
expect(cross!.risk).toBeLessThanOrEqual(1);
|
|
148
|
+
});
|
|
125
149
|
});
|
|
@@ -72,13 +72,15 @@ export function computeTemporalCoupling(input: ComputeTemporalCouplingInput): Te
|
|
|
72
72
|
const score = Math.min(scoreA, scoreB);
|
|
73
73
|
if (score < t.minScore) continue;
|
|
74
74
|
const hidden = !staticPairs.has(key);
|
|
75
|
-
|
|
75
|
+
const crossBoundary = topBoundary(a) !== topBoundary(b);
|
|
76
|
+
const risk = couplingRisk(score, n, hidden, crossBoundary);
|
|
77
|
+
out.push({ a, b, coOccurrences: n, scoreA, scoreB, score, hidden, crossBoundary, risk });
|
|
76
78
|
}
|
|
77
79
|
|
|
78
|
-
//
|
|
80
|
+
// By risk (encodes hidden + cross-boundary + evidence), then score, then
|
|
79
81
|
// co-occurrences, then alphabetically — stable across runs.
|
|
80
82
|
out.sort((x, y) => {
|
|
81
|
-
if (
|
|
83
|
+
if (y.risk !== x.risk) return y.risk - x.risk;
|
|
82
84
|
if (y.score !== x.score) return y.score - x.score;
|
|
83
85
|
if (y.coOccurrences !== x.coOccurrences) return y.coOccurrences - x.coOccurrences;
|
|
84
86
|
return x.a.localeCompare(y.a) || x.b.localeCompare(y.b);
|
|
@@ -102,6 +104,31 @@ function collectTouchedModules(
|
|
|
102
104
|
return [...touched].sort();
|
|
103
105
|
}
|
|
104
106
|
|
|
107
|
+
// Top-level module group: first path segment after an optional `src/`. Files
|
|
108
|
+
// directly under the root share the '' group, so two of them are NOT cross-
|
|
109
|
+
// boundary. For FSD this is the layer (features/entities/shared/...).
|
|
110
|
+
function topBoundary(id: string): string {
|
|
111
|
+
const p = id.startsWith('src/') ? id.slice(4) : id;
|
|
112
|
+
const i = p.indexOf('/');
|
|
113
|
+
return i === -1 ? '' : p.slice(0, i);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Actionability score. Strength (`score`) tempered by evidence volume, boosted
|
|
117
|
+
// when the coupling is hidden (the static graph doesn't already reveal it) and
|
|
118
|
+
// when it crosses a module boundary (missing-abstraction smell). Capped at 1.
|
|
119
|
+
function couplingRisk(
|
|
120
|
+
score: number,
|
|
121
|
+
coOccurrences: number,
|
|
122
|
+
hidden: boolean,
|
|
123
|
+
crossBoundary: boolean,
|
|
124
|
+
): number {
|
|
125
|
+
const evidence = Math.min(1, coOccurrences / 10);
|
|
126
|
+
let risk = score * (0.5 + 0.5 * evidence);
|
|
127
|
+
risk *= hidden ? 1.25 : 0.6;
|
|
128
|
+
if (hidden && crossBoundary) risk *= 1.15;
|
|
129
|
+
return Math.min(1, Math.round(risk * 1000) / 1000);
|
|
130
|
+
}
|
|
131
|
+
|
|
105
132
|
function collectStaticPairs(edges: DependencyEdge[]): Set<string> {
|
|
106
133
|
const out = new Set<string>();
|
|
107
134
|
for (const e of edges) {
|
package/src/git/types.ts
CHANGED
|
@@ -92,7 +92,7 @@ export interface TemporalCoupling {
|
|
|
92
92
|
scoreA: number;
|
|
93
93
|
/** `coOccurrences / commits(b)`. */
|
|
94
94
|
scoreB: number;
|
|
95
|
-
/** `min(scoreA, scoreB)` — the symmetric, conservative score.
|
|
95
|
+
/** `min(scoreA, scoreB)` — the symmetric, conservative score. */
|
|
96
96
|
score: number;
|
|
97
97
|
/**
|
|
98
98
|
* True when there is no static import edge between `a` and `b` in either
|
|
@@ -100,6 +100,19 @@ export interface TemporalCoupling {
|
|
|
100
100
|
* informative ones, because the static graph already exposes the rest.
|
|
101
101
|
*/
|
|
102
102
|
hidden: boolean;
|
|
103
|
+
/**
|
|
104
|
+
* True when `a` and `b` live in different top-level module groups (FSD layer
|
|
105
|
+
* / first path segment). A hidden + cross-boundary coupling is the strongest
|
|
106
|
+
* "missing abstraction / leaky boundary" signal — and one a pure-git tool
|
|
107
|
+
* (no architecture model) cannot produce.
|
|
108
|
+
*/
|
|
109
|
+
crossBoundary: boolean;
|
|
110
|
+
/**
|
|
111
|
+
* Actionability score (0..1): coupling strength tempered by evidence
|
|
112
|
+
* (`coOccurrences`), boosted when `hidden` and when `crossBoundary`. Primary
|
|
113
|
+
* sort key.
|
|
114
|
+
*/
|
|
115
|
+
risk: number;
|
|
103
116
|
}
|
|
104
117
|
|
|
105
118
|
export interface TemporalCouplingThresholds {
|
|
@@ -2,65 +2,65 @@ import { describe, expect, it } from 'vitest';
|
|
|
2
2
|
import { parseQuery, isEmpty } from '../parseQuery';
|
|
3
3
|
|
|
4
4
|
describe('parseQuery', () => {
|
|
5
|
-
it('
|
|
5
|
+
it('empty string → empty query', () => {
|
|
6
6
|
const q = parseQuery('');
|
|
7
7
|
expect(q.free).toEqual([]);
|
|
8
8
|
expect(q.prefixes).toEqual({});
|
|
9
9
|
expect(isEmpty(q)).toBe(true);
|
|
10
10
|
});
|
|
11
11
|
|
|
12
|
-
it('
|
|
12
|
+
it('single free token without prefixes', () => {
|
|
13
13
|
expect(parseQuery('useAuth')).toEqual({ free: ['useAuth'], prefixes: {} });
|
|
14
14
|
});
|
|
15
15
|
|
|
16
|
-
it('
|
|
16
|
+
it('single path: prefix', () => {
|
|
17
17
|
expect(parseQuery('path:src/features')).toEqual({
|
|
18
18
|
free: [],
|
|
19
19
|
prefixes: { path: ['src/features'] },
|
|
20
20
|
});
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
it('
|
|
23
|
+
it('several prefixes of different keys = AND intersection', () => {
|
|
24
24
|
const q = parseQuery('kind:component path:auth');
|
|
25
25
|
expect(q).toEqual({ free: [], prefixes: { kind: ['component'], path: ['auth'] } });
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
-
it('
|
|
28
|
+
it('several identical prefixes = OR within a key', () => {
|
|
29
29
|
const q = parseQuery('kind:component kind:composable');
|
|
30
30
|
expect(q.prefixes.kind).toEqual(['component', 'composable']);
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
-
it('
|
|
33
|
+
it('prefix + free-text mixed', () => {
|
|
34
34
|
expect(parseQuery('useAuth kind:composable')).toEqual({
|
|
35
35
|
free: ['useAuth'],
|
|
36
36
|
prefixes: { kind: ['composable'] },
|
|
37
37
|
});
|
|
38
38
|
});
|
|
39
39
|
|
|
40
|
-
it('
|
|
40
|
+
it('quoted prefix value allows spaces', () => {
|
|
41
41
|
const q = parseQuery('path:"src/feature x"');
|
|
42
42
|
expect(q.prefixes.path).toEqual(['src/feature x']);
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
-
it('
|
|
46
|
-
// `foo:bar`
|
|
45
|
+
it('unknown prefix — falls back to free-text in full', () => {
|
|
46
|
+
// `foo:bar` is not a known prefix → the whole token goes to free
|
|
47
47
|
expect(parseQuery('foo:bar')).toEqual({ free: ['foo:bar'], prefixes: {} });
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
-
it('
|
|
51
|
-
// `kind:`
|
|
50
|
+
it('empty prefix value — ignored', () => {
|
|
51
|
+
// `kind:` with no value — does not add a key
|
|
52
52
|
const q = parseQuery('kind: hello');
|
|
53
53
|
expect(q.prefixes.kind).toBeUndefined();
|
|
54
54
|
expect(q.free).toContain('hello');
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
-
it('case-insensitive
|
|
57
|
+
it('case-insensitive keys, case-sensitive values', () => {
|
|
58
58
|
const q = parseQuery('KIND:Component PATH:Auth');
|
|
59
59
|
expect(q.prefixes.kind).toEqual(['Component']);
|
|
60
60
|
expect(q.prefixes.path).toEqual(['Auth']);
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
-
it('export / import
|
|
63
|
+
it('export / import prefixes work', () => {
|
|
64
64
|
const q = parseQuery('export:useAuth import:react-query');
|
|
65
65
|
expect(q.prefixes).toEqual({ export: ['useAuth'], import: ['react-query'] });
|
|
66
66
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// search()
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// search() runs on a fully synthetic ScanResult — what matters is the ranker
|
|
2
|
+
// and prefix-filter logic, not the parser. The minimally valid fixture
|
|
3
|
+
// builder below honors the shape of the types but does not claim semantic
|
|
4
|
+
// completeness (no cycle/metric cross-references).
|
|
5
5
|
|
|
6
6
|
import { describe, expect, it } from 'vitest';
|
|
7
7
|
import type {
|
|
@@ -96,30 +96,30 @@ const FIXTURE = buildScan([
|
|
|
96
96
|
]);
|
|
97
97
|
|
|
98
98
|
describe('search', () => {
|
|
99
|
-
it('
|
|
99
|
+
it('empty query → empty output', () => {
|
|
100
100
|
expect(search(FIXTURE, '')).toEqual([]);
|
|
101
101
|
expect(search(FIXTURE, ' ')).toEqual([]);
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
-
it('export:useAuth
|
|
104
|
+
it('export:useAuth finds the composable', () => {
|
|
105
105
|
const r = search(FIXTURE, 'export:useAuth');
|
|
106
106
|
expect(r.map((x) => x.id)).toEqual(['src/features/auth/composables/useAuth.ts']);
|
|
107
107
|
expect(r[0]!.matched).toContain('export');
|
|
108
108
|
expect(r[0]!.highlights.export).toContain('useAuth');
|
|
109
109
|
});
|
|
110
110
|
|
|
111
|
-
it('import:react-query
|
|
111
|
+
it('import:react-query finds modules with that specifier', () => {
|
|
112
112
|
const r = search(FIXTURE, 'import:react-query');
|
|
113
113
|
expect(r.map((x) => x.id)).toEqual(['src/features/checkout/CheckoutForm.vue']);
|
|
114
114
|
expect(r[0]!.matched).toContain('import');
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
-
it('kind:store
|
|
117
|
+
it('kind:store filters by kind', () => {
|
|
118
118
|
const r = search(FIXTURE, 'kind:store');
|
|
119
119
|
expect(r.map((x) => x.id)).toEqual(['src/stores/userStore.ts']);
|
|
120
120
|
});
|
|
121
121
|
|
|
122
|
-
it('path:features
|
|
122
|
+
it('path:features matches a substring in path (case-insensitive)', () => {
|
|
123
123
|
const r = search(FIXTURE, 'path:Features');
|
|
124
124
|
const ids = r.map((x) => x.id);
|
|
125
125
|
expect(ids).toContain('src/features/auth/composables/useAuth.ts');
|
|
@@ -127,12 +127,12 @@ describe('search', () => {
|
|
|
127
127
|
expect(ids).not.toContain('src/shared/lib/api.ts');
|
|
128
128
|
});
|
|
129
129
|
|
|
130
|
-
it('AND
|
|
130
|
+
it('AND intersection: kind:component path:auth', () => {
|
|
131
131
|
const r = search(FIXTURE, 'kind:component path:auth');
|
|
132
132
|
expect(r.map((x) => x.id)).toEqual(['src/features/auth/AuthForm.vue']);
|
|
133
133
|
});
|
|
134
134
|
|
|
135
|
-
it('OR
|
|
135
|
+
it('OR within a key: kind:component kind:composable', () => {
|
|
136
136
|
const r = search(FIXTURE, 'kind:component kind:composable');
|
|
137
137
|
const ids = new Set(r.map((x) => x.id));
|
|
138
138
|
expect(ids.has('src/features/auth/AuthForm.vue')).toBe(true);
|
|
@@ -140,32 +140,32 @@ describe('search', () => {
|
|
|
140
140
|
expect(ids.has('src/stores/userStore.ts')).toBe(false);
|
|
141
141
|
});
|
|
142
142
|
|
|
143
|
-
it('free-text
|
|
143
|
+
it('free-text without a prefix: ranker finds matches by path and exports', () => {
|
|
144
144
|
const r = search(FIXTURE, 'useAuth');
|
|
145
|
-
// useAuth.ts
|
|
145
|
+
// useAuth.ts must be at the top (exact exports match + basename)
|
|
146
146
|
expect(r[0]!.id).toBe('src/features/auth/composables/useAuth.ts');
|
|
147
147
|
expect(r[0]!.score).toBeGreaterThan(0.5);
|
|
148
148
|
});
|
|
149
149
|
|
|
150
|
-
it('
|
|
150
|
+
it('exact basename match ranks above a substring match', () => {
|
|
151
151
|
const r = search(FIXTURE, 'useAuth');
|
|
152
|
-
// useAuth.ts — basename match; AuthForm.vue —
|
|
152
|
+
// useAuth.ts — basename match; AuthForm.vue — path substring only
|
|
153
153
|
expect(r[0]!.id).toBe('src/features/auth/composables/useAuth.ts');
|
|
154
154
|
const formIdx = r.findIndex((x) => x.id === 'src/features/auth/AuthForm.vue');
|
|
155
155
|
if (formIdx > -1) expect(formIdx).toBeGreaterThan(0);
|
|
156
156
|
});
|
|
157
157
|
|
|
158
|
-
it('limit
|
|
158
|
+
it('limit caps the output', () => {
|
|
159
159
|
const r = search(FIXTURE, 'path:src', { limit: 2 });
|
|
160
160
|
expect(r).toHaveLength(2);
|
|
161
161
|
});
|
|
162
162
|
|
|
163
|
-
it('
|
|
163
|
+
it('unknown kind does not throw, just finds nothing', () => {
|
|
164
164
|
expect(search(FIXTURE, 'kind:nonsense')).toEqual([]);
|
|
165
165
|
});
|
|
166
166
|
|
|
167
|
-
it('SearchResult.matched
|
|
168
|
-
// useAuth:
|
|
167
|
+
it('SearchResult.matched may contain several criteria at once', () => {
|
|
168
|
+
// useAuth: matches by export (via export:) and by path (via free-text)
|
|
169
169
|
const r = search(FIXTURE, 'export:useAuth auth');
|
|
170
170
|
expect(r[0]!.matched).toEqual(expect.arrayContaining(['export', 'path']));
|
|
171
171
|
});
|
package/src/search/index.ts
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// Global search over `ScanResult` — no filesystem reads, only structured
|
|
2
|
+
// metadata (modules + edges). Strategy:
|
|
3
3
|
//
|
|
4
|
-
// 1.
|
|
5
|
-
// 2.
|
|
6
|
-
//
|
|
7
|
-
// 3.
|
|
8
|
-
//
|
|
9
|
-
// 4.
|
|
10
|
-
//
|
|
11
|
-
//
|
|
4
|
+
// 1. Parse the query (`parseQuery`).
|
|
5
|
+
// 2. For each prefix collect sets of candidate IDs; only modules that pass
|
|
6
|
+
// the intersection of all prefixes (AND) make it into the final set.
|
|
7
|
+
// 3. On top of the filtered modules apply free-text ranking (substring match
|
|
8
|
+
// over path and exports) and sort by the resulting score.
|
|
9
|
+
// 4. Return results tagged with the match kind — the UI renders pill chips
|
|
10
|
+
// "Path / Export / Import / Kind", so each SearchResult must know why it
|
|
11
|
+
// ended up in the output.
|
|
12
12
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
13
|
+
// We do not: ripgrep over file contents, fuzzy substring (fzf-style scoring) —
|
|
14
|
+
// for path → modules.length is usually in the hundreds, plain substring is
|
|
15
|
+
// enough. We'll add complexity if a "too noisy" signal shows up.
|
|
16
16
|
|
|
17
17
|
import type { ScanResult, ModuleId, ModuleKind } from '../analyzer/types';
|
|
18
18
|
import { parseQuery, isEmpty, type ParsedQuery, type SearchPrefix } from './parseQuery';
|
|
@@ -24,20 +24,20 @@ export type MatchKind = 'path' | 'export' | 'import' | 'kind';
|
|
|
24
24
|
export interface SearchResult {
|
|
25
25
|
/** Module id (relative path). */
|
|
26
26
|
id: ModuleId;
|
|
27
|
-
/**
|
|
27
|
+
/** Which criteria matched — the UI uses this to highlight chips. */
|
|
28
28
|
matched: MatchKind[];
|
|
29
|
-
/** Score 0..1; 1 =
|
|
29
|
+
/** Score 0..1; 1 = perfect match, 0 = only the prefix filter passed. */
|
|
30
30
|
score: number;
|
|
31
31
|
/**
|
|
32
|
-
*
|
|
33
|
-
* (
|
|
34
|
-
*
|
|
32
|
+
* Highlight hint for each matched criterion — the concrete values
|
|
33
|
+
* (export name, import specifier, kind). Used by the results panel to
|
|
34
|
+
* explain "why this module surfaced for this query".
|
|
35
35
|
*/
|
|
36
36
|
highlights: Partial<Record<MatchKind, string[]>>;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
export interface SearchOptions {
|
|
40
|
-
/**
|
|
40
|
+
/** Cap the output size. Defaults to 50 — a typical UI overlay. */
|
|
41
41
|
limit?: number;
|
|
42
42
|
}
|
|
43
43
|
|
|
@@ -54,17 +54,17 @@ export function search(
|
|
|
54
54
|
const limit = options.limit ?? DEFAULT_LIMIT;
|
|
55
55
|
const moduleIds = new Set(scan.modules.map((m) => m.id));
|
|
56
56
|
|
|
57
|
-
// 1.
|
|
58
|
-
//
|
|
57
|
+
// 1. Prefix filters yield `Map<id, MatchKind[]> + highlights` — if a prefix
|
|
58
|
+
// is present, a module can only reach the output by passing it.
|
|
59
59
|
const prefixHits = collectPrefixHits(scan, parsed);
|
|
60
60
|
|
|
61
|
-
//
|
|
61
|
+
// If there are prefixes — keep only modules that hit all of the given ones.
|
|
62
62
|
const eligibleIds = applyPrefixIntersection(moduleIds, prefixHits, parsed.prefixes);
|
|
63
63
|
|
|
64
|
-
// 2. Free-text
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
64
|
+
// 2. Free-text ranking. If free is empty and prefixes exist — return eligible
|
|
65
|
+
// as is with score 0.5 (neutral). If free is present — compute the score and
|
|
66
|
+
// drop modules where no free token matched WHEN there are no prefixes;
|
|
67
|
+
// otherwise free lowers the score but does not cut a module out.
|
|
68
68
|
const results: SearchResult[] = [];
|
|
69
69
|
for (const id of eligibleIds) {
|
|
70
70
|
const module = scan.modules.find((m) => m.id === id);
|
|
@@ -72,7 +72,7 @@ export function search(
|
|
|
72
72
|
const matched: MatchKind[] = [];
|
|
73
73
|
const highlights: Partial<Record<MatchKind, string[]>> = {};
|
|
74
74
|
|
|
75
|
-
//
|
|
75
|
+
// collect confirmations from prefix hits
|
|
76
76
|
for (const kind of ['path', 'export', 'import', 'kind'] as const) {
|
|
77
77
|
const hit = prefixHits[kind].get(id);
|
|
78
78
|
if (hit) {
|
|
@@ -95,7 +95,7 @@ export function search(
|
|
|
95
95
|
(highlights.export ??= []).push(...f.matchedExports);
|
|
96
96
|
}
|
|
97
97
|
} else if (matched.length === 0) {
|
|
98
|
-
//
|
|
98
|
+
// Neither a prefix match nor a free match: the module is out entirely.
|
|
99
99
|
continue;
|
|
100
100
|
}
|
|
101
101
|
}
|
|
@@ -104,8 +104,8 @@ export function search(
|
|
|
104
104
|
results.push({ id, matched, score, highlights });
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
// 3.
|
|
108
|
-
// (
|
|
107
|
+
// 3. Sort: higher score → first; tiebreak by shorter path → first
|
|
108
|
+
// (short paths usually sit closer to a feature root than deep implementations).
|
|
109
109
|
results.sort((a, b) => {
|
|
110
110
|
if (b.score !== a.score) return b.score - a.score;
|
|
111
111
|
return a.id.length - b.id.length;
|
|
@@ -157,7 +157,7 @@ function collectPrefixHits(scan: ScanResult, q: ParsedQuery): PrefixHits {
|
|
|
157
157
|
}
|
|
158
158
|
|
|
159
159
|
if (q.prefixes.import) {
|
|
160
|
-
// edges.specifier —
|
|
160
|
+
// edges.specifier — the original import string; non-resolved are counted too
|
|
161
161
|
const needles = q.prefixes.import;
|
|
162
162
|
const matchedSpecifiersByModule = new Map<ModuleId, Set<string>>();
|
|
163
163
|
for (const e of scan.edges) {
|
|
@@ -213,16 +213,16 @@ interface FreeTextMatch {
|
|
|
213
213
|
}
|
|
214
214
|
|
|
215
215
|
/**
|
|
216
|
-
*
|
|
217
|
-
*
|
|
216
|
+
* Plain substring ranker. Not fuzzy: for the typical "find useAuth" task a
|
|
217
|
+
* substring gives stable results without noisy matches.
|
|
218
218
|
*
|
|
219
|
-
*
|
|
220
|
-
* -
|
|
219
|
+
* Scoring:
|
|
220
|
+
* - Exact match exports[i] === term: +0.5
|
|
221
221
|
* - exports[i] contains term: +0.25
|
|
222
222
|
* - basename(path) === term: +0.4
|
|
223
|
-
* - path contains term: +0.2 (
|
|
224
|
-
*
|
|
225
|
-
*
|
|
223
|
+
* - path contains term: +0.2 (but counted once in the score — so that three
|
|
224
|
+
* matches don't push the score > 1)
|
|
225
|
+
* Then normalize to 0..1 by the number of terms.
|
|
226
226
|
*/
|
|
227
227
|
function scoreFreeText(id: ModuleId, exports: readonly string[], terms: string[]): FreeTextMatch {
|
|
228
228
|
let score = 0;
|
|
@@ -260,7 +260,7 @@ function scoreFreeText(id: ModuleId, exports: readonly string[], terms: string[]
|
|
|
260
260
|
score += bestForTerm;
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
-
//
|
|
263
|
+
// Normalize: max 0.5 per term — top score 1.0 with N terms.
|
|
264
264
|
const normalized = terms.length > 0 ? Math.min(1, score / (terms.length * 0.5)) : 0;
|
|
265
265
|
return {
|
|
266
266
|
score: normalized,
|