@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.
Files changed (112) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +62 -0
  3. package/package.json +36 -0
  4. package/src/README.md +4 -0
  5. package/src/analyzer/__tests__/__snapshots__/referenceSnapshot.test.ts.snap +145 -0
  6. package/src/analyzer/__tests__/_paths.ts +8 -0
  7. package/src/analyzer/__tests__/analyze.test.ts +522 -0
  8. package/src/analyzer/__tests__/archDebt.test.ts +111 -0
  9. package/src/analyzer/__tests__/asyncLifecycleRisk.test.ts +122 -0
  10. package/src/analyzer/__tests__/browserFsAccessFileSource.test.ts +97 -0
  11. package/src/analyzer/__tests__/bundle.test.ts +191 -0
  12. package/src/analyzer/__tests__/classify.test.ts +99 -0
  13. package/src/analyzer/__tests__/contracts.test.ts +372 -0
  14. package/src/analyzer/__tests__/crossSourceConsistency.test.ts +317 -0
  15. package/src/analyzer/__tests__/cyclePatterns.test.ts +132 -0
  16. package/src/analyzer/__tests__/cycles.test.ts +74 -0
  17. package/src/analyzer/__tests__/detect.test.ts +62 -0
  18. package/src/analyzer/__tests__/discover.test.ts +68 -0
  19. package/src/analyzer/__tests__/displayId.test.ts +30 -0
  20. package/src/analyzer/__tests__/feedbackArcSet.test.ts +168 -0
  21. package/src/analyzer/__tests__/inMemoryFileSource.test.ts +34 -0
  22. package/src/analyzer/__tests__/incremental.test.ts +154 -0
  23. package/src/analyzer/__tests__/layers.test.ts +87 -0
  24. package/src/analyzer/__tests__/layersOverrides.test.ts +120 -0
  25. package/src/analyzer/__tests__/memoryRisk.test.ts +132 -0
  26. package/src/analyzer/__tests__/metrics.test.ts +59 -0
  27. package/src/analyzer/__tests__/parserRegistry.test.ts +54 -0
  28. package/src/analyzer/__tests__/parsers.test.ts +187 -0
  29. package/src/analyzer/__tests__/reactParser.test.ts +93 -0
  30. package/src/analyzer/__tests__/recommendations.test.ts +171 -0
  31. package/src/analyzer/__tests__/referenceSnapshot.test.ts +63 -0
  32. package/src/analyzer/__tests__/resolve.test.ts +294 -0
  33. package/src/analyzer/__tests__/rsc.test.ts +130 -0
  34. package/src/analyzer/__tests__/signals.test.ts +316 -0
  35. package/src/analyzer/__tests__/suggestContracts.test.ts +108 -0
  36. package/src/analyzer/__tests__/svelteParser.test.ts +108 -0
  37. package/src/analyzer/__tests__/typeOnlyCandidates.test.ts +163 -0
  38. package/src/analyzer/__tests__/vueAutoImport.test.ts +177 -0
  39. package/src/analyzer/archDebt.ts +68 -0
  40. package/src/analyzer/asyncLifecycleRisk.ts +234 -0
  41. package/src/analyzer/buildGraph.ts +683 -0
  42. package/src/analyzer/bundle/analyzeBundle.ts +147 -0
  43. package/src/analyzer/bundle/index.ts +12 -0
  44. package/src/analyzer/bundle/parseStats.ts +152 -0
  45. package/src/analyzer/bundle/types.ts +85 -0
  46. package/src/analyzer/classify.ts +54 -0
  47. package/src/analyzer/contracts.ts +265 -0
  48. package/src/analyzer/cyclePatterns.ts +138 -0
  49. package/src/analyzer/cycles.ts +98 -0
  50. package/src/analyzer/detect.ts +34 -0
  51. package/src/analyzer/discover.ts +131 -0
  52. package/src/analyzer/displayId.ts +21 -0
  53. package/src/analyzer/entryPoints.ts +136 -0
  54. package/src/analyzer/feedbackArcSet.ts +332 -0
  55. package/src/analyzer/fileSource.ts +8 -0
  56. package/src/analyzer/hotZones.ts +17 -0
  57. package/src/analyzer/incremental.ts +455 -0
  58. package/src/analyzer/index.ts +444 -0
  59. package/src/analyzer/layers.ts +183 -0
  60. package/src/analyzer/loadAliases.ts +288 -0
  61. package/src/analyzer/memoryRisk.ts +345 -0
  62. package/src/analyzer/metrics.ts +156 -0
  63. package/src/analyzer/parsers/index.ts +62 -0
  64. package/src/analyzer/parsers/reactParser.ts +24 -0
  65. package/src/analyzer/parsers/svelteParser.ts +46 -0
  66. package/src/analyzer/parsers/tsParser.ts +364 -0
  67. package/src/analyzer/parsers/vueParser.ts +109 -0
  68. package/src/analyzer/recommendations.ts +432 -0
  69. package/src/analyzer/resolve.ts +315 -0
  70. package/src/analyzer/rsc.ts +120 -0
  71. package/src/analyzer/signals.ts +684 -0
  72. package/src/analyzer/sources/browserFsAccessFileSource.ts +132 -0
  73. package/src/analyzer/sources/inMemoryFileSource.ts +24 -0
  74. package/src/analyzer/sources/nodeFsFileSource.ts +93 -0
  75. package/src/analyzer/sources/tauriFileSource.ts +68 -0
  76. package/src/analyzer/suggestContracts.ts +214 -0
  77. package/src/analyzer/typeOnlyCandidates.ts +233 -0
  78. package/src/analyzer/types.ts +537 -0
  79. package/src/cache/__tests__/cache.test.ts +316 -0
  80. package/src/cache/index.ts +432 -0
  81. package/src/codegen/__tests__/applyTypeOnlyFix.integration.test.ts +62 -0
  82. package/src/codegen/__tests__/applyTypeOnlyFix.test.ts +176 -0
  83. package/src/codegen/__tests__/configSnippets.test.ts +230 -0
  84. package/src/codegen/applyTypeOnlyFix.ts +344 -0
  85. package/src/codegen/configSnippets.ts +172 -0
  86. package/src/codegen/initConfig.ts +223 -0
  87. package/src/config/__tests__/frontScopeConfig.test.ts +187 -0
  88. package/src/config/frontScopeConfig.ts +830 -0
  89. package/src/diff/__tests__/diffScans.test.ts +103 -0
  90. package/src/diff/diffScans.ts +61 -0
  91. package/src/diff/index.ts +2 -0
  92. package/src/diff/types.ts +39 -0
  93. package/src/git/__tests__/computeChurn.test.ts +113 -0
  94. package/src/git/__tests__/computeTemporalCoupling.test.ts +125 -0
  95. package/src/git/__tests__/parseGitLog.test.ts +120 -0
  96. package/src/git/computeChurn.ts +111 -0
  97. package/src/git/computeTemporalCoupling.ts +114 -0
  98. package/src/git/index.ts +24 -0
  99. package/src/git/parseGitLog.ts +124 -0
  100. package/src/git/readGitHistory.ts +130 -0
  101. package/src/git/types.ts +119 -0
  102. package/src/index.ts +137 -0
  103. package/src/report/__tests__/buildFixPlan.test.ts +357 -0
  104. package/src/report/__tests__/buildJsonReport.test.ts +34 -0
  105. package/src/report/buildFixPlan.ts +481 -0
  106. package/src/report/buildJsonReport.ts +27 -0
  107. package/src/search/__tests__/parseQuery.test.ts +67 -0
  108. package/src/search/__tests__/search.test.ts +172 -0
  109. package/src/search/index.ts +281 -0
  110. package/src/search/parseQuery.ts +75 -0
  111. package/src/views/__tests__/analyzerViews.test.ts +558 -0
  112. package/src/views/analyzerViews.ts +1294 -0
