@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.
Files changed (35) hide show
  1. package/README.md +5 -3
  2. package/package.json +1 -1
  3. package/src/analyzer/__tests__/analyze.test.ts +41 -0
  4. package/src/analyzer/__tests__/bundle.test.ts +99 -0
  5. package/src/analyzer/__tests__/incremental.test.ts +61 -13
  6. package/src/analyzer/__tests__/layerViolationAccuracy.test.ts +77 -0
  7. package/src/analyzer/__tests__/metrics.test.ts +39 -0
  8. package/src/analyzer/__tests__/nuxtComposableAutoImport.test.ts +109 -0
  9. package/src/analyzer/__tests__/reactParser.test.ts +22 -0
  10. package/src/analyzer/__tests__/resolve.test.ts +27 -0
  11. package/src/analyzer/__tests__/rsc.test.ts +71 -0
  12. package/src/analyzer/archDebt.ts +32 -9
  13. package/src/analyzer/buildGraph.ts +73 -2
  14. package/src/analyzer/bundle/analyzeBundle.ts +84 -1
  15. package/src/analyzer/bundle/types.ts +9 -1
  16. package/src/analyzer/incremental.ts +26 -9
  17. package/src/analyzer/index.ts +1 -0
  18. package/src/analyzer/loadAliases.ts +4 -4
  19. package/src/analyzer/metrics.ts +10 -1
  20. package/src/analyzer/parsers/svelteParser.ts +5 -0
  21. package/src/analyzer/parsers/tsParser.ts +11 -1
  22. package/src/analyzer/recommendations.ts +13 -3
  23. package/src/analyzer/resolve.ts +22 -14
  24. package/src/analyzer/rsc.ts +73 -9
  25. package/src/analyzer/sources/browserFsAccessFileSource.ts +1 -1
  26. package/src/analyzer/sources/nodeFsFileSource.ts +1 -1
  27. package/src/analyzer/sources/tauriFileSource.ts +2 -2
  28. package/src/analyzer/types.ts +5 -0
  29. package/src/git/__tests__/computeTemporalCoupling.test.ts +24 -0
  30. package/src/git/computeTemporalCoupling.ts +30 -3
  31. package/src/git/types.ts +14 -1
  32. package/src/search/__tests__/parseQuery.test.ts +13 -13
  33. package/src/search/__tests__/search.test.ts +19 -19
  34. package/src/search/index.ts +39 -39
  35. package/src/search/parseQuery.ts +13 -13
@@ -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 static edge crossing a hard boundary (server importing
10
- // client, client importing server-only).
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
- // Directives win over conventions. A `'use client'` file in `app/` is
13
- // client. `server-only` / `client-only` packages are not modeled separately
14
- // here - the directive system covers the same cases for first-party code,
15
- // and we don't have a way to type-check third-party code without doing real
16
- // resolution.
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
- // 2. SvelteKit conventions (work for any framework value because users
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
- // 3. Framework-specific folder conventions.
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
- /** Если true (по умолчанию) - не заходим в тест/storybook/cypress/playwright директории. */
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
- /** Если true (по умолчанию) - не заходим в тест/storybook/cypress/playwright директории. */
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
- /** Дополнительные ignore-globs (gitignore-синтаксис), напр. ['*.test.ts']. */
11
+ /** Extra ignore globs (gitignore syntax), e.g. ['*.test.ts']. */
12
12
  extraIgnoreGlobs?: string[];
13
- /** Жёсткий лимит размера читаемого файла, байт. По умолчанию 2 МБ. */
13
+ /** Hard limit on the size of a file to read, in bytes. Default 2 MB. */
14
14
  maxFileBytes?: number;
15
15
  }
16
16
 
