@archora/core 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +62 -0
- package/package.json +36 -0
- package/src/README.md +4 -0
- package/src/analyzer/__tests__/__snapshots__/referenceSnapshot.test.ts.snap +145 -0
- package/src/analyzer/__tests__/_paths.ts +8 -0
- package/src/analyzer/__tests__/analyze.test.ts +522 -0
- package/src/analyzer/__tests__/archDebt.test.ts +111 -0
- package/src/analyzer/__tests__/asyncLifecycleRisk.test.ts +122 -0
- package/src/analyzer/__tests__/browserFsAccessFileSource.test.ts +97 -0
- package/src/analyzer/__tests__/bundle.test.ts +191 -0
- package/src/analyzer/__tests__/classify.test.ts +99 -0
- package/src/analyzer/__tests__/contracts.test.ts +372 -0
- package/src/analyzer/__tests__/crossSourceConsistency.test.ts +317 -0
- package/src/analyzer/__tests__/cyclePatterns.test.ts +132 -0
- package/src/analyzer/__tests__/cycles.test.ts +74 -0
- package/src/analyzer/__tests__/detect.test.ts +62 -0
- package/src/analyzer/__tests__/discover.test.ts +68 -0
- package/src/analyzer/__tests__/displayId.test.ts +30 -0
- package/src/analyzer/__tests__/feedbackArcSet.test.ts +168 -0
- package/src/analyzer/__tests__/inMemoryFileSource.test.ts +34 -0
- package/src/analyzer/__tests__/incremental.test.ts +154 -0
- package/src/analyzer/__tests__/layers.test.ts +87 -0
- package/src/analyzer/__tests__/layersOverrides.test.ts +120 -0
- package/src/analyzer/__tests__/memoryRisk.test.ts +132 -0
- package/src/analyzer/__tests__/metrics.test.ts +59 -0
- package/src/analyzer/__tests__/parserRegistry.test.ts +54 -0
- package/src/analyzer/__tests__/parsers.test.ts +187 -0
- package/src/analyzer/__tests__/reactParser.test.ts +93 -0
- package/src/analyzer/__tests__/recommendations.test.ts +171 -0
- package/src/analyzer/__tests__/referenceSnapshot.test.ts +63 -0
- package/src/analyzer/__tests__/resolve.test.ts +294 -0
- package/src/analyzer/__tests__/rsc.test.ts +130 -0
- package/src/analyzer/__tests__/signals.test.ts +316 -0
- package/src/analyzer/__tests__/suggestContracts.test.ts +108 -0
- package/src/analyzer/__tests__/svelteParser.test.ts +108 -0
- package/src/analyzer/__tests__/typeOnlyCandidates.test.ts +163 -0
- package/src/analyzer/__tests__/vueAutoImport.test.ts +177 -0
- package/src/analyzer/archDebt.ts +68 -0
- package/src/analyzer/asyncLifecycleRisk.ts +234 -0
- package/src/analyzer/buildGraph.ts +683 -0
- package/src/analyzer/bundle/analyzeBundle.ts +147 -0
- package/src/analyzer/bundle/index.ts +12 -0
- package/src/analyzer/bundle/parseStats.ts +152 -0
- package/src/analyzer/bundle/types.ts +85 -0
- package/src/analyzer/classify.ts +54 -0
- package/src/analyzer/contracts.ts +265 -0
- package/src/analyzer/cyclePatterns.ts +138 -0
- package/src/analyzer/cycles.ts +98 -0
- package/src/analyzer/detect.ts +34 -0
- package/src/analyzer/discover.ts +131 -0
- package/src/analyzer/displayId.ts +21 -0
- package/src/analyzer/entryPoints.ts +136 -0
- package/src/analyzer/feedbackArcSet.ts +332 -0
- package/src/analyzer/fileSource.ts +8 -0
- package/src/analyzer/hotZones.ts +17 -0
- package/src/analyzer/incremental.ts +455 -0
- package/src/analyzer/index.ts +444 -0
- package/src/analyzer/layers.ts +183 -0
- package/src/analyzer/loadAliases.ts +288 -0
- package/src/analyzer/memoryRisk.ts +345 -0
- package/src/analyzer/metrics.ts +156 -0
- package/src/analyzer/parsers/index.ts +62 -0
- package/src/analyzer/parsers/reactParser.ts +24 -0
- package/src/analyzer/parsers/svelteParser.ts +46 -0
- package/src/analyzer/parsers/tsParser.ts +364 -0
- package/src/analyzer/parsers/vueParser.ts +109 -0
- package/src/analyzer/recommendations.ts +432 -0
- package/src/analyzer/resolve.ts +315 -0
- package/src/analyzer/rsc.ts +120 -0
- package/src/analyzer/signals.ts +684 -0
- package/src/analyzer/sources/browserFsAccessFileSource.ts +132 -0
- package/src/analyzer/sources/inMemoryFileSource.ts +24 -0
- package/src/analyzer/sources/nodeFsFileSource.ts +93 -0
- package/src/analyzer/sources/tauriFileSource.ts +68 -0
- package/src/analyzer/suggestContracts.ts +214 -0
- package/src/analyzer/typeOnlyCandidates.ts +233 -0
- package/src/analyzer/types.ts +537 -0
- package/src/cache/__tests__/cache.test.ts +316 -0
- package/src/cache/index.ts +432 -0
- package/src/codegen/__tests__/applyTypeOnlyFix.integration.test.ts +62 -0
- package/src/codegen/__tests__/applyTypeOnlyFix.test.ts +176 -0
- package/src/codegen/__tests__/configSnippets.test.ts +230 -0
- package/src/codegen/applyTypeOnlyFix.ts +344 -0
- package/src/codegen/configSnippets.ts +172 -0
- package/src/codegen/initConfig.ts +223 -0
- package/src/config/__tests__/frontScopeConfig.test.ts +187 -0
- package/src/config/frontScopeConfig.ts +830 -0
- package/src/diff/__tests__/diffScans.test.ts +103 -0
- package/src/diff/diffScans.ts +61 -0
- package/src/diff/index.ts +2 -0
- package/src/diff/types.ts +39 -0
- package/src/git/__tests__/computeChurn.test.ts +113 -0
- package/src/git/__tests__/computeTemporalCoupling.test.ts +125 -0
- package/src/git/__tests__/parseGitLog.test.ts +120 -0
- package/src/git/computeChurn.ts +111 -0
- package/src/git/computeTemporalCoupling.ts +114 -0
- package/src/git/index.ts +24 -0
- package/src/git/parseGitLog.ts +124 -0
- package/src/git/readGitHistory.ts +130 -0
- package/src/git/types.ts +119 -0
- package/src/index.ts +137 -0
- package/src/report/__tests__/buildFixPlan.test.ts +357 -0
- package/src/report/__tests__/buildJsonReport.test.ts +34 -0
- package/src/report/buildFixPlan.ts +481 -0
- package/src/report/buildJsonReport.ts +27 -0
- package/src/search/__tests__/parseQuery.test.ts +67 -0
- package/src/search/__tests__/search.test.ts +172 -0
- package/src/search/index.ts +281 -0
- package/src/search/parseQuery.ts +75 -0
- package/src/views/__tests__/analyzerViews.test.ts +558 -0
- package/src/views/analyzerViews.ts +1294 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import type { ArchoraConfig, GeneratedPolicy } from '../config/frontScopeConfig';
|
|
2
|
+
import type { LayerViolation, ScanResult } from '../analyzer/types';
|
|
3
|
+
|
|
4
|
+
export interface SnippetOptions {
|
|
5
|
+
indent?: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const DEFAULT_INDENT = 2;
|
|
9
|
+
|
|
10
|
+
export type ProjectPolicyPreset = 'fsd' | 'package-workspace' | 'generated-openapi';
|
|
11
|
+
|
|
12
|
+
export function buildProjectPolicyPresetSnippet(
|
|
13
|
+
preset: ProjectPolicyPreset,
|
|
14
|
+
options: SnippetOptions = {},
|
|
15
|
+
): string {
|
|
16
|
+
return JSON.stringify(projectPolicyPreset(preset), null, options.indent ?? DEFAULT_INDENT);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function buildGeneratedConfigSnippet(
|
|
20
|
+
policy: GeneratedPolicy,
|
|
21
|
+
options: SnippetOptions = {},
|
|
22
|
+
): string {
|
|
23
|
+
const block: Record<string, unknown> = { mode: policy.mode };
|
|
24
|
+
if (policy.patterns && policy.patterns.length > 0) block['patterns'] = policy.patterns;
|
|
25
|
+
if (policy.presets && policy.presets.length > 0) block['presets'] = policy.presets;
|
|
26
|
+
return JSON.stringify({ analysis: { generated: block } }, null, options.indent ?? DEFAULT_INDENT);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function buildIgnoreSnippet(
|
|
30
|
+
patterns: readonly string[],
|
|
31
|
+
options: SnippetOptions = {},
|
|
32
|
+
): string {
|
|
33
|
+
return JSON.stringify(
|
|
34
|
+
{ ignore: dedupeNonEmpty(patterns) },
|
|
35
|
+
null,
|
|
36
|
+
options.indent ?? DEFAULT_INDENT,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function buildLayerOverrideSnippet(
|
|
41
|
+
violations: readonly LayerViolation[],
|
|
42
|
+
options: SnippetOptions = {},
|
|
43
|
+
): string {
|
|
44
|
+
const counts = new Map<string, Map<string, number>>();
|
|
45
|
+
for (const v of violations) {
|
|
46
|
+
const byLayer = counts.get(v.to) ?? new Map<string, number>();
|
|
47
|
+
byLayer.set(v.fromLayer, (byLayer.get(v.fromLayer) ?? 0) + 1);
|
|
48
|
+
counts.set(v.to, byLayer);
|
|
49
|
+
}
|
|
50
|
+
const overrides: Record<string, string> = {};
|
|
51
|
+
for (const [moduleId, byLayer] of counts) {
|
|
52
|
+
const top = pickTop(byLayer);
|
|
53
|
+
if (top) overrides[moduleId] = top;
|
|
54
|
+
}
|
|
55
|
+
return JSON.stringify({ layerOverrides: overrides }, null, options.indent ?? DEFAULT_INDENT);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function buildDynamicLoaderSnippet(scan: ScanResult, options: SnippetOptions = {}): string {
|
|
59
|
+
const seen = new Set<string>();
|
|
60
|
+
const samples: { specifier: string; resolveAs: string }[] = [];
|
|
61
|
+
for (const e of scan.edges) {
|
|
62
|
+
if (e.resolutionKind !== 'prefix' && e.resolutionKind !== 'glob') continue;
|
|
63
|
+
if (seen.has(e.specifier)) continue;
|
|
64
|
+
seen.add(e.specifier);
|
|
65
|
+
samples.push({ specifier: e.specifier, resolveAs: dynamicResolveTemplate(e.specifier) });
|
|
66
|
+
if (samples.length >= 5) break;
|
|
67
|
+
}
|
|
68
|
+
const block = samples.map((s, i) => ({
|
|
69
|
+
name: `dynamic-loader-${i + 1}`,
|
|
70
|
+
resolveAs: s.resolveAs,
|
|
71
|
+
description: `Observed pattern: ${s.specifier}`,
|
|
72
|
+
}));
|
|
73
|
+
return JSON.stringify({ dynamicLoaders: block }, null, options.indent ?? DEFAULT_INDENT);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function projectPolicyPreset(preset: ProjectPolicyPreset): ArchoraConfig {
|
|
77
|
+
switch (preset) {
|
|
78
|
+
case 'fsd':
|
|
79
|
+
return {
|
|
80
|
+
contracts: {
|
|
81
|
+
boundaries: [
|
|
82
|
+
{
|
|
83
|
+
name: 'features-isolation',
|
|
84
|
+
from: 'src/features/*/**',
|
|
85
|
+
to: 'src/features/*/**',
|
|
86
|
+
mode: 'must-not',
|
|
87
|
+
crossInstance: true,
|
|
88
|
+
severity: 'warning',
|
|
89
|
+
description: 'Feature slices should talk through shared APIs, not sibling internals.',
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: 'shared-not-ui-layers',
|
|
93
|
+
from: 'src/shared/**',
|
|
94
|
+
to: 'src/**',
|
|
95
|
+
mode: 'must-not',
|
|
96
|
+
except: ['src/shared/**'],
|
|
97
|
+
severity: 'error',
|
|
98
|
+
description: 'Shared code should stay independent from product layers.',
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
case 'package-workspace':
|
|
104
|
+
return {
|
|
105
|
+
contracts: {
|
|
106
|
+
boundaries: [
|
|
107
|
+
{
|
|
108
|
+
name: 'packages-through-public-api',
|
|
109
|
+
from: 'packages/*/src/**',
|
|
110
|
+
to: 'packages/*/src/**',
|
|
111
|
+
mode: 'must-not',
|
|
112
|
+
crossInstance: true,
|
|
113
|
+
except: ['packages/*/src/index.*'],
|
|
114
|
+
severity: 'warning',
|
|
115
|
+
description:
|
|
116
|
+
'Workspace packages should consume sibling packages through public APIs.',
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
budgets: [
|
|
120
|
+
{
|
|
121
|
+
name: 'package-entry-fanout',
|
|
122
|
+
module: 'packages/*/src/index.*',
|
|
123
|
+
maxFanOut: 12,
|
|
124
|
+
severity: 'warning',
|
|
125
|
+
description: 'Package entry points should stay narrow enough to review.',
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
case 'generated-openapi':
|
|
131
|
+
return {
|
|
132
|
+
analysis: {
|
|
133
|
+
generated: {
|
|
134
|
+
mode: 'classify',
|
|
135
|
+
presets: ['openapi', 'generated-folder'],
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function dedupeNonEmpty(input: readonly string[]): string[] {
|
|
143
|
+
const seen = new Set<string>();
|
|
144
|
+
const out: string[] = [];
|
|
145
|
+
for (const raw of input) {
|
|
146
|
+
if (typeof raw !== 'string') continue;
|
|
147
|
+
const trimmed = raw.trim();
|
|
148
|
+
if (!trimmed || seen.has(trimmed)) continue;
|
|
149
|
+
seen.add(trimmed);
|
|
150
|
+
out.push(trimmed);
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function pickTop(counts: Map<string, number>): string | null {
|
|
156
|
+
let best: string | null = null;
|
|
157
|
+
let bestCount = -1;
|
|
158
|
+
for (const [layer, c] of counts) {
|
|
159
|
+
if (c > bestCount || (c === bestCount && best !== null && layer.localeCompare(best) < 0)) {
|
|
160
|
+
best = layer;
|
|
161
|
+
bestCount = c;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return best;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function dynamicResolveTemplate(specifier: string): string {
|
|
168
|
+
const normalized = specifier.replace(/\\/g, '/');
|
|
169
|
+
if (normalized.endsWith('/')) return `${normalized}{0}/index`;
|
|
170
|
+
if (normalized.includes('*')) return normalized.replace(/\*+/g, '{0}');
|
|
171
|
+
return `${normalized}/{0}/index`;
|
|
172
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import type { ArchoraConfig } from '../config/frontScopeConfig';
|
|
2
|
+
|
|
3
|
+
export interface BuildInitialArchoraConfigInput {
|
|
4
|
+
files: readonly string[];
|
|
5
|
+
packageJsonText?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface InitialArchoraConfigResult {
|
|
9
|
+
config: ArchoraConfig;
|
|
10
|
+
detected: InitialArchoraDetection[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type InitialArchoraDetection =
|
|
14
|
+
| 'vite'
|
|
15
|
+
| 'next'
|
|
16
|
+
| 'nuxt'
|
|
17
|
+
| 'sveltekit'
|
|
18
|
+
| 'workspace-packages'
|
|
19
|
+
| 'generated-openapi';
|
|
20
|
+
|
|
21
|
+
const DEFAULT_IGNORES = [
|
|
22
|
+
'dist/**',
|
|
23
|
+
'build/**',
|
|
24
|
+
'coverage/**',
|
|
25
|
+
'.next/**',
|
|
26
|
+
'.nuxt/**',
|
|
27
|
+
'.svelte-kit/**',
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const ENTRY_CANDIDATES = [
|
|
31
|
+
'src/main.ts',
|
|
32
|
+
'src/main.tsx',
|
|
33
|
+
'src/main.js',
|
|
34
|
+
'src/main.jsx',
|
|
35
|
+
'src/App.tsx',
|
|
36
|
+
'src/App.jsx',
|
|
37
|
+
'src/app/layout.tsx',
|
|
38
|
+
'src/app/page.tsx',
|
|
39
|
+
'src/pages/_app.tsx',
|
|
40
|
+
'src/pages/index.tsx',
|
|
41
|
+
'src/routes/+layout.svelte',
|
|
42
|
+
'src/routes/+page.svelte',
|
|
43
|
+
'app/layout.tsx',
|
|
44
|
+
'app/page.tsx',
|
|
45
|
+
'pages/_app.tsx',
|
|
46
|
+
'pages/index.tsx',
|
|
47
|
+
'pages/index.vue',
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
export function buildInitialArchoraConfig(
|
|
51
|
+
input: BuildInitialArchoraConfigInput,
|
|
52
|
+
): InitialArchoraConfigResult {
|
|
53
|
+
const files = normalizeFiles(input.files);
|
|
54
|
+
const fileSet = new Set(files);
|
|
55
|
+
const packageJson = parsePackageJson(input.packageJsonText);
|
|
56
|
+
const detected = detectProjectShape(fileSet, packageJson);
|
|
57
|
+
|
|
58
|
+
const config: ArchoraConfig = {
|
|
59
|
+
entryPoints: pickEntryPoints(files, fileSet, detected),
|
|
60
|
+
ignore: DEFAULT_IGNORES,
|
|
61
|
+
signals: {
|
|
62
|
+
insightLimit: 6,
|
|
63
|
+
minInsightSeverity: 'medium',
|
|
64
|
+
minInsightConfidence: 'medium',
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
if (detected.includes('generated-openapi')) {
|
|
69
|
+
config.analysis = {
|
|
70
|
+
generated: {
|
|
71
|
+
mode: 'classify',
|
|
72
|
+
presets: ['openapi', 'generated-folder'],
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (detected.includes('workspace-packages')) {
|
|
78
|
+
config.contracts = {
|
|
79
|
+
boundaries: [
|
|
80
|
+
{
|
|
81
|
+
name: 'packages-through-public-api',
|
|
82
|
+
from: 'packages/*/src/**',
|
|
83
|
+
to: 'packages/*/src/**',
|
|
84
|
+
mode: 'must-not',
|
|
85
|
+
crossInstance: true,
|
|
86
|
+
except: ['packages/*/src/index.*'],
|
|
87
|
+
severity: 'warning',
|
|
88
|
+
description: 'Workspace packages should consume sibling packages through public APIs.',
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
budgets: [
|
|
92
|
+
{
|
|
93
|
+
name: 'package-entry-fanout',
|
|
94
|
+
module: 'packages/*/src/index.*',
|
|
95
|
+
maxFanOut: 12,
|
|
96
|
+
severity: 'warning',
|
|
97
|
+
description: 'Package entry points should stay narrow enough to review.',
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { config, detected };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function buildInitialArchoraConfigJson(
|
|
107
|
+
input: BuildInitialArchoraConfigInput,
|
|
108
|
+
indent = 2,
|
|
109
|
+
): string {
|
|
110
|
+
return `${JSON.stringify(buildInitialArchoraConfig(input).config, null, indent)}\n`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function normalizeFiles(files: readonly string[]): string[] {
|
|
114
|
+
const seen = new Set<string>();
|
|
115
|
+
const out: string[] = [];
|
|
116
|
+
for (const raw of files) {
|
|
117
|
+
const rel = raw.replace(/\\/g, '/').replace(/^\.?\//u, '');
|
|
118
|
+
if (!rel || seen.has(rel)) continue;
|
|
119
|
+
seen.add(rel);
|
|
120
|
+
out.push(rel);
|
|
121
|
+
}
|
|
122
|
+
return out.sort((a, b) => a.localeCompare(b));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function parsePackageJson(text: string | undefined): Record<string, unknown> | null {
|
|
126
|
+
if (!text) return null;
|
|
127
|
+
try {
|
|
128
|
+
const parsed = JSON.parse(text) as unknown;
|
|
129
|
+
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null;
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function detectProjectShape(
|
|
136
|
+
files: ReadonlySet<string>,
|
|
137
|
+
packageJson: Record<string, unknown> | null,
|
|
138
|
+
): InitialArchoraDetection[] {
|
|
139
|
+
const deps = collectDependencies(packageJson);
|
|
140
|
+
const detected: InitialArchoraDetection[] = [];
|
|
141
|
+
|
|
142
|
+
if (deps.has('vite') || files.has('vite.config.ts') || files.has('vite.config.js')) {
|
|
143
|
+
detected.push('vite');
|
|
144
|
+
}
|
|
145
|
+
if (deps.has('next') || files.has('next.config.js') || files.has('next.config.ts')) {
|
|
146
|
+
detected.push('next');
|
|
147
|
+
}
|
|
148
|
+
if (deps.has('nuxt') || files.has('nuxt.config.ts') || files.has('nuxt.config.js')) {
|
|
149
|
+
detected.push('nuxt');
|
|
150
|
+
}
|
|
151
|
+
if (deps.has('@sveltejs/kit') || files.has('svelte.config.js') || files.has('svelte.config.ts')) {
|
|
152
|
+
detected.push('sveltekit');
|
|
153
|
+
}
|
|
154
|
+
if (hasWorkspacePackages(files, packageJson)) {
|
|
155
|
+
detected.push('workspace-packages');
|
|
156
|
+
}
|
|
157
|
+
if ([...files].some(isGeneratedApiPath)) {
|
|
158
|
+
detected.push('generated-openapi');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return detected;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function collectDependencies(packageJson: Record<string, unknown> | null): Set<string> {
|
|
165
|
+
const deps = new Set<string>();
|
|
166
|
+
for (const key of ['dependencies', 'devDependencies', 'peerDependencies']) {
|
|
167
|
+
const block = packageJson?.[key];
|
|
168
|
+
if (!block || typeof block !== 'object') continue;
|
|
169
|
+
for (const name of Object.keys(block as Record<string, unknown>)) deps.add(name);
|
|
170
|
+
}
|
|
171
|
+
return deps;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function hasWorkspacePackages(
|
|
175
|
+
files: ReadonlySet<string>,
|
|
176
|
+
packageJson: Record<string, unknown> | null,
|
|
177
|
+
): boolean {
|
|
178
|
+
const workspaces = packageJson?.['workspaces'];
|
|
179
|
+
const hasWorkspaceField =
|
|
180
|
+
Array.isArray(workspaces) ||
|
|
181
|
+
Boolean(
|
|
182
|
+
workspaces &&
|
|
183
|
+
typeof workspaces === 'object' &&
|
|
184
|
+
Array.isArray((workspaces as Record<string, unknown>)['packages']),
|
|
185
|
+
);
|
|
186
|
+
if (!hasWorkspaceField) return false;
|
|
187
|
+
return [...files].some((file) => /^packages\/[^/]+\/src\/index\.[cm]?[jt]sx?$/u.test(file));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function pickEntryPoints(
|
|
191
|
+
files: readonly string[],
|
|
192
|
+
fileSet: ReadonlySet<string>,
|
|
193
|
+
detected: readonly InitialArchoraDetection[],
|
|
194
|
+
): string[] {
|
|
195
|
+
const entries: string[] = [];
|
|
196
|
+
for (const candidate of ENTRY_CANDIDATES) {
|
|
197
|
+
if (fileSet.has(candidate)) entries.push(candidate);
|
|
198
|
+
}
|
|
199
|
+
if (detected.includes('workspace-packages')) {
|
|
200
|
+
entries.push(
|
|
201
|
+
...files.filter((file) => /^packages\/[^/]+\/src\/index\.[cm]?[jt]sx?$/u.test(file)),
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
return dedupe(entries);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function isGeneratedApiPath(file: string): boolean {
|
|
208
|
+
return (
|
|
209
|
+
/(^|\/)(openapi|api-generated|swagger|__generated__|generated)\//u.test(file) ||
|
|
210
|
+
/\.(gen|generated)\.[cm]?[jt]sx?$/u.test(file)
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function dedupe(values: readonly string[]): string[] {
|
|
215
|
+
const seen = new Set<string>();
|
|
216
|
+
const out: string[] = [];
|
|
217
|
+
for (const value of values) {
|
|
218
|
+
if (seen.has(value)) continue;
|
|
219
|
+
seen.add(value);
|
|
220
|
+
out.push(value);
|
|
221
|
+
}
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
loadArchoraConfig,
|
|
4
|
+
loadArchoraConfigWithDiagnostics,
|
|
5
|
+
resolveGeneratedPatterns,
|
|
6
|
+
GENERATED_PRESETS,
|
|
7
|
+
} from '../frontScopeConfig';
|
|
8
|
+
import type { FileSource } from '../../analyzer/fileSource';
|
|
9
|
+
|
|
10
|
+
describe('frontScopeConfig analysis.generated', () => {
|
|
11
|
+
it('parses kebab mode + patterns + presets and dedupes', async () => {
|
|
12
|
+
const cfg = await loadArchoraConfig(
|
|
13
|
+
mockSource({
|
|
14
|
+
'.archora.json': JSON.stringify({
|
|
15
|
+
analysis: {
|
|
16
|
+
generated: {
|
|
17
|
+
mode: 'classify',
|
|
18
|
+
patterns: ['src/recruit/openapi/**', ' ', '**/openapi/**'],
|
|
19
|
+
presets: ['openapi', 'unknown-preset', 'vendor'],
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
}),
|
|
23
|
+
}),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
expect(cfg.analysis?.generated?.mode).toBe('classify');
|
|
27
|
+
expect(cfg.analysis?.generated?.patterns).toEqual(['src/recruit/openapi/**', '**/openapi/**']);
|
|
28
|
+
expect(cfg.analysis?.generated?.presets).toEqual(['openapi', 'vendor']);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('accepts the legacy `classifyAsGenerated` mode + `paths` shape from the Settings UI snippet', async () => {
|
|
32
|
+
const cfg = await loadArchoraConfig(
|
|
33
|
+
mockSource({
|
|
34
|
+
'.archora.json': JSON.stringify({
|
|
35
|
+
analysis: {
|
|
36
|
+
generated: {
|
|
37
|
+
mode: 'classifyAsGenerated',
|
|
38
|
+
paths: ['src/api-generated/**'],
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
}),
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
expect(cfg.analysis?.generated?.mode).toBe('classify');
|
|
46
|
+
expect(cfg.analysis?.generated?.patterns).toEqual(['src/api-generated/**']);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('drops the policy when neither patterns nor presets are present', async () => {
|
|
50
|
+
const cfg = await loadArchoraConfig(
|
|
51
|
+
mockSource({
|
|
52
|
+
'.archora.json': JSON.stringify({
|
|
53
|
+
analysis: { generated: { mode: 'classify' } },
|
|
54
|
+
}),
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
expect(cfg.analysis).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('returns diagnostics for invalid config fields without throwing', async () => {
|
|
61
|
+
const result = await loadArchoraConfigWithDiagnostics(
|
|
62
|
+
mockSource({
|
|
63
|
+
'.archora.json': JSON.stringify({
|
|
64
|
+
unknownField: true,
|
|
65
|
+
ignore: 'dist/**',
|
|
66
|
+
analysis: { generated: { mode: 'classify', presets: ['bad-preset'] } },
|
|
67
|
+
contracts: { budgets: [{ name: 'shared-budget', module: 'src/shared/**' }] },
|
|
68
|
+
}),
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
expect(result.config.ignore).toBeUndefined();
|
|
73
|
+
expect(result.file).toBe('.archora.json');
|
|
74
|
+
expect(result.diagnostics.map((diagnostic) => diagnostic.path)).toEqual(
|
|
75
|
+
expect.arrayContaining([
|
|
76
|
+
'$.unknownField',
|
|
77
|
+
'$.ignore',
|
|
78
|
+
'$.analysis.generated.presets[0]',
|
|
79
|
+
'$.contracts.budgets[0]',
|
|
80
|
+
]),
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('parses signal suppressions from project config', async () => {
|
|
85
|
+
const cfg = await loadArchoraConfig(
|
|
86
|
+
mockSource({
|
|
87
|
+
'.archora.json': JSON.stringify({
|
|
88
|
+
signals: {
|
|
89
|
+
insightLimit: 4,
|
|
90
|
+
minInsightSeverity: 'high',
|
|
91
|
+
minInsightConfidence: 'high',
|
|
92
|
+
suppressions: [
|
|
93
|
+
{
|
|
94
|
+
stableKey: 'contract:shared-boundary',
|
|
95
|
+
reason: 'Accepted until shared API extraction lands.',
|
|
96
|
+
scope: 'module',
|
|
97
|
+
moduleId: 'src/shared/api/client.ts',
|
|
98
|
+
createdAt: '2026-05-22T00:00:00.000Z',
|
|
99
|
+
expiresAt: '2026-06-22T00:00:00.000Z',
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
}),
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
expect(cfg.signals?.suppressions?.[0]).toMatchObject({
|
|
108
|
+
stableKey: 'contract:shared-boundary',
|
|
109
|
+
reason: 'Accepted until shared API extraction lands.',
|
|
110
|
+
scope: 'module',
|
|
111
|
+
moduleId: 'src/shared/api/client.ts',
|
|
112
|
+
});
|
|
113
|
+
expect(cfg.signals).toMatchObject({
|
|
114
|
+
insightLimit: 4,
|
|
115
|
+
minInsightSeverity: 'high',
|
|
116
|
+
minInsightConfidence: 'high',
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('parses architecture budget thresholds from project config', async () => {
|
|
121
|
+
const cfg = await loadArchoraConfig(
|
|
122
|
+
mockSource({
|
|
123
|
+
'.archora.json': JSON.stringify({
|
|
124
|
+
architectureBudget: {
|
|
125
|
+
maxDebtScore: 35,
|
|
126
|
+
maxCycles: 0,
|
|
127
|
+
maxCriticalSignals: 0,
|
|
128
|
+
maxContractErrors: 0,
|
|
129
|
+
maxHotspotGrowth: 2,
|
|
130
|
+
},
|
|
131
|
+
}),
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
expect(cfg.architectureBudget).toEqual({
|
|
136
|
+
maxDebtScore: 35,
|
|
137
|
+
maxCycles: 0,
|
|
138
|
+
maxCriticalSignals: 0,
|
|
139
|
+
maxContractErrors: 0,
|
|
140
|
+
maxHotspotGrowth: 2,
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('reports invalid JSON as a config diagnostic', async () => {
|
|
145
|
+
const result = await loadArchoraConfigWithDiagnostics(
|
|
146
|
+
mockSource({
|
|
147
|
+
'.archora.json': '{',
|
|
148
|
+
}),
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
expect(result.config).toEqual({});
|
|
152
|
+
expect(result.file).toBe('.archora.json');
|
|
153
|
+
expect(result.diagnostics[0]).toMatchObject({
|
|
154
|
+
file: '.archora.json',
|
|
155
|
+
path: '$',
|
|
156
|
+
severity: 'error',
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('reports absent rules config without diagnostics', async () => {
|
|
161
|
+
const result = await loadArchoraConfigWithDiagnostics(mockSource({}));
|
|
162
|
+
|
|
163
|
+
expect(result.config).toEqual({});
|
|
164
|
+
expect(result.file).toBeNull();
|
|
165
|
+
expect(result.diagnostics).toEqual([]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('expands presets in declaration order, user patterns first', () => {
|
|
169
|
+
const out = resolveGeneratedPatterns({
|
|
170
|
+
mode: 'classify',
|
|
171
|
+
patterns: ['src/recruit/openapi/**'],
|
|
172
|
+
presets: ['openapi', 'generated-folder'],
|
|
173
|
+
});
|
|
174
|
+
expect(out[0]).toBe('src/recruit/openapi/**');
|
|
175
|
+
for (const p of GENERATED_PRESETS.openapi) expect(out).toContain(p);
|
|
176
|
+
for (const p of GENERATED_PRESETS['generated-folder']) expect(out).toContain(p);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
function mockSource(files: Record<string, string>): FileSource {
|
|
181
|
+
return {
|
|
182
|
+
rootPath: '/repo',
|
|
183
|
+
list: async () => Object.keys(files),
|
|
184
|
+
read: async (p) => files[p] ?? '',
|
|
185
|
+
exists: async (p) => p in files,
|
|
186
|
+
};
|
|
187
|
+
}
|