@@ -0,0 +1,132 @@
1
+ import ignore, { type Ignore } from 'ignore';
2
+ import type { FileSource } from '../fileSource';
3
+ import { ALWAYS_SKIP_DIRS, TEST_LIKE_DIRS } from '../discover';
4
+
5
+ const SUPPORTED_EXT = new Set(['.ts', '.tsx', '.js', '.jsx', '.vue', '.svelte', '.mjs', '.cjs']);
6
+
7
+ export interface BrowserFsAccessFileSourceOptions {
8
+ rootHandle: FileSystemDirectoryHandle;
9
+ rootName?: string;
10
+ onProgress?: (visited: number) => void;
11
+ /** Если true (по умолчанию) - не заходим в тест/storybook/cypress/playwright директории. */
12
+ skipTestLikeDirs?: boolean;
13
+ /** Read `.gitignore` from root and filter the listing. Default: true. */
14
+ respectGitignore?: boolean;
15
+ }
16
+
17
+ async function loadRootGitignore(root: FileSystemDirectoryHandle): Promise<Ignore> {
18
+ const ig = ignore();
19
+ try {
20
+ const handle = await root.getFileHandle('.gitignore');
21
+ ig.add(await (await handle.getFile()).text());
22
+ } catch {
23
+ // No .gitignore at root.
24
+ }
25
+ return ig;
26
+ }
27
+
28
+ export async function createBrowserFsAccessFileSource(
29
+ options: BrowserFsAccessFileSourceOptions,
30
+ ): Promise<FileSource> {
31
+ const {
32
+ rootHandle,
33
+ rootName = rootHandle.name,
34
+ onProgress,
35
+ skipTestLikeDirs = true,
36
+ respectGitignore = true,
37
+ } = options;
38
+ const fileHandles = new Map<string, FileSystemFileHandle>();
39
+ const ig = respectGitignore ? await loadRootGitignore(rootHandle) : ignore();
40
+ let listed = false;
41
+
42
+ async function ensureListed(): Promise<void> {
43
+ if (listed) return;
44
+ let visited = 0;
45
+ await walk(
46
+ rootHandle,
47
+ '',
48
+ fileHandles,
49
+ () => {
50
+ visited++;
51
+ if (visited % 50 === 0) onProgress?.(visited);
52
+ },
53
+ skipTestLikeDirs,
54
+ ig,
55
+ );
56
+ onProgress?.(visited);
57
+ listed = true;
58
+ }
59
+
60
+ return {
61
+ rootPath: rootName,
62
+ async list(): Promise<string[]> {
63
+ await ensureListed();
64
+ return [...fileHandles.keys()].sort();
65
+ },
66
+ async read(rel: string): Promise<string> {
67
+ // cache is extension-filtered, so config files need a fallback lookup
68
+ await ensureListed();
69
+ const cached = fileHandles.get(rel);
70
+ if (cached) return (await cached.getFile()).text();
71
+ const handle = await resolveFileHandle(rootHandle, rel);
72
+ if (!handle) throw new Error(`File not found: ${rel}`);
73
+ return (await handle.getFile()).text();
74
+ },
75
+ async exists(rel: string): Promise<boolean> {
76
+ await ensureListed();
77
+ if (fileHandles.has(rel)) return true;
78
+ return (await resolveFileHandle(rootHandle, rel)) !== null;
79
+ },
80
+ };
81
+ }
82
+
83
+ async function resolveFileHandle(
84
+ root: FileSystemDirectoryHandle,
85
+ rel: string,
86
+ ): Promise<FileSystemFileHandle | null> {
87
+ const segments = rel.split('/').filter((s) => s.length > 0);
88
+ if (segments.length === 0) return null;
89
+ let dir: FileSystemDirectoryHandle = root;
90
+ for (let i = 0; i < segments.length - 1; i++) {
91
+ try {
92
+ dir = await dir.getDirectoryHandle(segments[i]!);
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+ try {
98
+ return await dir.getFileHandle(segments[segments.length - 1]!);
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+
104
+ async function walk(
105
+ dir: FileSystemDirectoryHandle,
106
+ relDir: string,
107
+ out: Map<string, FileSystemFileHandle>,
108
+ onVisit: () => void,
109
+ skipTestLikeDirs: boolean,
110
+ ig: Ignore,
111
+ ): Promise<void> {
112
+ for await (const [name, handle] of dir.entries()) {
113
+ const relPath = relDir ? `${relDir}/${name}` : name;
114
+ if (handle.kind === 'directory') {
115
+ if (ALWAYS_SKIP_DIRS.has(name)) continue;
116
+ if (skipTestLikeDirs && TEST_LIKE_DIRS.has(name)) continue;
117
+ if (ig.ignores(`${relPath}/`)) continue;
118
+ await walk(handle as FileSystemDirectoryHandle, relPath, out, onVisit, skipTestLikeDirs, ig);
119
+ continue;
120
+ }
121
+ const ext = extOf(name);
122
+ if (!SUPPORTED_EXT.has(ext)) continue;
123
+ if (ig.ignores(relPath)) continue;
124
+ out.set(relPath, handle as FileSystemFileHandle);
125
+ onVisit();
126
+ }
127
+ }
128
+
129
+ function extOf(name: string): string {
130
+ const i = name.lastIndexOf('.');
131
+ return i === -1 ? '' : name.slice(i).toLowerCase();
132
+ }
@@ -0,0 +1,24 @@
1
+ import type { FileSource } from '../fileSource';
2
+
3
+ // in-memory FileSource for tests + the worker pipeline
4
+ export function createInMemoryFileSource(
5
+ rootPath: string,
6
+ files: Record<string, string>,
7
+ ): FileSource {
8
+ return {
9
+ rootPath,
10
+ async list(): Promise<string[]> {
11
+ return Object.keys(files);
12
+ },
13
+ async read(relativePath: string): Promise<string> {
14
+ const content = files[relativePath];
15
+ if (content === undefined) {
16
+ throw new Error(`InMemoryFileSource: file not found "${relativePath}"`);
17
+ }
18
+ return content;
19
+ },
20
+ async exists(relativePath: string): Promise<boolean> {
21
+ return relativePath in files;
22
+ },
23
+ };
24
+ }
@@ -0,0 +1,93 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import ignore, { type Ignore } from 'ignore';
4
+ import type { FileSource } from '../fileSource';
5
+ import { ALWAYS_SKIP_DIRS, TEST_LIKE_DIRS } from '../discover';
6
+
7
+ const SUPPORTED_EXT = new Set(['.ts', '.tsx', '.js', '.jsx', '.vue', '.svelte', '.mjs', '.cjs']);
8
+
9
+ export interface NodeFsFileSourceOptions {
10
+ rootPath: string;
11
+ respectGitignore?: boolean;
12
+ extraIgnore?: string[];
13
+ /** Если true (по умолчанию) - не заходим в тест/storybook/cypress/playwright директории. */
14
+ skipTestLikeDirs?: boolean;
15
+ }
16
+
17
+ async function loadGitignore(rootPath: string): Promise<Ignore> {
18
+ const ig = ignore();
19
+ try {
20
+ const content = await fs.readFile(path.join(rootPath, '.gitignore'), 'utf8');
21
+ ig.add(content);
22
+ } catch {
23
+ /* no gitignore is fine */
24
+ }
25
+ return ig;
26
+ }
27
+
28
+ export async function createNodeFsFileSource(
29
+ options: NodeFsFileSourceOptions,
30
+ ): Promise<FileSource> {
31
+ const { rootPath, respectGitignore = true, extraIgnore = [], skipTestLikeDirs = true } = options;
32
+ const absRoot = path.resolve(rootPath);
33
+ const ig = respectGitignore ? await loadGitignore(absRoot) : ignore();
34
+ if (extraIgnore.length > 0) ig.add(extraIgnore);
35
+
36
+ return {
37
+ rootPath: absRoot,
38
+ async list(): Promise<string[]> {
39
+ const out: string[] = [];
40
+ await walk(absRoot, '', ig, out, skipTestLikeDirs);
41
+ return out.sort();
42
+ },
43
+ async read(rel: string): Promise<string> {
44
+ return fs.readFile(path.join(absRoot, rel), 'utf8');
45
+ },
46
+ async exists(rel: string): Promise<boolean> {
47
+ try {
48
+ const stat = await fs.stat(path.join(absRoot, rel));
49
+ return stat.isFile();
50
+ } catch {
51
+ return false;
52
+ }
53
+ },
54
+ };
55
+ }
56
+
57
+ async function walk(
58
+ absRoot: string,
59
+ relDir: string,
60
+ ig: Ignore,
61
+ out: string[],
62
+ skipTestLikeDirs: boolean,
63
+ ): Promise<void> {
64
+ const absDir = path.join(absRoot, relDir);
65
+ let entries;
66
+ try {
67
+ entries = await fs.readdir(absDir, { withFileTypes: true });
68
+ } catch {
69
+ return;
70
+ }
71
+
72
+ for (const entry of entries) {
73
+ const relPath = relDir ? path.posix.join(toPosix(relDir), entry.name) : entry.name;
74
+
75
+ if (entry.isDirectory()) {
76
+ if (ALWAYS_SKIP_DIRS.has(entry.name)) continue;
77
+ if (skipTestLikeDirs && TEST_LIKE_DIRS.has(entry.name)) continue;
78
+ if (ig.ignores(`${relPath}/`)) continue;
79
+ await walk(absRoot, relPath, ig, out, skipTestLikeDirs);
80
+ continue;
81
+ }
82
+
83
+ if (!entry.isFile()) continue;
84
+ if (ig.ignores(relPath)) continue;
85
+ const ext = path.extname(entry.name).toLowerCase();
86
+ if (!SUPPORTED_EXT.has(ext)) continue;
87
+ out.push(relPath);
88
+ }
89
+ }
90
+
91
+ function toPosix(p: string): string {
92
+ return p.split(path.sep).join('/');
93
+ }
@@ -0,0 +1,68 @@
1
+ import type { FileSource } from '../fileSource';
2
+
3
+ interface TauriInvoke {
4
+ <T>(cmd: string, args?: Record<string, unknown>): Promise<T>;
5
+ }
6
+
7
+ export interface TauriFileSourceOptions {
8
+ rootPath: string;
9
+ rootName?: string;
10
+ invoke: TauriInvoke;
11
+ /** Дополнительные ignore-globs (gitignore-синтаксис), напр. ['*.test.ts']. */
12
+ extraIgnoreGlobs?: string[];
13
+ /** Жёсткий лимит размера читаемого файла, байт. По умолчанию 2 МБ. */
14
+ maxFileBytes?: number;
15
+ }
16
+
17
+ export async function createTauriFileSource(options: TauriFileSourceOptions): Promise<FileSource> {
18
+ const { rootPath, rootName, invoke, extraIgnoreGlobs, maxFileBytes } = options;
19
+ let listing: Promise<Set<string>> | null = null;
20
+
21
+ async function ensureListed(): Promise<Set<string>> {
22
+ if (!listing) {
23
+ listing = invoke<string[]>('read_project_tree', {
24
+ root: rootPath,
25
+ options: {
26
+ extraIgnoreGlobs: extraIgnoreGlobs ?? [],
27
+ followSymlinks: false,
28
+ },
29
+ }).then((paths) => new Set(paths));
30
+ }
31
+ return listing;
32
+ }
33
+
34
+ return {
35
+ rootPath: rootName ?? rootPath,
36
+ async list(): Promise<string[]> {
37
+ const set = await ensureListed();
38
+ return [...set].sort();
39
+ },
40
+ async read(relativePath: string): Promise<string> {
41
+ return invoke<string>('read_file', {
42
+ root: rootPath,
43
+ relative: relativePath,
44
+ options: maxFileBytes !== undefined ? { maxBytes: maxFileBytes } : {},
45
+ });
46
+ },
47
+ async readMany(relativePaths: readonly string[]): Promise<Record<string, string>> {
48
+ return invoke<Record<string, string>>('read_files', {
49
+ root: rootPath,
50
+ relatives: relativePaths,
51
+ options: maxFileBytes !== undefined ? { maxBytes: maxFileBytes } : {},
52
+ });
53
+ },
54
+ async exists(relativePath: string): Promise<boolean> {
55
+ // listing is extension-filtered; configs miss the cache and need file_exists
56
+ const set = await ensureListed();
57
+ if (set.has(relativePath)) return true;
58
+ try {
59
+ return await invoke<boolean>('file_exists', {
60
+ root: rootPath,
61
+ relative: relativePath,
62
+ });
63
+ } catch {
64
+ return false;
65
+ }
66
+ },
67
+ };
68
+ }
@@ -0,0 +1,214 @@
1
+ // Auto-suggest contract rules.
2
+ //
3
+ // Walks an already-built scan and proposes a `.archora.json -> contracts`
4
+ // block that codifies the architectural intent visible in the code:
5
+ //
6
+ // - `features-isolation` boundary when there's a `features/*` folder,
7
+ // cross-instance `must-not` between siblings.
8
+ // - `layer-discipline` boundaries derived from observed layer violations:
9
+ // for every (lowerLayer -> higherLayer) pair that the code already
10
+ // respects (no violations going the wrong way), we don't propose;
11
+ // for pairs the user violates, we DO propose `must-not` so they get
12
+ // a CI gate the moment they fix the existing violations.
13
+ // - `no-cycles` budgets for folders that currently have 0 cycles - locks
14
+ // them in.
15
+ //
16
+ // The generator never proposes rules that already exist in the loaded
17
+ // `.archora.json` (matched by `name` + same `from`/`to`/`module` shape).
18
+ // CLI consumes the output via `archora suggest contracts`; UI offers
19
+ // "copy as .archora.json".
20
+
21
+ import type { ContractsConfig } from '../config/frontScopeConfig';
22
+ import { detectLayer, LAYER_ORDER, type Layer } from './layers';
23
+ import type { Cycle, DependencyEdge, ModuleNode } from './types';
24
+
25
+ export interface SuggestContractsInput {
26
+ modules: ModuleNode[];
27
+ edges: DependencyEdge[];
28
+ cycles: Cycle[];
29
+ /** Current contracts block, if any - used to suppress duplicates. */
30
+ existing?: ContractsConfig;
31
+ }
32
+
33
+ /**
34
+ * Structured reason for i18n consumers (UI). The `key` is a stable i18n key
35
+ * the host can translate; `params` are interpolation values. CLI/JSON readers
36
+ * keep using the plain `reasons` map.
37
+ */
38
+ export interface SuggestedReason {
39
+ key: string;
40
+ params: Record<string, string | number>;
41
+ }
42
+
43
+ export interface SuggestedContracts {
44
+ contracts: ContractsConfig;
45
+ /** Human-readable explanations per rule, keyed by `rule.name`. English. */
46
+ reasons: Record<string, string>;
47
+ /** i18n-friendly explanations per rule, same keys as `reasons`. */
48
+ reasonKeys: Record<string, SuggestedReason>;
49
+ }
50
+
51
+ const FEATURES_GLOB = 'src/features/*/**';
52
+ const FEATURE_RULE_NAME = 'features-isolation';
53
+
54
+ export function suggestContracts(input: SuggestContractsInput): SuggestedContracts {
55
+ const reasons: Record<string, string> = {};
56
+ const reasonKeys: Record<string, SuggestedReason> = {};
57
+ const out: ContractsConfig = {};
58
+ const existing = input.existing ?? {};
59
+
60
+ const boundaries = collectBoundarySuggestions(input, existing, reasons, reasonKeys);
61
+ if (boundaries.length > 0) out.boundaries = boundaries;
62
+
63
+ const budgets = collectBudgetSuggestions(input, existing, reasons, reasonKeys);
64
+ if (budgets.length > 0) out.budgets = budgets;
65
+
66
+ return { contracts: out, reasons, reasonKeys };
67
+ }
68
+
69
+ function collectBoundarySuggestions(
70
+ input: SuggestContractsInput,
71
+ existing: ContractsConfig,
72
+ reasons: Record<string, string>,
73
+ reasonKeys: Record<string, SuggestedReason>,
74
+ ): NonNullable<ContractsConfig['boundaries']> {
75
+ const out: NonNullable<ContractsConfig['boundaries']> = [];
76
+ const existingByName = new Set(existing.boundaries?.map((r) => r.name) ?? []);
77
+
78
+ // 1) features-isolation
79
+ const hasFeatures = input.modules.some((m) => m.id.startsWith('src/features/'));
80
+ if (hasFeatures && !existingByName.has(FEATURE_RULE_NAME)) {
81
+ // Only propose if at least one cross-feature edge currently exists or
82
+ // is plausible (multiple feature folders). One-feature projects get a
83
+ // less useful rule.
84
+ const featureRoots = collectImmediateChildrenUnder(input.modules, 'src/features/');
85
+ if (featureRoots.size >= 2) {
86
+ out.push({
87
+ name: FEATURE_RULE_NAME,
88
+ from: FEATURES_GLOB,
89
+ to: FEATURES_GLOB,
90
+ mode: 'must-not',
91
+ crossInstance: true,
92
+ description: 'Features must be self-contained. Cross-feature imports go through shared/.',
93
+ });
94
+ reasons[FEATURE_RULE_NAME] =
95
+ `Found ${featureRoots.size} feature folders under src/features/. The rule forbids sibling-to-sibling imports.`;
96
+ reasonKeys[FEATURE_RULE_NAME] = {
97
+ key: 'featuresIsolation',
98
+ params: { count: featureRoots.size },
99
+ };
100
+ }
101
+ }
102
+
103
+ // 2) layer-discipline: propose `must-not` for every observed violation
104
+ // direction we can name from FSD layers. We collapse all per-edge
105
+ // layer violations into per-(fromLayer,toLayer) suggestions.
106
+ const layerPairs = new Map<string, { fromLayer: Layer; toLayer: Layer; count: number }>();
107
+ for (const e of input.edges) {
108
+ if (e.kind === 'type-only') continue;
109
+ const fromLayer = detectLayer(e.from);
110
+ const toLayer = detectLayer(e.to);
111
+ if (fromLayer === 'unknown' || toLayer === 'unknown') continue;
112
+ const fromRank = LAYER_ORDER.indexOf(fromLayer);
113
+ const toRank = LAYER_ORDER.indexOf(toLayer);
114
+ // LAYER_ORDER goes UI -> core (smaller rank = closer to UI).
115
+ // Allowed direction: smaller rank importing larger rank (UI pulls in
116
+ // shared/core). Violation: bigger rank importing smaller rank
117
+ // (core/shared reaching up into widgets/features).
118
+ if (fromRank <= toRank) continue;
119
+ const key = `${fromLayer}->${toLayer}`;
120
+ const cur = layerPairs.get(key);
121
+ if (cur) cur.count += 1;
122
+ else layerPairs.set(key, { fromLayer, toLayer, count: 1 });
123
+ }
124
+ for (const { fromLayer, toLayer, count } of layerPairs.values()) {
125
+ const name = `layer-${fromLayer}-not-${toLayer}`;
126
+ if (existingByName.has(name)) continue;
127
+ out.push({
128
+ name,
129
+ from: `src/${fromLayer}/**`,
130
+ to: `src/${toLayer}/**`,
131
+ mode: 'must-not',
132
+ severity: 'error',
133
+ description: `${fromLayer} should not import from ${toLayer} (FSD layer order).`,
134
+ });
135
+ reasons[name] =
136
+ `Observed ${count} edge(s) from ${fromLayer} into ${toLayer}; codify the rule to prevent regression after the fix.`;
137
+ reasonKeys[name] = {
138
+ key: 'layerDiscipline',
139
+ params: { count, fromLayer, toLayer },
140
+ };
141
+ }
142
+
143
+ return out;
144
+ }
145
+
146
+ function collectBudgetSuggestions(
147
+ input: SuggestContractsInput,
148
+ existing: ContractsConfig,
149
+ reasons: Record<string, string>,
150
+ reasonKeys: Record<string, SuggestedReason>,
151
+ ): NonNullable<ContractsConfig['budgets']> {
152
+ const out: NonNullable<ContractsConfig['budgets']> = [];
153
+ const existingByName = new Set(existing.budgets?.map((r) => r.name) ?? []);
154
+
155
+ // shared/** + entities/** + core/** are common "no-cycles" candidates.
156
+ // Only propose when the current scan has zero cycles touching them - a
157
+ // suggestion that fails on day one is worse than no suggestion.
158
+ const candidates: { glob: string; label: string }[] = [
159
+ { glob: 'src/shared/**', label: 'shared' },
160
+ { glob: 'src/entities/**', label: 'entities' },
161
+ { glob: 'src/core/**', label: 'core' },
162
+ { glob: 'src/shared/ui/**', label: 'shared/ui' },
163
+ ];
164
+ for (const c of candidates) {
165
+ const name = `no-cycles-${c.label.replace(/\//gu, '-')}`;
166
+ if (existingByName.has(name)) continue;
167
+ if (!hasModulesUnder(input.modules, c.glob)) continue;
168
+ if (countCyclesUnder(input.cycles, c.glob) > 0) continue;
169
+ out.push({
170
+ name,
171
+ module: c.glob,
172
+ maxCycles: 0,
173
+ severity: 'warning',
174
+ description: `Lock cycle count at 0 in ${c.label}; the layer is currently clean.`,
175
+ });
176
+ reasons[name] = `${c.label} currently has 0 cycles - lock it in to catch regressions.`;
177
+ reasonKeys[name] = { key: 'noCycles', params: { label: c.label } };
178
+ }
179
+
180
+ return out;
181
+ }
182
+
183
+ function collectImmediateChildrenUnder(modules: ModuleNode[], prefix: string): Set<string> {
184
+ const out = new Set<string>();
185
+ for (const m of modules) {
186
+ if (!m.id.startsWith(prefix)) continue;
187
+ const tail = m.id.slice(prefix.length);
188
+ const slash = tail.indexOf('/');
189
+ if (slash <= 0) continue;
190
+ out.add(tail.slice(0, slash));
191
+ }
192
+ return out;
193
+ }
194
+
195
+ // Glob support is intentionally narrow: we only emit globs that follow the
196
+ // `prefix/**` shape, so a string-prefix check is exact.
197
+ function globPrefix(glob: string): string {
198
+ const idx = glob.indexOf('/**');
199
+ return idx === -1 ? glob : glob.slice(0, idx + 1);
200
+ }
201
+
202
+ function hasModulesUnder(modules: ModuleNode[], glob: string): boolean {
203
+ const p = globPrefix(glob);
204
+ return modules.some((m) => m.id.startsWith(p));
205
+ }
206
+
207
+ function countCyclesUnder(cycles: Cycle[], glob: string): number {
208
+ const p = globPrefix(glob);
209
+ let n = 0;
210
+ for (const c of cycles) {
211
+ if (c.modules.some((id) => id.startsWith(p))) n += 1;
212
+ }
213
+ return n;
214
+ }