@@ -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
- out.push({ a, b, coOccurrences: n, scoreA, scoreB, score, hidden });
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
- // Hidden couplings first (most informative), then by score, then by
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 (x.hidden !== y.hidden) return x.hidden ? -1 : 1;
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. Sorting key. */
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('пустая строкапустой query', () => {
5
+ it('empty stringempty 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('один free-token без префиксов', () => {
12
+ it('single free token without prefixes', () => {
13
13
  expect(parseQuery('useAuth')).toEqual({ free: ['useAuth'], prefixes: {} });
14
14
  });
15
15
 
16
- it('один префикс path:', () => {
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('несколько префиксов разных ключей = AND-пересечение', () => {
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('несколько одинаковых префиксов = OR внутри ключа', () => {
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('префикс + free-text смешаны', () => {
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('неизвестный префиксфолбэк в free-text целиком', () => {
46
- // `foo:bar` не известный prefix → токен целиком уходит в free
45
+ it('unknown prefixfalls 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 valueignored', () => {
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 ключи, case-sensitive значения', () => {
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() работает на полностью искусственном ScanResult — нам важна
2
- // логика ранкера и префикс-фильтров, а не парсер. Минимально-валидный
3
- // fixture-builder ниже соблюдает форму типов, но не претендует на
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('пустой query → пустая выдача', () => {
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 находит composable', () => {
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 находит модули с этим specifier', () => {
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 ищет substring в path (case-insensitive)', () => {
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-пересечение: kind:component path:auth', () => {
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 внутри ключа: kind:component kind:composable', () => {
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 без префикса: ранкер находит совпадения по path и exports', () => {
143
+ it('free-text without a prefix: ranker finds matches by path and exports', () => {
144
144
  const r = search(FIXTURE, 'useAuth');
145
- // useAuth.ts должен быть на верху (точное совпадение exports + basename)
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('точное совпадение basename ранжируется выше substring-совпадения', () => {
150
+ it('exact basename match ranks above a substring match', () => {
151
151
  const r = search(FIXTURE, 'useAuth');
152
- // useAuth.ts — basename match; AuthForm.vue — только path-substring
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('неизвестный kind не падает, просто не находит', () => {
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: совпадает по export (через export:) и по path (через free-text)
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
  });
@@ -1,18 +1,18 @@
1
- // Глобальный поиск по `ScanResult` — без чтения файловой системы, только
2
- // по структурированным метаданным (modules + edges). Стратегия:
1
+ // Global search over `ScanResult` — no filesystem reads, only structured
2
+ // metadata (modules + edges). Strategy:
3
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
- // почему он попал в выдачу.
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
- // Не делаем: ripgrep по содержимому файлов, fuzzy-substring (типа
14
- // fzf-style scoring) — для path → modules.length обычно сотни, обычный
15
- // substring достаточен. Усложним, если появится сигнал «слишком шумно».
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
- /** Какие критерии сработали — UI использует для подсветки чипов. */
27
+ /** Which criteria matchedthe UI uses this to highlight chips. */
28
28
  matched: MatchKind[];
29
- /** Score 0..1; 1 = идеальное соответствие, 0 = только prefix-фильтр прошёл. */
29
+ /** Score 0..1; 1 = perfect match, 0 = only the prefix filter passed. */
30
30
  score: number;
31
31
  /**
32
- * Подсветочный hint для каждого совпавшего критерияконкретные значения
33
- * (имя экспорта, specifier импорта, kind). Используется панелью результатов
34
- * для пояснения «почему этот модуль вылез на этот запрос».
32
+ * Highlight hint for each matched criterionthe 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
- /** Ограничить размер выдачи. По умолчанию 50 — типовой UI-overlay. */
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. Префиксные фильтры выдают `Map<id, MatchKind[]> + highlights` —
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 ранкинг. Если free пуст и префиксы естьотдаём eligible
65
- // как есть со скором 0.5 (нейтральный). Если free естьсчитаем скор и
66
- // отбрасываем модули, у которых ни один free-токен не сматчился, КОГДА
67
- // префиксов нет; иначе free снижает скор, но не отсекает.
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
- // Нет ни префикс-матча, ни free-матча: модуль вообще не попал.
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. Сортировка: больший score → раньше; tiebreak по короче-путьраньше
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 — оригинальная строка импорта; non-resolved тоже учитываем
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
- * Простой substring-ранкер. Не fuzzy: для типичной задачи «найди useAuth»
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
- * - Точное совпадение exports[i] === term: +0.5
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 (но в score раз чтобы три совпадения не
224
- * делали скор > 1)
225
- * Затем нормализуем в 0..1 по числу term'ов.
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
- // Нормализуем: максимум 0.5 за term — top score 1.0 при N term'ов.
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,