@ankhorage/doctor 0.11.7 → 0.11.9

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.
@@ -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 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;
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 CATCH_ALL_DIRECTORIES) {
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
- 'package.architecture.catch-all-directory.disallowed',
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
- 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
- );
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
- 'package.imports.outside-root.disallowed',
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
- if (isThinDeliveryAdapter(sourceSegments) && targetSegments.includes('adapters')) {
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
- 'package.architecture.delivery-concrete-adapter-import.disallowed',
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
- 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
- };
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 ruleId:
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
- 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,
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
- ruleId,
257
- `${roleLabel} must not import outward ${outwardRole}/ implementation through "${sourceImport.specifier}".`,
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
- ruleId: DoctorDiagnostic['ruleId'],
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: 'error',
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
+ }