@ankhorage/doctor 0.10.40 → 0.11.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.
@@ -0,0 +1,292 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import type { DoctorDiagnostic, DoctorPolicyProfile } from './diagnostics.js';
5
+
6
+ interface SourceImport {
7
+ readonly filePath: string;
8
+ readonly specifier: string;
9
+ }
10
+
11
+ interface AnalyzeSourceArchitectureInput {
12
+ readonly activeSourceImports: readonly SourceImport[];
13
+ readonly profile: DoctorPolicyProfile;
14
+ readonly targetPath: string;
15
+ }
16
+
17
+ const CATCH_ALL_DIRECTORIES = ['common', 'helpers', 'shared'] as const;
18
+ const INNER_ROLE_SEGMENTS = new Set(['domain', 'core']);
19
+ const APPLICATION_ROLE_SEGMENTS = new Set(['application']);
20
+ const PORT_ROLE_SEGMENTS = new Set(['ports']);
21
+ const DOMAIN_OUTWARD_SEGMENTS = new Set([
22
+ 'adapters',
23
+ 'app',
24
+ 'application',
25
+ 'cli',
26
+ 'composition',
27
+ 'host',
28
+ 'infrastructure',
29
+ 'platform',
30
+ ]);
31
+ const APPLICATION_OUTWARD_SEGMENTS = new Set([
32
+ 'adapters',
33
+ 'app',
34
+ 'cli',
35
+ 'composition',
36
+ 'host',
37
+ 'infrastructure',
38
+ 'platform',
39
+ ]);
40
+ const PORT_OUTWARD_SEGMENTS = APPLICATION_OUTWARD_SEGMENTS;
41
+ const INWARD_FEATURE_ROLES = ['application', 'contracts', 'domain', 'planning', 'ports'] as const;
42
+
43
+ /*** Validate folder-role combinations and inward source dependency direction. */
44
+ export async function analyzeSourceArchitecture(
45
+ input: AnalyzeSourceArchitectureInput,
46
+ ): Promise<DoctorDiagnostic[]> {
47
+ return [...(await analyzeDirectoryVocabularyAsync(input)), ...analyzeImportDirection(input)];
48
+ }
49
+
50
+ /*** Validate architectural directory names only when a repository has introduced them. */
51
+ async function analyzeDirectoryVocabularyAsync(
52
+ input: AnalyzeSourceArchitectureInput,
53
+ ): Promise<DoctorDiagnostic[]> {
54
+ const diagnostics: DoctorDiagnostic[] = [];
55
+ const sourceRoot = path.join(input.targetPath, 'src');
56
+
57
+ for (const directoryName of CATCH_ALL_DIRECTORIES) {
58
+ const directoryPath = path.join(sourceRoot, directoryName);
59
+ if (await pathExistsAsync(directoryPath)) {
60
+ diagnostics.push(
61
+ createDiagnostic(
62
+ input,
63
+ directoryPath,
64
+ 'package.architecture.catch-all-directory.disallowed',
65
+ `src/${directoryName}/ is a generic catch-all. Move code to an owning domain, feature, package edge, or utils/ according to the repository profile.`,
66
+ ),
67
+ );
68
+ }
69
+ }
70
+
71
+ diagnostics.push(...(await analyzeFeatureCombinationsAsync(input, sourceRoot)));
72
+ return diagnostics;
73
+ }
74
+
75
+ /*** Validate feature-first role combinations without requiring unused ceremonial layers. */
76
+ async function analyzeFeatureCombinationsAsync(
77
+ input: AnalyzeSourceArchitectureInput,
78
+ sourceRoot: string,
79
+ ): Promise<DoctorDiagnostic[]> {
80
+ const featuresRoot = path.join(sourceRoot, 'features');
81
+ if (!(await pathExistsAsync(featuresRoot))) return [];
82
+
83
+ const featureEntries = (await fs.readdir(featuresRoot, { withFileTypes: true }))
84
+ .filter((entry) => entry.isDirectory())
85
+ .sort((left, right) => left.name.localeCompare(right.name));
86
+ const diagnostics: DoctorDiagnostic[] = [];
87
+
88
+ for (const entry of featureEntries) {
89
+ const featureRoot = path.join(featuresRoot, entry.name);
90
+ const roleNames = new Set(
91
+ (await fs.readdir(featureRoot, { withFileTypes: true }))
92
+ .filter((child) => child.isDirectory())
93
+ .map((child) => child.name),
94
+ );
95
+ const hasInwardRole = INWARD_FEATURE_ROLES.some((role) => roleNames.has(role));
96
+
97
+ if (roleNames.has('adapters') && !hasInwardRole) {
98
+ diagnostics.push(
99
+ createDiagnostic(
100
+ input,
101
+ path.join(featureRoot, 'adapters'),
102
+ 'package.architecture.role-combination.invalid',
103
+ `Feature "${entry.name}" declares adapters/ without domain, application, ports, contracts, or planning policy to adapt to.`,
104
+ ),
105
+ );
106
+ }
107
+
108
+ if (
109
+ roleNames.has('composition') &&
110
+ !['adapters', 'application', 'ports', 'planning'].some((role) => roleNames.has(role))
111
+ ) {
112
+ diagnostics.push(
113
+ createDiagnostic(
114
+ input,
115
+ path.join(featureRoot, 'composition'),
116
+ 'package.architecture.role-combination.invalid',
117
+ `Feature "${entry.name}" declares composition/ without application, ports, planning, or adapters to wire.`,
118
+ ),
119
+ );
120
+ }
121
+ }
122
+
123
+ return diagnostics;
124
+ }
125
+
126
+ /*** Validate that relative source imports point inward across recognized architecture roles. */
127
+ function analyzeImportDirection(input: AnalyzeSourceArchitectureInput): DoctorDiagnostic[] {
128
+ return input.activeSourceImports.flatMap((sourceImport) =>
129
+ analyzeOneImportDirection(input, sourceImport),
130
+ );
131
+ }
132
+
133
+ /*** Validate one relative import against repository and layer boundaries. */
134
+ function analyzeOneImportDirection(
135
+ input: AnalyzeSourceArchitectureInput,
136
+ sourceImport: SourceImport,
137
+ ): DoctorDiagnostic[] {
138
+ if (!sourceImport.specifier.startsWith('.')) return [];
139
+
140
+ const targetPath = path.resolve(path.dirname(sourceImport.filePath), sourceImport.specifier);
141
+ const escaped = createEscapedRepositoryDiagnostic(input, sourceImport, targetPath);
142
+ if (escaped !== null) return [escaped];
143
+
144
+ return analyzeRecognizedRoleDirection(input, sourceImport, targetPath);
145
+ }
146
+
147
+ /*** Report a relative import that escapes the standalone repository boundary. */
148
+ function createEscapedRepositoryDiagnostic(
149
+ input: AnalyzeSourceArchitectureInput,
150
+ sourceImport: SourceImport,
151
+ targetPath: string,
152
+ ): DoctorDiagnostic | null {
153
+ const relativeTarget = path.relative(input.targetPath, targetPath);
154
+ if (!relativeTarget.startsWith('..') && !path.isAbsolute(relativeTarget)) return null;
155
+
156
+ return createDiagnostic(
157
+ input,
158
+ sourceImport.filePath,
159
+ 'package.imports.outside-root.disallowed',
160
+ `Relative import "${sourceImport.specifier}" escapes the standalone repository root.`,
161
+ );
162
+ }
163
+
164
+ /*** Apply inward dependency rules for recognized source architecture roles. */
165
+ function analyzeRecognizedRoleDirection(
166
+ input: AnalyzeSourceArchitectureInput,
167
+ sourceImport: SourceImport,
168
+ targetPath: string,
169
+ ): DoctorDiagnostic[] {
170
+ const sourceSegments = splitRelativePath(input.targetPath, sourceImport.filePath);
171
+ const targetSegments = splitRelativePath(input.targetPath, targetPath);
172
+ if (isThinDeliveryAdapter(sourceSegments) && targetSegments.includes('adapters')) {
173
+ return [
174
+ createDiagnostic(
175
+ input,
176
+ sourceImport.filePath,
177
+ 'package.architecture.delivery-concrete-adapter-import.disallowed',
178
+ `Thin delivery adapter must not wire concrete adapter implementation through "${sourceImport.specifier}". Import an application operation or composition boundary instead.`,
179
+ ),
180
+ ];
181
+ }
182
+
183
+ const role = resolveSourceRole(sourceSegments);
184
+ if (role === null) return [];
185
+
186
+ return createOutwardImportDiagnostic(
187
+ input,
188
+ sourceImport,
189
+ targetSegments,
190
+ role.forbiddenSegments,
191
+ role.ruleId,
192
+ role.label,
193
+ );
194
+ }
195
+
196
+ /*** Check whether a source file belongs to a thin CLI command delivery boundary. */
197
+ function isThinDeliveryAdapter(sourceSegments: readonly string[]): boolean {
198
+ const cliIndex = sourceSegments.indexOf('cli');
199
+ return cliIndex >= 0 && sourceSegments[cliIndex + 1] === 'commands';
200
+ }
201
+
202
+ /*** Resolve the dependency rule owned by one recognized inner source role. */
203
+ function resolveSourceRole(sourceSegments: readonly string[]): SourceRoleRule | null {
204
+ if (sourceSegments.some((segment) => INNER_ROLE_SEGMENTS.has(segment))) {
205
+ return {
206
+ forbiddenSegments: DOMAIN_OUTWARD_SEGMENTS,
207
+ label: 'Domain/core policy',
208
+ ruleId: 'package.architecture.domain-outward-import.disallowed',
209
+ };
210
+ }
211
+ if (sourceSegments.some((segment) => APPLICATION_ROLE_SEGMENTS.has(segment))) {
212
+ return {
213
+ forbiddenSegments: APPLICATION_OUTWARD_SEGMENTS,
214
+ label: 'Application/use-case code',
215
+ ruleId: 'package.architecture.application-outward-import.disallowed',
216
+ };
217
+ }
218
+ if (sourceSegments.some((segment) => PORT_ROLE_SEGMENTS.has(segment))) {
219
+ return {
220
+ forbiddenSegments: PORT_OUTWARD_SEGMENTS,
221
+ label: 'Port contracts',
222
+ ruleId: 'package.architecture.port-outward-import.disallowed',
223
+ };
224
+ }
225
+ return null;
226
+ }
227
+
228
+ interface SourceRoleRule {
229
+ readonly forbiddenSegments: ReadonlySet<string>;
230
+ readonly label: string;
231
+ readonly ruleId:
232
+ | 'package.architecture.application-outward-import.disallowed'
233
+ | 'package.architecture.domain-outward-import.disallowed'
234
+ | 'package.architecture.port-outward-import.disallowed';
235
+ }
236
+
237
+ /*** Report one outward dependency when the imported path crosses a forbidden role. */
238
+ function createOutwardImportDiagnostic(
239
+ input: AnalyzeSourceArchitectureInput,
240
+ sourceImport: SourceImport,
241
+ targetSegments: readonly string[],
242
+ forbiddenSegments: ReadonlySet<string>,
243
+ ruleId:
244
+ | 'package.architecture.application-outward-import.disallowed'
245
+ | 'package.architecture.domain-outward-import.disallowed'
246
+ | 'package.architecture.port-outward-import.disallowed',
247
+ roleLabel: string,
248
+ ): DoctorDiagnostic[] {
249
+ const outwardRole = targetSegments.find((segment) => forbiddenSegments.has(segment));
250
+ if (outwardRole === undefined) return [];
251
+
252
+ return [
253
+ createDiagnostic(
254
+ input,
255
+ sourceImport.filePath,
256
+ ruleId,
257
+ `${roleLabel} must not import outward ${outwardRole}/ implementation through "${sourceImport.specifier}".`,
258
+ ),
259
+ ];
260
+ }
261
+
262
+ /*** Split one repository path into normalized architecture segments. */
263
+ function splitRelativePath(targetPath: string, filePath: string): readonly string[] {
264
+ return path.relative(targetPath, filePath).split(path.sep).filter(Boolean);
265
+ }
266
+
267
+ /*** Build one deterministic architecture diagnostic. */
268
+ function createDiagnostic(
269
+ input: AnalyzeSourceArchitectureInput,
270
+ diagnosticPath: string,
271
+ ruleId: DoctorDiagnostic['ruleId'],
272
+ message: string,
273
+ ): DoctorDiagnostic {
274
+ return {
275
+ code: 'field-invalid',
276
+ message,
277
+ path: diagnosticPath,
278
+ profile: input.profile,
279
+ ruleId,
280
+ severity: 'error',
281
+ };
282
+ }
283
+
284
+ /*** Check whether a filesystem path exists. */
285
+ async function pathExistsAsync(targetPath: string): Promise<boolean> {
286
+ try {
287
+ await fs.access(targetPath);
288
+ return true;
289
+ } catch {
290
+ return false;
291
+ }
292
+ }