@archora/core 1.1.0 → 2.0.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 (52) hide show
  1. package/README.md +5 -3
  2. package/package.json +1 -1
  3. package/src/README.md +2 -2
  4. package/src/analyzer/__tests__/__snapshots__/referenceSnapshot.test.ts.snap +1 -1
  5. package/src/analyzer/__tests__/analyze.test.ts +41 -0
  6. package/src/analyzer/__tests__/bundle.test.ts +99 -0
  7. package/src/analyzer/__tests__/hotZones.test.ts +128 -0
  8. package/src/analyzer/__tests__/incremental.test.ts +61 -13
  9. package/src/analyzer/__tests__/layerViolationAccuracy.test.ts +77 -0
  10. package/src/analyzer/__tests__/memoryRisk.test.ts +94 -0
  11. package/src/analyzer/__tests__/metrics.test.ts +39 -0
  12. package/src/analyzer/__tests__/nuxtComposableAutoImport.test.ts +109 -0
  13. package/src/analyzer/__tests__/reactParser.test.ts +22 -0
  14. package/src/analyzer/__tests__/recommendations.test.ts +67 -0
  15. package/src/analyzer/__tests__/resolve.test.ts +54 -0
  16. package/src/analyzer/__tests__/rsc.test.ts +133 -3
  17. package/src/analyzer/archDebt.ts +32 -9
  18. package/src/analyzer/buildGraph.ts +75 -3
  19. package/src/analyzer/bundle/analyzeBundle.ts +84 -1
  20. package/src/analyzer/bundle/types.ts +9 -1
  21. package/src/analyzer/hotZones.ts +94 -2
  22. package/src/analyzer/incremental.ts +28 -10
  23. package/src/analyzer/index.ts +3 -1
  24. package/src/analyzer/loadAliases.ts +4 -4
  25. package/src/analyzer/memoryRisk.ts +33 -2
  26. package/src/analyzer/metrics.ts +10 -1
  27. package/src/analyzer/parsers/svelteParser.ts +5 -0
  28. package/src/analyzer/parsers/tsParser.ts +11 -1
  29. package/src/analyzer/recommendations.ts +28 -14
  30. package/src/analyzer/resolve.ts +51 -18
  31. package/src/analyzer/rsc.ts +90 -9
  32. package/src/analyzer/sources/browserFsAccessFileSource.ts +1 -1
  33. package/src/analyzer/sources/nodeFsFileSource.ts +1 -1
  34. package/src/analyzer/sources/tauriFileSource.ts +2 -2
  35. package/src/analyzer/types.ts +22 -0
  36. package/src/cache/index.ts +18 -3
  37. package/src/diff/__tests__/diffScans.test.ts +64 -1
  38. package/src/diff/diffScans.ts +31 -1
  39. package/src/diff/types.ts +19 -1
  40. package/src/git/__tests__/computeTemporalCoupling.test.ts +24 -0
  41. package/src/git/computeTemporalCoupling.ts +35 -4
  42. package/src/git/types.ts +14 -1
  43. package/src/index.ts +5 -0
  44. package/src/report/__tests__/buildDeadCodeReport.test.ts +108 -0
  45. package/src/report/buildDeadCodeReport.ts +110 -0
  46. package/src/report/buildFixPlan.ts +14 -69
  47. package/src/search/__tests__/parseQuery.test.ts +13 -13
  48. package/src/search/__tests__/search.test.ts +19 -19
  49. package/src/search/index.ts +39 -39
  50. package/src/search/parseQuery.ts +13 -13
  51. package/src/views/__tests__/analyzerViews.test.ts +6 -0
  52. package/src/views/analyzerViews.ts +1 -6
@@ -30,7 +30,11 @@ const DEFAULTS: Required<TemporalCouplingThresholds> = {
30
30
  minCoOccurrences: 3,
31
31
  minScore: 0.5,
32
32
  maxPairs: 100,
33
- commitFanOutCap: 50,
33
+ // Batch PRs (codegen bumps, "touch every composable" passes) co-change dozens
34
+ // of files at once and flood the raw list with same-group noise. A real
35
+ // architectural coupling shows up in small, focused commits, so cap fan-out
36
+ // tighter than a typical batch PR.
37
+ commitFanOutCap: 30,
34
38
  };
35
39
 
