@ankhorage/doctor 0.11.7 → 0.11.8
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/CHANGELOG.md +6 -0
- package/README.md +1 -1
- package/dist/analysis.d.ts.map +1 -1
- package/dist/analysis.js +62 -181
- package/dist/analysis.js.map +1 -1
- package/dist/cliLayoutAnalysis.d.ts.map +1 -1
- package/dist/cliLayoutAnalysis.js +26 -26
- package/dist/cliLayoutAnalysis.js.map +1 -1
- package/dist/sourceArchitectureAnalysis.d.ts.map +1 -1
- package/dist/sourceArchitectureAnalysis.js +35 -70
- package/dist/sourceArchitectureAnalysis.js.map +1 -1
- package/dist/utils/getArchitecturePolicyRule.d.ts +281 -0
- package/dist/utils/getArchitecturePolicyRule.d.ts.map +1 -0
- package/dist/utils/getArchitecturePolicyRule.js +12 -0
- package/dist/utils/getArchitecturePolicyRule.js.map +1 -0
- package/package.json +2 -2
- package/src/analysis.ts +83 -218
- package/src/cliLayoutAnalysis.ts +33 -26
- package/src/sourceArchitectureAnalysis.ts +59 -105
- package/src/utils/getArchitecturePolicyRule.ts +15 -0
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { promises as fs } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
|
+
import { ARCHITECTURE_POLICY } from '@ankhorage/policy/architecture';
|
|
5
|
+
|
|
4
6
|
import type { DoctorDiagnostic, DoctorPolicyProfile } from './diagnostics.js';
|
|
7
|
+
import { getArchitecturePolicyRule } from './utils/getArchitecturePolicyRule.js';
|
|
5
8
|
|
|
6
9
|
interface SourceImport {
|
|
7
10
|
readonly filePath: string;
|
|
@@ -14,31 +17,7 @@ interface AnalyzeSourceArchitectureInput {
|
|
|
14
17
|
readonly targetPath: string;
|
|
15
18
|
}
|
|
16
19
|
|
|
17
|
-
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;
|
|
20
|
+
const SOURCE_POLICY = ARCHITECTURE_POLICY.source;
|
|
42
21
|
|
|
43
22
|
/*** Validate folder-role combinations and inward source dependency direction. */
|
|
44
23
|
export async function analyzeSourceArchitecture(
|
|
@@ -54,14 +33,14 @@ async function analyzeDirectoryVocabularyAsync(
|
|
|
54
33
|
const diagnostics: DoctorDiagnostic[] = [];
|
|
55
34
|
const sourceRoot = path.join(input.targetPath, 'src');
|
|
56
35
|
|
|
57
|
-
for (const directoryName of
|
|
36
|
+
for (const directoryName of SOURCE_POLICY.catchAllDirectories) {
|
|
58
37
|
const directoryPath = path.join(sourceRoot, directoryName);
|
|
59
38
|
if (await pathExistsAsync(directoryPath)) {
|
|
60
39
|
diagnostics.push(
|
|
61
40
|
createDiagnostic(
|
|
62
41
|
input,
|
|
63
42
|
directoryPath,
|
|
64
|
-
|
|
43
|
+
ARCHITECTURE_POLICY.rules.catchAllDirectory,
|
|
65
44
|
`src/${directoryName}/ is a generic catch-all. Move code to an owning domain, feature, package edge, or utils/ according to the repository profile.`,
|
|
66
45
|
),
|
|
67
46
|
);
|
|
@@ -92,31 +71,21 @@ async function analyzeFeatureCombinationsAsync(
|
|
|
92
71
|
.filter((child) => child.isDirectory())
|
|
93
72
|
.map((child) => child.name),
|
|
94
73
|
);
|
|
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
74
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
75
|
+
for (const [roleName, requirement] of Object.entries(SOURCE_POLICY.featureCombinations)) {
|
|
76
|
+
if (
|
|
77
|
+
roleNames.has(roleName) &&
|
|
78
|
+
!requirement.requiresAnyOf.some((requiredRole) => roleNames.has(requiredRole))
|
|
79
|
+
) {
|
|
80
|
+
diagnostics.push(
|
|
81
|
+
createDiagnostic(
|
|
82
|
+
input,
|
|
83
|
+
path.join(featureRoot, roleName),
|
|
84
|
+
getArchitecturePolicyRule(requirement.ruleId),
|
|
85
|
+
`Feature "${entry.name}" declares ${roleName}/ without one of the required inward roles: ${requirement.requiresAnyOf.join(', ')}.`,
|
|
86
|
+
),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
120
89
|
}
|
|
121
90
|
}
|
|
122
91
|
|
|
@@ -156,7 +125,7 @@ function createEscapedRepositoryDiagnostic(
|
|
|
156
125
|
return createDiagnostic(
|
|
157
126
|
input,
|
|
158
127
|
sourceImport.filePath,
|
|
159
|
-
|
|
128
|
+
getArchitecturePolicyRule(SOURCE_POLICY.repositoryBoundaryRuleId),
|
|
160
129
|
`Relative import "${sourceImport.specifier}" escapes the standalone repository root.`,
|
|
161
130
|
);
|
|
162
131
|
}
|
|
@@ -169,12 +138,17 @@ function analyzeRecognizedRoleDirection(
|
|
|
169
138
|
): DoctorDiagnostic[] {
|
|
170
139
|
const sourceSegments = splitRelativePath(input.targetPath, sourceImport.filePath);
|
|
171
140
|
const targetSegments = splitRelativePath(input.targetPath, targetPath);
|
|
172
|
-
|
|
141
|
+
const deliveryPolicy = SOURCE_POLICY.thinDeliveryAdapter;
|
|
142
|
+
|
|
143
|
+
if (
|
|
144
|
+
includesSegmentSequence(sourceSegments, deliveryPolicy.pathSegments) &&
|
|
145
|
+
targetSegments.includes(deliveryPolicy.concreteAdapterSegment)
|
|
146
|
+
) {
|
|
173
147
|
return [
|
|
174
148
|
createDiagnostic(
|
|
175
149
|
input,
|
|
176
150
|
sourceImport.filePath,
|
|
177
|
-
|
|
151
|
+
getArchitecturePolicyRule(deliveryPolicy.ruleId),
|
|
178
152
|
`Thin delivery adapter must not wire concrete adapter implementation through "${sourceImport.specifier}". Import an application operation or composition boundary instead.`,
|
|
179
153
|
),
|
|
180
154
|
];
|
|
@@ -183,55 +157,30 @@ function analyzeRecognizedRoleDirection(
|
|
|
183
157
|
const role = resolveSourceRole(sourceSegments);
|
|
184
158
|
if (role === null) return [];
|
|
185
159
|
|
|
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';
|
|
160
|
+
return createOutwardImportDiagnostic(input, sourceImport, targetSegments, role);
|
|
200
161
|
}
|
|
201
162
|
|
|
202
163
|
/*** Resolve the dependency rule owned by one recognized inner source role. */
|
|
203
164
|
function resolveSourceRole(sourceSegments: readonly string[]): SourceRoleRule | null {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
};
|
|
165
|
+
for (const role of Object.values(SOURCE_POLICY.roles)) {
|
|
166
|
+
if (
|
|
167
|
+
sourceSegments.some((segment) => role.segments.some((roleSegment) => roleSegment === segment))
|
|
168
|
+
) {
|
|
169
|
+
return {
|
|
170
|
+
forbiddenSegments: new Set<string>(role.forbiddenOutwardSegments),
|
|
171
|
+
label: role.label,
|
|
172
|
+
rule: getArchitecturePolicyRule(role.ruleId),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
224
175
|
}
|
|
176
|
+
|
|
225
177
|
return null;
|
|
226
178
|
}
|
|
227
179
|
|
|
228
180
|
interface SourceRoleRule {
|
|
229
181
|
readonly forbiddenSegments: ReadonlySet<string>;
|
|
230
182
|
readonly label: string;
|
|
231
|
-
readonly
|
|
232
|
-
| 'package.architecture.application-outward-import.disallowed'
|
|
233
|
-
| 'package.architecture.domain-outward-import.disallowed'
|
|
234
|
-
| 'package.architecture.port-outward-import.disallowed';
|
|
183
|
+
readonly rule: ReturnType<typeof getArchitecturePolicyRule>;
|
|
235
184
|
}
|
|
236
185
|
|
|
237
186
|
/*** Report one outward dependency when the imported path crosses a forbidden role. */
|
|
@@ -239,36 +188,41 @@ function createOutwardImportDiagnostic(
|
|
|
239
188
|
input: AnalyzeSourceArchitectureInput,
|
|
240
189
|
sourceImport: SourceImport,
|
|
241
190
|
targetSegments: readonly string[],
|
|
242
|
-
|
|
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,
|
|
191
|
+
role: SourceRoleRule,
|
|
248
192
|
): DoctorDiagnostic[] {
|
|
249
|
-
const outwardRole = targetSegments.find((segment) => forbiddenSegments.has(segment));
|
|
193
|
+
const outwardRole = targetSegments.find((segment) => role.forbiddenSegments.has(segment));
|
|
250
194
|
if (outwardRole === undefined) return [];
|
|
251
195
|
|
|
252
196
|
return [
|
|
253
197
|
createDiagnostic(
|
|
254
198
|
input,
|
|
255
199
|
sourceImport.filePath,
|
|
256
|
-
|
|
257
|
-
`${
|
|
200
|
+
role.rule,
|
|
201
|
+
`${role.label} must not import outward ${outwardRole}/ implementation through "${sourceImport.specifier}".`,
|
|
258
202
|
),
|
|
259
203
|
];
|
|
260
204
|
}
|
|
261
205
|
|
|
206
|
+
/*** Check whether one path segment sequence occurs contiguously in another. */
|
|
207
|
+
function includesSegmentSequence(
|
|
208
|
+
segments: readonly string[],
|
|
209
|
+
expected: readonly string[],
|
|
210
|
+
): boolean {
|
|
211
|
+
return segments.some((_, index) =>
|
|
212
|
+
expected.every((segment, offset) => segments[index + offset] === segment),
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
262
216
|
/*** Split one repository path into normalized architecture segments. */
|
|
263
217
|
function splitRelativePath(targetPath: string, filePath: string): readonly string[] {
|
|
264
218
|
return path.relative(targetPath, filePath).split(path.sep).filter(Boolean);
|
|
265
219
|
}
|
|
266
220
|
|
|
267
|
-
/*** Build one deterministic architecture diagnostic. */
|
|
221
|
+
/*** Build one deterministic architecture diagnostic from Policy-owned metadata. */
|
|
268
222
|
function createDiagnostic(
|
|
269
223
|
input: AnalyzeSourceArchitectureInput,
|
|
270
224
|
diagnosticPath: string,
|
|
271
|
-
|
|
225
|
+
rule: ReturnType<typeof getArchitecturePolicyRule>,
|
|
272
226
|
message: string,
|
|
273
227
|
): DoctorDiagnostic {
|
|
274
228
|
return {
|
|
@@ -276,8 +230,8 @@ function createDiagnostic(
|
|
|
276
230
|
message,
|
|
277
231
|
path: diagnosticPath,
|
|
278
232
|
profile: input.profile,
|
|
279
|
-
ruleId,
|
|
280
|
-
severity:
|
|
233
|
+
ruleId: rule.id,
|
|
234
|
+
severity: rule.severity,
|
|
281
235
|
};
|
|
282
236
|
}
|
|
283
237
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ARCHITECTURE_POLICY } from '@ankhorage/policy/architecture';
|
|
2
|
+
|
|
3
|
+
import type { DoctorRuleId } from '../diagnostics.js';
|
|
4
|
+
|
|
5
|
+
/***
|
|
6
|
+
* Resolves one canonical architecture/profile Policy rule by its stable Doctor rule id.
|
|
7
|
+
*/
|
|
8
|
+
export function getArchitecturePolicyRule(ruleId: DoctorRuleId) {
|
|
9
|
+
const rule = Object.values(ARCHITECTURE_POLICY.rules).find((entry) => entry.id === ruleId);
|
|
10
|
+
if (rule === undefined) {
|
|
11
|
+
throw new Error(`Unknown architecture policy rule: ${ruleId}`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return rule;
|
|
15
|
+
}
|