@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.
- package/README.md +5 -3
- package/package.json +1 -1
- package/src/README.md +2 -2
- package/src/analyzer/__tests__/__snapshots__/referenceSnapshot.test.ts.snap +1 -1
- package/src/analyzer/__tests__/analyze.test.ts +41 -0
- package/src/analyzer/__tests__/bundle.test.ts +99 -0
- package/src/analyzer/__tests__/hotZones.test.ts +128 -0
- package/src/analyzer/__tests__/incremental.test.ts +61 -13
- package/src/analyzer/__tests__/layerViolationAccuracy.test.ts +77 -0
- package/src/analyzer/__tests__/memoryRisk.test.ts +94 -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__/recommendations.test.ts +67 -0
- package/src/analyzer/__tests__/resolve.test.ts +54 -0
- package/src/analyzer/__tests__/rsc.test.ts +133 -3
- package/src/analyzer/archDebt.ts +32 -9
- package/src/analyzer/buildGraph.ts +75 -3
- package/src/analyzer/bundle/analyzeBundle.ts +84 -1
- package/src/analyzer/bundle/types.ts +9 -1
- package/src/analyzer/hotZones.ts +94 -2
- package/src/analyzer/incremental.ts +28 -10
- package/src/analyzer/index.ts +3 -1
- package/src/analyzer/loadAliases.ts +4 -4
- package/src/analyzer/memoryRisk.ts +33 -2
- 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 +28 -14
- package/src/analyzer/resolve.ts +51 -18
- package/src/analyzer/rsc.ts +90 -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 +22 -0
- package/src/cache/index.ts +18 -3
- package/src/diff/__tests__/diffScans.test.ts +64 -1
- package/src/diff/diffScans.ts +31 -1
- package/src/diff/types.ts +19 -1
- package/src/git/__tests__/computeTemporalCoupling.test.ts +24 -0
- package/src/git/computeTemporalCoupling.ts +35 -4
- package/src/git/types.ts +14 -1
- package/src/index.ts +5 -0
- package/src/report/__tests__/buildDeadCodeReport.test.ts +108 -0
- package/src/report/buildDeadCodeReport.ts +110 -0
- package/src/report/buildFixPlan.ts +14 -69
- 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/views/__tests__/analyzerViews.test.ts +6 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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 (
|
|
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.
|
|
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
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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('
|
|
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
|
});
|