36
40
  export function computeTemporalCoupling(input: ComputeTemporalCouplingInput): TemporalCoupling[] {
@@ -72,13 +76,15 @@ export function computeTemporalCoupling(input: ComputeTemporalCouplingInput): Te
72
76
  const score = Math.min(scoreA, scoreB);
73
77
  if (score < t.minScore) continue;
74
78
  const hidden = !staticPairs.has(key);
75
- out.push({ a, b, coOccurrences: n, scoreA, scoreB, score, hidden });
79
+ const crossBoundary = topBoundary(a) !== topBoundary(b);
80
+ const risk = couplingRisk(score, n, hidden, crossBoundary);
81
+ out.push({ a, b, coOccurrences: n, scoreA, scoreB, score, hidden, crossBoundary, risk });
76
82
  }
77
83
 
78
- // Hidden couplings first (most informative), then by score, then by
84
+ // By risk (encodes hidden + cross-boundary + evidence), then score, then
79
85
  // co-occurrences, then alphabetically — stable across runs.
80
86
  out.sort((x, y) => {
81
- if (x.hidden !== y.hidden) return x.hidden ? -1 : 1;
87
+ if (y.risk !== x.risk) return y.risk - x.risk;
82
88
  if (y.score !== x.score) return y.score - x.score;
83
89
  if (y.coOccurrences !== x.coOccurrences) return y.coOccurrences - x.coOccurrences;
84
90
  return x.a.localeCompare(y.a) || x.b.localeCompare(y.b);
@@ -102,6 +108,31 @@ function collectTouchedModules(
102
108
  return [...touched].sort();
103
109
  }
104
110
 
111
+ // Top-level module group: first path segment after an optional `src/`. Files
112
+ // directly under the root share the '' group, so two of them are NOT cross-
113
+ // boundary. For FSD this is the layer (features/entities/shared/...).
114
+ function topBoundary(id: string): string {
115
+ const p = id.startsWith('src/') ? id.slice(4) : id;
116
+ const i = p.indexOf('/');
117
+ return i === -1 ? '' : p.slice(0, i);
118
+ }
119
+
120
+ // Actionability score. Strength (`score`) tempered by evidence volume, boosted
121
+ // when the coupling is hidden (the static graph doesn't already reveal it) and
122
+ // when it crosses a module boundary (missing-abstraction smell). Capped at 1.
123
+ function couplingRisk(
124
+ score: number,
125
+ coOccurrences: number,
126
+ hidden: boolean,
127
+ crossBoundary: boolean,
128
+ ): number {
129
+ const evidence = Math.min(1, coOccurrences / 10);
130
+ let risk = score * (0.5 + 0.5 * evidence);
131
+ risk *= hidden ? 1.25 : 0.6;
132
+ if (hidden && crossBoundary) risk *= 1.15;
133
+ return Math.min(1, Math.round(risk * 1000) / 1000);
134
+ }
135
+
105
136
  function collectStaticPairs(edges: DependencyEdge[]): Set<string> {
106
137
  const out = new Set<string>();
107
138
  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 {
package/src/index.ts CHANGED
@@ -31,6 +31,11 @@ export {
31
31
  type FixPlanRepairGroup,
32
32
  type BuildFixPlanOptions,
33
33
  } from './report/buildFixPlan';
34
+ export {
35
+ buildDeadCodeReport,
36
+ type DeadCodeReport,
37
+ type DeadCodeCandidate,
38
+ } from './report/buildDeadCodeReport';
34
39
  export { diffScans, type ScanDiff, type ChangedModule, type ScanDiffSummary } from './diff/index';
35
40
  export { compileGlob } from './analyzer/discover';
36
41
  export {
@@ -0,0 +1,108 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildDeadCodeReport } from '../buildDeadCodeReport';
3
+ import type { ScanResult } from '../../analyzer/types';
4
+
5
+ describe('buildDeadCodeReport', () => {
6
+ it('returns candidates with correct groups and totalLoc, excluding non-qualifying modules', () => {
7
+ const scan = scanFixture();
8
+ const report = buildDeadCodeReport(scan);
9
+
10
+ // entry-kind, live module (fanIn>0), and test file must be excluded
11
+ const ids = report.candidates.map((c) => c.id);
12
+ expect(ids).not.toContain('src/app/main.ts');
13
+ expect(ids).not.toContain('src/features/active/model.ts');
14
+ expect(ids).not.toContain('src/utils/parse.test.ts');
15
+
16
+ // isolated source module and script-entry must be included
17
+ expect(ids).toContain('src/legacy/orphan.ts');
18
+ expect(ids).toContain('scripts/seed.mjs');
19
+
20
+ // groups
21
+ const orphan = report.candidates.find((c) => c.id === 'src/legacy/orphan.ts');
22
+ expect(orphan?.group).toBe('isolated');
23
+ expect(orphan?.loc).toBe(40);
24
+
25
+ const script = report.candidates.find((c) => c.id === 'scripts/seed.mjs');
26
+ expect(script?.group).toBe('script-entry');
27
+
28
+ // counts
29
+ expect(report.isolatedCount).toBe(1);
30
+ expect(report.scriptEntryCount).toBe(1);
31
+ expect(report.candidates).toHaveLength(2);
32
+
33
+ // totalLoc = sum of candidate locs
34
+ expect(report.totalLoc).toBe(40 + 8);
35
+
36
+ // sorted by loc desc
37
+ expect(report.candidates.map((c) => c.id)).toEqual([
38
+ 'src/legacy/orphan.ts',
39
+ 'scripts/seed.mjs',
40
+ ]);
41
+ });
42
+
43
+ it('returns an empty report when no dead modules exist', () => {
44
+ const report = buildDeadCodeReport({
45
+ ...scanFixture(),
46
+ modules: [mod('src/app/main.ts', 'entry', 20)],
47
+ metrics: {},
48
+ });
49
+ expect(report.candidates).toHaveLength(0);
50
+ expect(report.totalLoc).toBe(0);
51
+ expect(report.isolatedCount).toBe(0);
52
+ expect(report.scriptEntryCount).toBe(0);
53
+ });
54
+ });
55
+
56
+ function scanFixture(): ScanResult {
57
+ return {
58
+ project: { id: 'test', name: 'test', rootPath: '/repo', detectedFramework: 'generic' },
59
+ modules: [
60
+ mod('src/app/main.ts', 'entry', 15),
61
+ mod('src/features/active/model.ts', 'model', 30),
62
+ mod('src/legacy/orphan.ts', 'module', 40),
63
+ mod('scripts/seed.mjs', 'module', 8),
64
+ mod('src/utils/parse.test.ts', 'test', 20),
65
+ ],
66
+ edges: [],
67
+ cycles: [],
68
+ metrics: {
69
+ 'src/app/main.ts': metric(1, 2),
70
+ 'src/features/active/model.ts': metric(3, 1),
71
+ 'src/legacy/orphan.ts': metric(0, 0),
72
+ 'scripts/seed.mjs': metric(0, 0),
73
+ 'src/utils/parse.test.ts': metric(0, 0),
74
+ },
75
+ hotZones: [],
76
+ layerViolations: [],
77
+ archDebt: {
78
+ score: 0,
79
+ grade: 'A',
80
+ breakdown: { cycles: 0, layerViolations: 0, hotZones: 0, coupling: 0 },
81
+ },
82
+ recommendations: [],
83
+ contractViolations: [],
84
+ scannedAt: '2026-06-21T00:00:00.000Z',
85
+ durationMs: 1,
86
+ warnings: [],
87
+ };
88
+ }
89
+
90
+ function mod(
91
+ id: string,
92
+ kind: ScanResult['modules'][number]['kind'],
93
+ loc: number,
94
+ ): ScanResult['modules'][number] {
95
+ return { id, absPath: `/repo/${id}`, kind, language: 'ts', loc, exports: [], isInfra: false };
96
+ }
97
+
98
+ function metric(fanIn: number, fanOut: number): ScanResult['metrics'][string] {
99
+ return {
100
+ fanIn,
101
+ fanOut,
102
+ instability: 0,
103
+ depth: 0,
104
+ inCycle: false,
105
+ couplingScore: 0,
106
+ hotnessScore: 0,
107
+ };
108
+ }
@@ -0,0 +1,110 @@
1
+ import type { ModuleId, ModuleNode, ScanResult } from '../analyzer/types';
2
+
3
+ export interface DeadCodeCandidate {
4
+ id: ModuleId;
5
+ loc: number;
6
+ kind: ModuleNode['kind'];
7
+ group: 'isolated' | 'script-entry';
8
+ reason: string;
9
+ }
10
+
11
+ export interface DeadCodeReport {
12
+ candidates: DeadCodeCandidate[];
13
+ /** Sum of candidate loc — the reclaimable LOC estimate. */
14
+ totalLoc: number;
15
+ isolatedCount: number;
16
+ scriptEntryCount: number;
17
+ }
18
+
19
+ const DEAD_MODULE_REASON =
20
+ 'No resolved imports connect this module to the analyzed dependency model.';
21
+
22
+ export function buildDeadCodeReport(scan: ScanResult): DeadCodeReport {
23
+ const candidates: DeadCodeCandidate[] = [];
24
+
25
+ for (const module of scan.modules) {
26
+ if (!isReviewModule(module.id)) continue;
27
+ const metrics = scan.metrics[module.id];
28
+ const fanIn = metrics?.fanIn ?? 0;
29
+ const fanOut = metrics?.fanOut ?? 0;
30
+ if (
31
+ fanIn === 0 &&
32
+ fanOut === 0 &&
33
+ module.exports.length === 0 &&
34
+ module.kind !== 'entry' &&
35
+ module.kind !== 'test' &&
36
+ !module.isGenerated &&
37
+ !module.isInfra
38
+ ) {
39
+ candidates.push({
40
+ id: module.id,
41
+ loc: module.loc,
42
+ kind: module.kind,
43
+ group: isScriptEntryCandidate(module.id) ? 'script-entry' : 'isolated',
44
+ reason: DEAD_MODULE_REASON,
45
+ });
46
+ }
47
+ }
48
+
49
+ candidates.sort((a, b) => b.loc - a.loc || a.id.localeCompare(b.id));
50
+
51
+ const totalLoc = candidates.reduce((sum, c) => sum + c.loc, 0);
52
+ const isolatedCount = candidates.filter((c) => c.group === 'isolated').length;
53
+ const scriptEntryCount = candidates.filter((c) => c.group === 'script-entry').length;
54
+
55
+ return { candidates, totalLoc, isolatedCount, scriptEntryCount };
56
+ }
57
+
58
+ /**
59
+ * Repair action and verify text for an unreachable module finding.
60
+ * Shared with buildFixPlan so the two never diverge.
61
+ */
62
+ export function unreachableRepair(id: ModuleId): {
63
+ action: string;
64
+ verify: string;
65
+ params: Record<string, unknown>;
66
+ } {
67
+ if (isScriptEntryCandidate(id)) {
68
+ return {
69
+ action: `Treat ${id} as a script entry: add it to architecture entry configuration or exclude it from review scope; delete only after confirming no package script or CI job calls it.`,
70
+ verify:
71
+ 'Run archora report . --format fix-plan after entry/exclude config and confirm this script is no longer a priority finding.',
72
+ params: { entryCandidate: 'script' },
73
+ };
74
+ }
75
+
76
+ return {
77
+ action:
78
+ 'Check whether this file is dead code, dynamically loaded outside analyzer reach, or should be declared as an entry point.',
79
+ verify:
80
+ 'Re-scan after deletion, ignore, or entry-point configuration and confirm the candidate is gone.',
81
+ params: {},
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Matches modules that should go through architecture review.
87
+ * Excludes fixture directories and test files — same predicate as buildFixPlan.
88
+ */
89
+ export function isReviewModule(id: ModuleId): boolean {
90
+ return !/(^|\/)(fixtures|test\/fixtures|__fixtures__|__tests__|__mocks__)(\/|$)|\.(test|spec)\./u.test(
91
+ id,
92
+ );
93
+ }
94
+
95
+ /**
96
+ * Heuristic: a file directly under a `scripts/` directory with a JS extension
97
+ * is likely a standalone Node script, not a dead module.
98
+ */
99
+ export function isScriptEntryCandidate(id: ModuleId): boolean {
100
+ const normalized = id.replace(/\\/gu, '/');
101
+ let scriptsIndex = 0;
102
+ if (!normalized.startsWith('scripts/')) {
103
+ const nestedIndex = normalized.indexOf('/scripts/');
104
+ if (nestedIndex < 0) return false;
105
+ scriptsIndex = nestedIndex + 1;
106
+ }
107
+ const file = normalized.slice(scriptsIndex + 'scripts/'.length);
108
+ if (!file) return false;
109
+ return file.endsWith('.js') || file.endsWith('.cjs') || file.endsWith('.mjs');
110
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ModuleId, ModuleNode, Recommendation, ScanResult } from '../analyzer/types';
2
+ import { buildDeadCodeReport, isReviewModule, unreachableRepair } from './buildDeadCodeReport';
2
3
 
3
4
  export interface FixPlanFinding {
4
5
  type:
@@ -223,33 +224,19 @@ function buildPriorityFindings(scan: ScanResult, generated: Set<ModuleId>): FixP
223
224
  });
224
225
  }
225
226
 
226
- for (const module of scan.modules) {
227
- if (!isReviewModule(module.id)) continue;
228
- const metrics = scan.metrics[module.id];
229
- const fanIn = metrics?.fanIn ?? 0;
230
- const fanOut = metrics?.fanOut ?? 0;
231
- if (
232
- fanIn === 0 &&
233
- fanOut === 0 &&
234
- module.exports.length === 0 &&
235
- module.kind !== 'entry' &&
236
- module.kind !== 'test' &&
237
- !module.isGenerated &&
238
- !module.isInfra
239
- ) {
240
- const repair = unreachableRepair(module.id);
241
- findings.push({
242
- type: 'unreachable-from-entries',
243
- id: `${module.id}:unreachable`,
244
- title: 'Unreachable module candidate',
245
- weight: Math.min(70, 35 + module.loc / 10),
246
- targets: [module.id],
247
- reason: 'No resolved imports connect this module to the analyzed dependency model.',
248
- action: repair.action,
249
- verify: repair.verify,
250
- params: { loc: module.loc, ...repair.params },
251
- });
252
- }
227
+ for (const candidate of buildDeadCodeReport(scan).candidates) {
228
+ const repair = unreachableRepair(candidate.id);
229
+ findings.push({
230
+ type: 'unreachable-from-entries',
231
+ id: `${candidate.id}:unreachable`,
232
+ title: 'Unreachable module candidate',
233
+ weight: Math.min(70, 35 + candidate.loc / 10),
234
+ targets: [candidate.id],
235
+ reason: candidate.reason,
236
+ action: repair.action,
237
+ verify: repair.verify,
238
+ params: { loc: candidate.loc, ...repair.params },
239
+ });
253
240
  }
254
241
 
255
242
  for (const violation of scan.contractViolations.slice(0, 50)) {
@@ -318,52 +305,10 @@ function buildPriorityFindings(scan: ScanResult, generated: Set<ModuleId>): FixP
318
305
  .slice(0, 100);
319
306
  }
320
307
 
321
- function isReviewModule(id: ModuleId): boolean {
322
- return !/(^|\/)(fixtures|test\/fixtures|__fixtures__|__tests__|__mocks__)(\/|$)|\.(test|spec)\./u.test(
323
- id,
324
- );
325
- }
326
-
327
308
  function isLikelyGeneratedPath(id: ModuleId): boolean {
328
309
  return /(^|\/)(generated|__generated__|openapi|swagger|graphql-codegen)(\/|$)/iu.test(id);
329
310
  }
330
311
 
331
- function unreachableRepair(id: ModuleId): {
332
- action: string;
333
- verify: string;
334
- params: Record<string, unknown>;
335
- } {
336
- if (isScriptEntryCandidate(id)) {
337
- return {
338
- action: `Treat ${id} as a script entry: add it to architecture entry configuration or exclude it from review scope; delete only after confirming no package script or CI job calls it.`,
339
- verify:
340
- 'Run archora report . --format fix-plan after entry/exclude config and confirm this script is no longer a priority finding.',
341
- params: { entryCandidate: 'script' },
342
- };
343
- }
344
-
345
- return {
346
- action:
347
- 'Check whether this file is dead code, dynamically loaded outside analyzer reach, or should be declared as an entry point.',
348
- verify:
349
- 'Re-scan after deletion, ignore, or entry-point configuration and confirm the candidate is gone.',
350
- params: {},
351
- };
352
- }
353
-
354
- function isScriptEntryCandidate(id: ModuleId): boolean {
355
- const normalized = id.replace(/\\/gu, '/');
356
- let scriptsIndex = 0;
357
- if (!normalized.startsWith('scripts/')) {
358
- const nestedIndex = normalized.indexOf('/scripts/');
359
- if (nestedIndex < 0) return false;
360
- scriptsIndex = nestedIndex + 1;
361
- }
362
- const file = normalized.slice(scriptsIndex + 'scripts/'.length);
363
- if (!file) return false;
364
- return file.endsWith('.js') || file.endsWith('.cjs') || file.endsWith('.mjs');
365
- }
366
-
367
312
  function barrelCycleRepair(
368
313
  scan: ScanResult,
369
314
  modules: readonly ModuleId[],
@@ -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
  });