@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,830 @@
|
|
|
1
|
+
import type { FileSource } from '../analyzer/fileSource';
|
|
2
|
+
import type { SignalConfidence, SignalSeverity, Suppression } from '../analyzer/types';
|
|
3
|
+
|
|
4
|
+
// .archora.json - per-project tuning, all fields optional
|
|
5
|
+
export interface ArchoraConfig {
|
|
6
|
+
entryPoints?: string[];
|
|
7
|
+
// user-defined dynamic loaders: e.g. { name: 'load', resolveAs: './mfes/{0}/index' }
|
|
8
|
+
dynamicLoaders?: DynamicLoaderConfig[];
|
|
9
|
+
ignore?: string[];
|
|
10
|
+
// glob -> layer
|
|
11
|
+
layerOverrides?: Record<string, string>;
|
|
12
|
+
// architectural contracts: boundaries, budgets, api-stability
|
|
13
|
+
contracts?: ContractsConfig;
|
|
14
|
+
// bundle-aware analysis thresholds. All fields optional.
|
|
15
|
+
bundle?: BundleSettings;
|
|
16
|
+
// Generated/OpenAPI policy. Build outputs are still skipped by discovery.
|
|
17
|
+
analysis?: AnalysisConfig;
|
|
18
|
+
// Signal trust layer: suppressions are persisted in project config.
|
|
19
|
+
signals?: SignalConfig;
|
|
20
|
+
// Project-level CI budget for architecture regressions.
|
|
21
|
+
architectureBudget?: ArchitectureBudgetConfig;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type ConfigDiagnosticSeverity = 'error' | 'warning';
|
|
25
|
+
|
|
26
|
+
export interface ConfigDiagnostic {
|
|
27
|
+
file: string;
|
|
28
|
+
path: string;
|
|
29
|
+
severity: ConfigDiagnosticSeverity;
|
|
30
|
+
message: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface LoadArchoraConfigResult {
|
|
34
|
+
config: ArchoraConfig;
|
|
35
|
+
diagnostics: ConfigDiagnostic[];
|
|
36
|
+
file: string | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface AnalysisConfig {
|
|
40
|
+
generated?: GeneratedPolicy;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface SignalConfig {
|
|
44
|
+
suppressions?: Suppression[];
|
|
45
|
+
insightLimit?: number;
|
|
46
|
+
minInsightSeverity?: SignalSeverity;
|
|
47
|
+
minInsightConfidence?: SignalConfidence;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ArchitectureBudgetConfig {
|
|
51
|
+
maxDebtScore?: number;
|
|
52
|
+
maxCycles?: number;
|
|
53
|
+
maxCriticalSignals?: number;
|
|
54
|
+
maxContractErrors?: number;
|
|
55
|
+
maxHotspotGrowth?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface GeneratedPolicy {
|
|
59
|
+
mode: 'exclude' | 'classify';
|
|
60
|
+
patterns?: string[];
|
|
61
|
+
presets?: GeneratedPreset[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type GeneratedPreset =
|
|
65
|
+
| 'openapi'
|
|
66
|
+
| 'generated-folder'
|
|
67
|
+
| 'generated-suffix'
|
|
68
|
+
| 'vendor'
|
|
69
|
+
| 'rtk-query';
|
|
70
|
+
|
|
71
|
+
export const GENERATED_PRESETS: Readonly<Record<GeneratedPreset, readonly string[]>> = {
|
|
72
|
+
openapi: ['**/openapi/**', '**/api-generated/**', '**/swagger/**'],
|
|
73
|
+
'generated-folder': ['**/__generated__/**', '**/generated/**'],
|
|
74
|
+
'generated-suffix': ['**/*.gen.ts', '**/*.generated.ts', '**/*.gen.js', '**/*.generated.js'],
|
|
75
|
+
vendor: ['**/vendor/**', '**/third_party/**'],
|
|
76
|
+
'rtk-query': ['**/*.api.ts', '**/queries-generated/**'],
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export function resolveGeneratedPatterns(policy: GeneratedPolicy | undefined): string[] {
|
|
80
|
+
if (!policy) return [];
|
|
81
|
+
const seen = new Set<string>();
|
|
82
|
+
const out: string[] = [];
|
|
83
|
+
for (const p of policy.patterns ?? []) {
|
|
84
|
+
if (typeof p !== 'string') continue;
|
|
85
|
+
const trimmed = p.trim();
|
|
86
|
+
if (!trimmed || seen.has(trimmed)) continue;
|
|
87
|
+
seen.add(trimmed);
|
|
88
|
+
out.push(trimmed);
|
|
89
|
+
}
|
|
90
|
+
for (const preset of policy.presets ?? []) {
|
|
91
|
+
const list = GENERATED_PRESETS[preset];
|
|
92
|
+
if (!list) continue;
|
|
93
|
+
for (const p of list) {
|
|
94
|
+
if (seen.has(p)) continue;
|
|
95
|
+
seen.add(p);
|
|
96
|
+
out.push(p);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface BundleSettings {
|
|
103
|
+
/** Chunk size in bytes that triggers `heavy-chunk`. */
|
|
104
|
+
heavyChunkBytes?: number;
|
|
105
|
+
/** Module appearing in >= N chunks triggers `duplicate`. */
|
|
106
|
+
duplicateMinChunks?: number;
|
|
107
|
+
/** Module taking >= share of a heavy chunk triggers `solo-hot` (0..1). */
|
|
108
|
+
soloHotShare?: number;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface DynamicLoaderConfig {
|
|
112
|
+
name: string;
|
|
113
|
+
argIndex?: number;
|
|
114
|
+
// template; {0} is replaced with the literal arg
|
|
115
|
+
resolveAs: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* "Architecture as code". The whole block is opt-in - omitting it (or any of
|
|
120
|
+
* its sub-fields) keeps the analyzer behaviour unchanged.
|
|
121
|
+
*/
|
|
122
|
+
export interface ContractsConfig {
|
|
123
|
+
boundaries?: BoundaryRule[];
|
|
124
|
+
budgets?: BudgetRule[];
|
|
125
|
+
/**
|
|
126
|
+
* "Frozen" public surfaces. The full breaking-change check lives behind
|
|
127
|
+
* `apiDiff`; for now we only parse the schema so users can declare intent.
|
|
128
|
+
*/
|
|
129
|
+
apiStability?: ApiStabilityRule[];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Forbid or whitelist edges crossing module boundaries. The DSL is two-sided:
|
|
134
|
+
*
|
|
135
|
+
* - `from`: whose outgoing edges this rule controls (glob).
|
|
136
|
+
* - `mode: 'must-not'` + `to`: from-modules may NOT import to-modules.
|
|
137
|
+
* - `mode: 'can-only'` + `to`: from-modules may import only to-modules
|
|
138
|
+
* (anything else is a violation).
|
|
139
|
+
* - `except`: globs (matched against the `to` module id) that whitelist
|
|
140
|
+
* specific exceptions, applied AFTER the rule predicate.
|
|
141
|
+
*
|
|
142
|
+
* Internal (same-module) imports never violate, because gigs would force users
|
|
143
|
+
* to add `except: ['features/auth/**']` for every features-internal rule.
|
|
144
|
+
*/
|
|
145
|
+
export interface BoundaryRule {
|
|
146
|
+
/** Stable identifier for diagnostics (CLI output, UI grouping). */
|
|
147
|
+
name: string;
|
|
148
|
+
/** Glob (gitignore-style) matching the importing module ids. */
|
|
149
|
+
from: string;
|
|
150
|
+
mode: 'must-not' | 'can-only';
|
|
151
|
+
/** Glob matching the imported module ids. */
|
|
152
|
+
to: string;
|
|
153
|
+
/** Optional whitelist (glob list), applied to the importee. */
|
|
154
|
+
except?: string[];
|
|
155
|
+
/**
|
|
156
|
+
* "Sibling isolation" knob. When `true` AND the rule is shaped like
|
|
157
|
+
* `features/*\/**` ↔ `features/*\/**`, the engine treats the wildcard
|
|
158
|
+
* segment as an instance identifier and only flags edges where the
|
|
159
|
+
* `from`-side and `to`-side instances differ. Internal-to-feature edges
|
|
160
|
+
* (`features/auth/index.ts → features/auth/lib/x.ts`) pass through.
|
|
161
|
+
*
|
|
162
|
+
* Defaults to `false`. Without it, `must-not` rules with the same `from`
|
|
163
|
+
* and `to` pattern would flood the report with intra-feature noise.
|
|
164
|
+
*/
|
|
165
|
+
crossInstance?: boolean;
|
|
166
|
+
/** Free-form description shown in UI tooltips and CLI failure output. */
|
|
167
|
+
description?: string;
|
|
168
|
+
severity?: 'error' | 'warning';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Numeric ceilings on per-module metrics. `module` is a glob; the rule trips
|
|
173
|
+
* for every matched module that exceeds the bound.
|
|
174
|
+
*/
|
|
175
|
+
export interface BudgetRule {
|
|
176
|
+
name: string;
|
|
177
|
+
/** Glob matching module ids. */
|
|
178
|
+
module: string;
|
|
179
|
+
/** Maximum allowed `fanIn` (incoming edges). */
|
|
180
|
+
maxFanIn?: number;
|
|
181
|
+
/** Maximum allowed `fanOut` (outgoing edges). */
|
|
182
|
+
maxFanOut?: number;
|
|
183
|
+
/** Maximum lines-of-code (`module.loc`). */
|
|
184
|
+
maxLoc?: number;
|
|
185
|
+
/**
|
|
186
|
+
* Maximum cycles touching any module under this glob. Useful to forbid
|
|
187
|
+
* cycles in `shared/ui/**` while allowing them elsewhere.
|
|
188
|
+
*/
|
|
189
|
+
maxCycles?: number;
|
|
190
|
+
description?: string;
|
|
191
|
+
severity?: 'error' | 'warning';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Marks a public surface as "frozen". 6.2 only stores the declaration -
|
|
196
|
+
* 6.4 (`semantic surface`) will compute the actual breaking-change diff.
|
|
197
|
+
* Including the schema now lets users codify intent without waiting.
|
|
198
|
+
*/
|
|
199
|
+
export interface ApiStabilityRule {
|
|
200
|
+
name: string;
|
|
201
|
+
/** Glob (or single path) matching the public-surface module ids. */
|
|
202
|
+
module: string;
|
|
203
|
+
policy: 'frozen' | 'additions-only';
|
|
204
|
+
description?: string;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const CONFIG_FILENAMES = ['.archora.json', 'archora.json'];
|
|
208
|
+
|
|
209
|
+
export async function loadArchoraConfig(source: FileSource): Promise<ArchoraConfig> {
|
|
210
|
+
return (await loadArchoraConfigWithDiagnostics(source)).config;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export async function loadArchoraConfigWithDiagnostics(
|
|
214
|
+
source: FileSource,
|
|
215
|
+
): Promise<LoadArchoraConfigResult> {
|
|
216
|
+
for (const candidate of CONFIG_FILENAMES) {
|
|
217
|
+
if (await source.exists(candidate)) {
|
|
218
|
+
try {
|
|
219
|
+
const raw = await source.read(candidate);
|
|
220
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
221
|
+
const diagnostics: ConfigDiagnostic[] = [];
|
|
222
|
+
const config = normalizeConfig(parsed, diagnostics, candidate);
|
|
223
|
+
return { config, diagnostics, file: candidate };
|
|
224
|
+
} catch (err) {
|
|
225
|
+
return {
|
|
226
|
+
config: {},
|
|
227
|
+
file: candidate,
|
|
228
|
+
diagnostics: [
|
|
229
|
+
{
|
|
230
|
+
file: candidate,
|
|
231
|
+
path: '$',
|
|
232
|
+
severity: 'error',
|
|
233
|
+
message: `Invalid JSON: ${err instanceof Error ? err.message : String(err)}`,
|
|
234
|
+
},
|
|
235
|
+
],
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return { config: {}, diagnostics: [], file: null };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function normalizeConfig(
|
|
244
|
+
raw: unknown,
|
|
245
|
+
diagnostics: ConfigDiagnostic[] = [],
|
|
246
|
+
file = '.archora.json',
|
|
247
|
+
): ArchoraConfig {
|
|
248
|
+
if (!raw || typeof raw !== 'object') {
|
|
249
|
+
diagnostics.push({
|
|
250
|
+
file,
|
|
251
|
+
path: '$',
|
|
252
|
+
severity: 'error',
|
|
253
|
+
message: 'Configuration root must be an object.',
|
|
254
|
+
});
|
|
255
|
+
return {};
|
|
256
|
+
}
|
|
257
|
+
const r = raw as Record<string, unknown>;
|
|
258
|
+
const out: ArchoraConfig = {};
|
|
259
|
+
for (const key of Object.keys(r)) {
|
|
260
|
+
if (
|
|
261
|
+
![
|
|
262
|
+
'entryPoints',
|
|
263
|
+
'dynamicLoaders',
|
|
264
|
+
'ignore',
|
|
265
|
+
'layerOverrides',
|
|
266
|
+
'contracts',
|
|
267
|
+
'bundle',
|
|
268
|
+
'analysis',
|
|
269
|
+
'signals',
|
|
270
|
+
'architectureBudget',
|
|
271
|
+
].includes(key)
|
|
272
|
+
) {
|
|
273
|
+
diagnostics.push({
|
|
274
|
+
file,
|
|
275
|
+
path: `$.${key}`,
|
|
276
|
+
severity: 'warning',
|
|
277
|
+
message: 'Unknown config field. It will be ignored.',
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (Array.isArray(r['entryPoints'])) {
|
|
282
|
+
out.entryPoints = r['entryPoints'].filter((x): x is string => typeof x === 'string');
|
|
283
|
+
} else if (r['entryPoints'] !== undefined) {
|
|
284
|
+
diagnostics.push({
|
|
285
|
+
file,
|
|
286
|
+
path: '$.entryPoints',
|
|
287
|
+
severity: 'warning',
|
|
288
|
+
message: 'Expected an array of module paths.',
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
if (Array.isArray(r['ignore'])) {
|
|
292
|
+
out.ignore = r['ignore'].filter((x): x is string => typeof x === 'string');
|
|
293
|
+
} else if (r['ignore'] !== undefined) {
|
|
294
|
+
diagnostics.push({
|
|
295
|
+
file,
|
|
296
|
+
path: '$.ignore',
|
|
297
|
+
severity: 'warning',
|
|
298
|
+
message: 'Expected an array of glob patterns.',
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
if (r['layerOverrides'] && typeof r['layerOverrides'] === 'object') {
|
|
302
|
+
const overrides: Record<string, string> = {};
|
|
303
|
+
for (const [k, v] of Object.entries(r['layerOverrides'] as Record<string, unknown>)) {
|
|
304
|
+
if (typeof v === 'string') overrides[k] = v;
|
|
305
|
+
}
|
|
306
|
+
out.layerOverrides = overrides;
|
|
307
|
+
} else if (r['layerOverrides'] !== undefined) {
|
|
308
|
+
diagnostics.push({
|
|
309
|
+
file,
|
|
310
|
+
path: '$.layerOverrides',
|
|
311
|
+
severity: 'warning',
|
|
312
|
+
message: 'Expected an object of glob-to-layer mappings.',
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
if (Array.isArray(r['dynamicLoaders'])) {
|
|
316
|
+
const loaders: DynamicLoaderConfig[] = [];
|
|
317
|
+
for (const [index, d] of (r['dynamicLoaders'] as unknown[]).entries()) {
|
|
318
|
+
if (!d || typeof d !== 'object') {
|
|
319
|
+
diagnostics.push({
|
|
320
|
+
file,
|
|
321
|
+
path: `$.dynamicLoaders[${index}]`,
|
|
322
|
+
severity: 'warning',
|
|
323
|
+
message: 'Expected an object with name and resolveAs.',
|
|
324
|
+
});
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
const dr = d as Record<string, unknown>;
|
|
328
|
+
const name = typeof dr['name'] === 'string' ? dr['name'] : null;
|
|
329
|
+
const resolveAs = typeof dr['resolveAs'] === 'string' ? dr['resolveAs'] : null;
|
|
330
|
+
if (!name || !resolveAs) {
|
|
331
|
+
diagnostics.push({
|
|
332
|
+
file,
|
|
333
|
+
path: `$.dynamicLoaders[${index}]`,
|
|
334
|
+
severity: 'warning',
|
|
335
|
+
message: 'Dynamic loader requires string name and resolveAs.',
|
|
336
|
+
});
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
const argIndex = typeof dr['argIndex'] === 'number' ? dr['argIndex'] : 0;
|
|
340
|
+
loaders.push({ name, resolveAs, argIndex });
|
|
341
|
+
}
|
|
342
|
+
if (loaders.length > 0) out.dynamicLoaders = loaders;
|
|
343
|
+
} else if (r['dynamicLoaders'] !== undefined) {
|
|
344
|
+
diagnostics.push({
|
|
345
|
+
file,
|
|
346
|
+
path: '$.dynamicLoaders',
|
|
347
|
+
severity: 'warning',
|
|
348
|
+
message: 'Expected an array of dynamic loader rules.',
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
if (r['contracts'] && typeof r['contracts'] === 'object') {
|
|
352
|
+
const contracts = normalizeContracts(
|
|
353
|
+
r['contracts'] as Record<string, unknown>,
|
|
354
|
+
diagnostics,
|
|
355
|
+
file,
|
|
356
|
+
'$.contracts',
|
|
357
|
+
);
|
|
358
|
+
if (contracts) out.contracts = contracts;
|
|
359
|
+
} else if (r['contracts'] !== undefined) {
|
|
360
|
+
diagnostics.push({
|
|
361
|
+
file,
|
|
362
|
+
path: '$.contracts',
|
|
363
|
+
severity: 'warning',
|
|
364
|
+
message: 'Expected an object with boundaries, budgets or apiStability arrays.',
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
if (r['bundle'] && typeof r['bundle'] === 'object') {
|
|
368
|
+
const bundle = normalizeBundle(r['bundle'] as Record<string, unknown>, diagnostics, file);
|
|
369
|
+
if (bundle) out.bundle = bundle;
|
|
370
|
+
} else if (r['bundle'] !== undefined) {
|
|
371
|
+
diagnostics.push({
|
|
372
|
+
file,
|
|
373
|
+
path: '$.bundle',
|
|
374
|
+
severity: 'warning',
|
|
375
|
+
message: 'Expected an object with bundle thresholds.',
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
if (r['analysis'] && typeof r['analysis'] === 'object') {
|
|
379
|
+
const analysis = normalizeAnalysis(r['analysis'] as Record<string, unknown>, diagnostics, file);
|
|
380
|
+
if (analysis) out.analysis = analysis;
|
|
381
|
+
} else if (r['analysis'] !== undefined) {
|
|
382
|
+
diagnostics.push({
|
|
383
|
+
file,
|
|
384
|
+
path: '$.analysis',
|
|
385
|
+
severity: 'warning',
|
|
386
|
+
message: 'Expected an object with analysis options.',
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
if (r['signals'] && typeof r['signals'] === 'object') {
|
|
390
|
+
const signals = normalizeSignals(r['signals'] as Record<string, unknown>, diagnostics, file);
|
|
391
|
+
if (signals) out.signals = signals;
|
|
392
|
+
} else if (r['signals'] !== undefined) {
|
|
393
|
+
diagnostics.push({
|
|
394
|
+
file,
|
|
395
|
+
path: '$.signals',
|
|
396
|
+
severity: 'warning',
|
|
397
|
+
message: 'Expected an object with signal suppressions.',
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
if (r['architectureBudget'] && typeof r['architectureBudget'] === 'object') {
|
|
401
|
+
const budget = normalizeArchitectureBudget(
|
|
402
|
+
r['architectureBudget'] as Record<string, unknown>,
|
|
403
|
+
diagnostics,
|
|
404
|
+
file,
|
|
405
|
+
);
|
|
406
|
+
if (budget) out.architectureBudget = budget;
|
|
407
|
+
} else if (r['architectureBudget'] !== undefined) {
|
|
408
|
+
diagnostics.push({
|
|
409
|
+
file,
|
|
410
|
+
path: '$.architectureBudget',
|
|
411
|
+
severity: 'warning',
|
|
412
|
+
message: 'Expected an object with architecture budget thresholds.',
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
return out;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function normalizeArchitectureBudget(
|
|
419
|
+
raw: Record<string, unknown>,
|
|
420
|
+
diagnostics: ConfigDiagnostic[],
|
|
421
|
+
file: string,
|
|
422
|
+
): ArchitectureBudgetConfig | null {
|
|
423
|
+
const out: ArchitectureBudgetConfig = {};
|
|
424
|
+
const fields: Array<keyof ArchitectureBudgetConfig> = [
|
|
425
|
+
'maxDebtScore',
|
|
426
|
+
'maxCycles',
|
|
427
|
+
'maxCriticalSignals',
|
|
428
|
+
'maxContractErrors',
|
|
429
|
+
'maxHotspotGrowth',
|
|
430
|
+
];
|
|
431
|
+
for (const field of fields) {
|
|
432
|
+
const value = raw[field];
|
|
433
|
+
if (value === undefined) continue;
|
|
434
|
+
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
|
|
435
|
+
out[field] = value;
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
diagnostics.push({
|
|
439
|
+
file,
|
|
440
|
+
path: `$.architectureBudget.${field}`,
|
|
441
|
+
severity: 'warning',
|
|
442
|
+
message: 'Expected a non-negative number.',
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function normalizeSignals(
|
|
449
|
+
raw: Record<string, unknown>,
|
|
450
|
+
diagnostics: ConfigDiagnostic[],
|
|
451
|
+
file: string,
|
|
452
|
+
): SignalConfig | null {
|
|
453
|
+
const out: SignalConfig = {};
|
|
454
|
+
if (typeof raw['insightLimit'] === 'number' && Number.isInteger(raw['insightLimit'])) {
|
|
455
|
+
if (raw['insightLimit'] >= 0) out.insightLimit = raw['insightLimit'];
|
|
456
|
+
} else if (raw['insightLimit'] !== undefined) {
|
|
457
|
+
diagnostics.push({
|
|
458
|
+
file,
|
|
459
|
+
path: '$.signals.insightLimit',
|
|
460
|
+
severity: 'warning',
|
|
461
|
+
message: 'Expected a non-negative integer insight limit.',
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
if (isSignalSeverity(raw['minInsightSeverity'])) {
|
|
465
|
+
out.minInsightSeverity = raw['minInsightSeverity'];
|
|
466
|
+
} else if (raw['minInsightSeverity'] !== undefined) {
|
|
467
|
+
diagnostics.push({
|
|
468
|
+
file,
|
|
469
|
+
path: '$.signals.minInsightSeverity',
|
|
470
|
+
severity: 'warning',
|
|
471
|
+
message: 'Expected info, low, medium, high or critical.',
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
if (isSignalConfidence(raw['minInsightConfidence'])) {
|
|
475
|
+
out.minInsightConfidence = raw['minInsightConfidence'];
|
|
476
|
+
} else if (raw['minInsightConfidence'] !== undefined) {
|
|
477
|
+
diagnostics.push({
|
|
478
|
+
file,
|
|
479
|
+
path: '$.signals.minInsightConfidence',
|
|
480
|
+
severity: 'warning',
|
|
481
|
+
message: 'Expected low, medium or high.',
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
if (Array.isArray(raw['suppressions'])) {
|
|
485
|
+
const suppressions: Suppression[] = [];
|
|
486
|
+
for (const [index, item] of (raw['suppressions'] as unknown[]).entries()) {
|
|
487
|
+
const suppression = normalizeSuppression(item);
|
|
488
|
+
if (suppression) {
|
|
489
|
+
suppressions.push(suppression);
|
|
490
|
+
} else {
|
|
491
|
+
diagnostics.push({
|
|
492
|
+
file,
|
|
493
|
+
path: `$.signals.suppressions[${index}]`,
|
|
494
|
+
severity: 'warning',
|
|
495
|
+
message: 'Signal suppression requires stableKey and reason.',
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
if (suppressions.length > 0) out.suppressions = suppressions;
|
|
500
|
+
} else if (raw['suppressions'] !== undefined) {
|
|
501
|
+
diagnostics.push({
|
|
502
|
+
file,
|
|
503
|
+
path: '$.signals.suppressions',
|
|
504
|
+
severity: 'warning',
|
|
505
|
+
message: 'Expected an array of signal suppressions.',
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
return out.suppressions ||
|
|
509
|
+
out.insightLimit !== undefined ||
|
|
510
|
+
out.minInsightSeverity ||
|
|
511
|
+
out.minInsightConfidence
|
|
512
|
+
? out
|
|
513
|
+
: null;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function isSignalSeverity(value: unknown): value is SignalSeverity {
|
|
517
|
+
return (
|
|
518
|
+
value === 'info' ||
|
|
519
|
+
value === 'low' ||
|
|
520
|
+
value === 'medium' ||
|
|
521
|
+
value === 'high' ||
|
|
522
|
+
value === 'critical'
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function isSignalConfidence(value: unknown): value is SignalConfidence {
|
|
527
|
+
return value === 'low' || value === 'medium' || value === 'high';
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function normalizeSuppression(raw: unknown): Suppression | null {
|
|
531
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
532
|
+
const r = raw as Record<string, unknown>;
|
|
533
|
+
const stableKey = typeof r['stableKey'] === 'string' ? r['stableKey'].trim() : '';
|
|
534
|
+
const reason = typeof r['reason'] === 'string' ? r['reason'].trim() : '';
|
|
535
|
+
if (!stableKey || !reason) return null;
|
|
536
|
+
const out: Suppression = { stableKey, reason };
|
|
537
|
+
if (r['scope'] === 'project' || r['scope'] === 'module' || r['scope'] === 'rule') {
|
|
538
|
+
out.scope = r['scope'];
|
|
539
|
+
}
|
|
540
|
+
if (typeof r['moduleId'] === 'string' && r['moduleId'].trim()) {
|
|
541
|
+
out.moduleId = r['moduleId'].trim();
|
|
542
|
+
}
|
|
543
|
+
if (typeof r['createdAt'] === 'string' && r['createdAt'].trim()) {
|
|
544
|
+
out.createdAt = r['createdAt'].trim();
|
|
545
|
+
}
|
|
546
|
+
if (typeof r['expiresAt'] === 'string' && r['expiresAt'].trim()) {
|
|
547
|
+
out.expiresAt = r['expiresAt'].trim();
|
|
548
|
+
}
|
|
549
|
+
return out;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function normalizeAnalysis(
|
|
553
|
+
raw: Record<string, unknown>,
|
|
554
|
+
diagnostics: ConfigDiagnostic[],
|
|
555
|
+
file: string,
|
|
556
|
+
): AnalysisConfig | null {
|
|
557
|
+
const out: AnalysisConfig = {};
|
|
558
|
+
if (raw['generated'] && typeof raw['generated'] === 'object') {
|
|
559
|
+
const policy = normalizeGenerated(
|
|
560
|
+
raw['generated'] as Record<string, unknown>,
|
|
561
|
+
diagnostics,
|
|
562
|
+
file,
|
|
563
|
+
'$.analysis.generated',
|
|
564
|
+
);
|
|
565
|
+
if (policy) out.generated = policy;
|
|
566
|
+
} else if (raw['generated'] !== undefined) {
|
|
567
|
+
diagnostics.push({
|
|
568
|
+
file,
|
|
569
|
+
path: '$.analysis.generated',
|
|
570
|
+
severity: 'warning',
|
|
571
|
+
message: 'Expected an object with mode and patterns or presets.',
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
return out.generated ? out : null;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const VALID_PRESETS: ReadonlySet<GeneratedPreset> = new Set([
|
|
578
|
+
'openapi',
|
|
579
|
+
'generated-folder',
|
|
580
|
+
'generated-suffix',
|
|
581
|
+
'vendor',
|
|
582
|
+
'rtk-query',
|
|
583
|
+
]);
|
|
584
|
+
|
|
585
|
+
function normalizeGenerated(
|
|
586
|
+
raw: Record<string, unknown>,
|
|
587
|
+
diagnostics: ConfigDiagnostic[],
|
|
588
|
+
file: string,
|
|
589
|
+
path: string,
|
|
590
|
+
): GeneratedPolicy | null {
|
|
591
|
+
// Keep the first Settings snippet key working for copied configs.
|
|
592
|
+
const rawMode = raw['mode'];
|
|
593
|
+
let mode: GeneratedPolicy['mode'];
|
|
594
|
+
if (rawMode === 'exclude') mode = 'exclude';
|
|
595
|
+
else if (rawMode === 'classify' || rawMode === 'classifyAsGenerated') mode = 'classify';
|
|
596
|
+
else {
|
|
597
|
+
diagnostics.push({
|
|
598
|
+
file,
|
|
599
|
+
path: `${path}.mode`,
|
|
600
|
+
severity: 'warning',
|
|
601
|
+
message: 'Expected "exclude" or "classify".',
|
|
602
|
+
});
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const patterns = Array.isArray(raw['patterns'])
|
|
607
|
+
? (raw['patterns'] as unknown[]).filter(
|
|
608
|
+
(x): x is string => typeof x === 'string' && x.trim().length > 0,
|
|
609
|
+
)
|
|
610
|
+
: Array.isArray(raw['paths'])
|
|
611
|
+
? (raw['paths'] as unknown[]).filter(
|
|
612
|
+
(x): x is string => typeof x === 'string' && x.trim().length > 0,
|
|
613
|
+
)
|
|
614
|
+
: [];
|
|
615
|
+
|
|
616
|
+
const presets = Array.isArray(raw['presets'])
|
|
617
|
+
? (raw['presets'] as unknown[]).filter((x): x is GeneratedPreset =>
|
|
618
|
+
VALID_PRESETS.has(x as GeneratedPreset),
|
|
619
|
+
)
|
|
620
|
+
: [];
|
|
621
|
+
if (Array.isArray(raw['presets'])) {
|
|
622
|
+
for (const [index, preset] of (raw['presets'] as unknown[]).entries()) {
|
|
623
|
+
if (!VALID_PRESETS.has(preset as GeneratedPreset)) {
|
|
624
|
+
diagnostics.push({
|
|
625
|
+
file,
|
|
626
|
+
path: `${path}.presets[${index}]`,
|
|
627
|
+
severity: 'warning',
|
|
628
|
+
message: 'Unknown generated preset. It will be ignored.',
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (patterns.length === 0 && presets.length === 0) {
|
|
635
|
+
diagnostics.push({
|
|
636
|
+
file,
|
|
637
|
+
path,
|
|
638
|
+
severity: 'warning',
|
|
639
|
+
message: 'Generated policy needs at least one pattern or preset.',
|
|
640
|
+
});
|
|
641
|
+
return null;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const out: GeneratedPolicy = { mode };
|
|
645
|
+
if (patterns.length > 0) out.patterns = patterns;
|
|
646
|
+
if (presets.length > 0) out.presets = presets;
|
|
647
|
+
return out;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function normalizeBundle(
|
|
651
|
+
raw: Record<string, unknown>,
|
|
652
|
+
diagnostics: ConfigDiagnostic[],
|
|
653
|
+
file: string,
|
|
654
|
+
): BundleSettings | null {
|
|
655
|
+
const out: BundleSettings = {};
|
|
656
|
+
if (typeof raw['heavyChunkBytes'] === 'number' && raw['heavyChunkBytes'] >= 0) {
|
|
657
|
+
out.heavyChunkBytes = raw['heavyChunkBytes'];
|
|
658
|
+
}
|
|
659
|
+
if (typeof raw['duplicateMinChunks'] === 'number' && raw['duplicateMinChunks'] >= 2) {
|
|
660
|
+
out.duplicateMinChunks = raw['duplicateMinChunks'];
|
|
661
|
+
}
|
|
662
|
+
if (
|
|
663
|
+
typeof raw['soloHotShare'] === 'number' &&
|
|
664
|
+
raw['soloHotShare'] > 0 &&
|
|
665
|
+
raw['soloHotShare'] <= 1
|
|
666
|
+
) {
|
|
667
|
+
out.soloHotShare = raw['soloHotShare'];
|
|
668
|
+
}
|
|
669
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
670
|
+
if (!['heavyChunkBytes', 'duplicateMinChunks', 'soloHotShare'].includes(key)) continue;
|
|
671
|
+
if ((out as Record<string, unknown>)[key] !== undefined) continue;
|
|
672
|
+
diagnostics.push({
|
|
673
|
+
file,
|
|
674
|
+
path: `$.bundle.${key}`,
|
|
675
|
+
severity: 'warning',
|
|
676
|
+
message: `Invalid bundle threshold value: ${String(value)}.`,
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function normalizeContracts(
|
|
683
|
+
raw: Record<string, unknown>,
|
|
684
|
+
diagnostics: ConfigDiagnostic[],
|
|
685
|
+
file: string,
|
|
686
|
+
path: string,
|
|
687
|
+
): ContractsConfig | null {
|
|
688
|
+
const out: ContractsConfig = {};
|
|
689
|
+
if (Array.isArray(raw['boundaries'])) {
|
|
690
|
+
const rules: BoundaryRule[] = [];
|
|
691
|
+
for (const [index, r] of (raw['boundaries'] as unknown[]).entries()) {
|
|
692
|
+
const rule = normalizeBoundary(r);
|
|
693
|
+
if (rule) {
|
|
694
|
+
rules.push(rule);
|
|
695
|
+
} else {
|
|
696
|
+
diagnostics.push({
|
|
697
|
+
file,
|
|
698
|
+
path: `${path}.boundaries[${index}]`,
|
|
699
|
+
severity: 'warning',
|
|
700
|
+
message: 'Boundary rule requires name, from, to and mode.',
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
if (rules.length > 0) out.boundaries = rules;
|
|
705
|
+
} else if (raw['boundaries'] !== undefined) {
|
|
706
|
+
diagnostics.push({
|
|
707
|
+
file,
|
|
708
|
+
path: `${path}.boundaries`,
|
|
709
|
+
severity: 'warning',
|
|
710
|
+
message: 'Expected an array of boundary rules.',
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
if (Array.isArray(raw['budgets'])) {
|
|
714
|
+
const rules: BudgetRule[] = [];
|
|
715
|
+
for (const [index, r] of (raw['budgets'] as unknown[]).entries()) {
|
|
716
|
+
const rule = normalizeBudget(r);
|
|
717
|
+
if (rule) {
|
|
718
|
+
rules.push(rule);
|
|
719
|
+
} else {
|
|
720
|
+
diagnostics.push({
|
|
721
|
+
file,
|
|
722
|
+
path: `${path}.budgets[${index}]`,
|
|
723
|
+
severity: 'warning',
|
|
724
|
+
message: 'Budget rule requires name, module and at least one numeric ceiling.',
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
if (rules.length > 0) out.budgets = rules;
|
|
729
|
+
} else if (raw['budgets'] !== undefined) {
|
|
730
|
+
diagnostics.push({
|
|
731
|
+
file,
|
|
732
|
+
path: `${path}.budgets`,
|
|
733
|
+
severity: 'warning',
|
|
734
|
+
message: 'Expected an array of budget rules.',
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
if (Array.isArray(raw['apiStability'])) {
|
|
738
|
+
const rules: ApiStabilityRule[] = [];
|
|
739
|
+
for (const [index, r] of (raw['apiStability'] as unknown[]).entries()) {
|
|
740
|
+
const rule = normalizeApiStability(r);
|
|
741
|
+
if (rule) {
|
|
742
|
+
rules.push(rule);
|
|
743
|
+
} else {
|
|
744
|
+
diagnostics.push({
|
|
745
|
+
file,
|
|
746
|
+
path: `${path}.apiStability[${index}]`,
|
|
747
|
+
severity: 'warning',
|
|
748
|
+
message: 'API stability rule requires name, module and policy.',
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
if (rules.length > 0) out.apiStability = rules;
|
|
753
|
+
} else if (raw['apiStability'] !== undefined) {
|
|
754
|
+
diagnostics.push({
|
|
755
|
+
file,
|
|
756
|
+
path: `${path}.apiStability`,
|
|
757
|
+
severity: 'warning',
|
|
758
|
+
message: 'Expected an array of API stability rules.',
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
return out.boundaries || out.budgets || out.apiStability ? out : null;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function normalizeBoundary(raw: unknown): BoundaryRule | null {
|
|
765
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
766
|
+
const r = raw as Record<string, unknown>;
|
|
767
|
+
const name = typeof r['name'] === 'string' ? r['name'] : null;
|
|
768
|
+
const from = typeof r['from'] === 'string' ? r['from'] : null;
|
|
769
|
+
const to = typeof r['to'] === 'string' ? r['to'] : null;
|
|
770
|
+
const mode = r['mode'] === 'must-not' || r['mode'] === 'can-only' ? r['mode'] : null;
|
|
771
|
+
if (!name || !from || !to || !mode) return null;
|
|
772
|
+
const out: BoundaryRule = { name, from, to, mode };
|
|
773
|
+
if (Array.isArray(r['except'])) {
|
|
774
|
+
out.except = (r['except'] as unknown[]).filter((x): x is string => typeof x === 'string');
|
|
775
|
+
}
|
|
776
|
+
if (r['crossInstance'] === true) out.crossInstance = true;
|
|
777
|
+
if (typeof r['description'] === 'string') out.description = r['description'];
|
|
778
|
+
if (r['severity'] === 'error' || r['severity'] === 'warning') out.severity = r['severity'];
|
|
779
|
+
return out;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function normalizeBudget(raw: unknown): BudgetRule | null {
|
|
783
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
784
|
+
const r = raw as Record<string, unknown>;
|
|
785
|
+
const name = typeof r['name'] === 'string' ? r['name'] : null;
|
|
786
|
+
const module_ = typeof r['module'] === 'string' ? r['module'] : null;
|
|
787
|
+
if (!name || !module_) return null;
|
|
788
|
+
const out: BudgetRule = { name, module: module_ };
|
|
789
|
+
// Camel and kebab forms accepted - the YAML example in PLAN uses kebab.
|
|
790
|
+
const maxFanIn = numericField(r, ['maxFanIn', 'max-fan-in']);
|
|
791
|
+
if (maxFanIn !== null) out.maxFanIn = maxFanIn;
|
|
792
|
+
const maxFanOut = numericField(r, ['maxFanOut', 'max-fan-out']);
|
|
793
|
+
if (maxFanOut !== null) out.maxFanOut = maxFanOut;
|
|
794
|
+
const maxLoc = numericField(r, ['maxLoc', 'max-loc']);
|
|
795
|
+
if (maxLoc !== null) out.maxLoc = maxLoc;
|
|
796
|
+
const maxCycles = numericField(r, ['maxCycles', 'max-cycles']);
|
|
797
|
+
if (maxCycles !== null) out.maxCycles = maxCycles;
|
|
798
|
+
if (typeof r['description'] === 'string') out.description = r['description'];
|
|
799
|
+
if (r['severity'] === 'error' || r['severity'] === 'warning') out.severity = r['severity'];
|
|
800
|
+
// A budget with no numeric ceiling is meaningless; drop it.
|
|
801
|
+
if (
|
|
802
|
+
out.maxFanIn === undefined &&
|
|
803
|
+
out.maxFanOut === undefined &&
|
|
804
|
+
out.maxLoc === undefined &&
|
|
805
|
+
out.maxCycles === undefined
|
|
806
|
+
) {
|
|
807
|
+
return null;
|
|
808
|
+
}
|
|
809
|
+
return out;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function normalizeApiStability(raw: unknown): ApiStabilityRule | null {
|
|
813
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
814
|
+
const r = raw as Record<string, unknown>;
|
|
815
|
+
const name = typeof r['name'] === 'string' ? r['name'] : null;
|
|
816
|
+
const module_ = typeof r['module'] === 'string' ? r['module'] : null;
|
|
817
|
+
const policy = r['policy'] === 'frozen' || r['policy'] === 'additions-only' ? r['policy'] : null;
|
|
818
|
+
if (!name || !module_ || !policy) return null;
|
|
819
|
+
const out: ApiStabilityRule = { name, module: module_, policy };
|
|
820
|
+
if (typeof r['description'] === 'string') out.description = r['description'];
|
|
821
|
+
return out;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function numericField(r: Record<string, unknown>, keys: string[]): number | null {
|
|
825
|
+
for (const k of keys) {
|
|
826
|
+
const v = r[k];
|
|
827
|
+
if (typeof v === 'number' && Number.isFinite(v) && v >= 0) return v;
|
|
828
|
+
}
|
|
829
|
+
return null;
|
|
830
|
+
}
|