@ankhorage/doctor 0.11.96 → 0.12.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 (34) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +1 -1
  3. package/dist/analysis.js +22 -22
  4. package/dist/analysis.js.map +1 -1
  5. package/dist/bunPolicyAnalysis.d.ts.map +1 -1
  6. package/dist/bunPolicyAnalysis.js +5 -14
  7. package/dist/bunPolicyAnalysis.js.map +1 -1
  8. package/dist/cliLayoutAnalysis.d.ts.map +1 -1
  9. package/dist/cliLayoutAnalysis.js +39 -29
  10. package/dist/cliLayoutAnalysis.js.map +1 -1
  11. package/dist/repositorySourceRulesAnalysis.d.ts +16 -0
  12. package/dist/repositorySourceRulesAnalysis.d.ts.map +1 -0
  13. package/dist/repositorySourceRulesAnalysis.js +103 -0
  14. package/dist/repositorySourceRulesAnalysis.js.map +1 -0
  15. package/dist/sourceArchitectureAnalysis.d.ts +3 -6
  16. package/dist/sourceArchitectureAnalysis.d.ts.map +1 -1
  17. package/dist/sourceArchitectureAnalysis.js +93 -97
  18. package/dist/sourceArchitectureAnalysis.js.map +1 -1
  19. package/dist/utils/getRepositoryRule.d.ts +9 -0
  20. package/dist/utils/getRepositoryRule.d.ts.map +1 -0
  21. package/dist/utils/getRepositoryRule.js +15 -0
  22. package/dist/utils/getRepositoryRule.js.map +1 -0
  23. package/package.json +5 -3
  24. package/src/analysis.ts +22 -24
  25. package/src/bunPolicyAnalysis.ts +7 -17
  26. package/src/cliLayoutAnalysis.ts +51 -30
  27. package/src/repositorySourceRulesAnalysis.ts +153 -0
  28. package/src/sourceArchitectureAnalysis.ts +102 -177
  29. package/src/utils/getRepositoryRule.ts +20 -0
  30. package/dist/utils/getArchitecturePolicyRule.d.ts +0 -286
  31. package/dist/utils/getArchitecturePolicyRule.d.ts.map +0 -1
  32. package/dist/utils/getArchitecturePolicyRule.js +0 -12
  33. package/dist/utils/getArchitecturePolicyRule.js.map +0 -1
  34. package/src/utils/getArchitecturePolicyRule.ts +0 -15
@@ -1,7 +1,7 @@
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';
4
+ import { REPOSITORY_RULE_IDS, REPOSITORY_RULE_METADATA } from '@ankhorage/rules-repository';
5
5
 
6
6
  import {
7
7
  analyzeDoctorTarget,
@@ -10,11 +10,12 @@ import {
10
10
  } from './analysis.js';
11
11
  import type { DoctorDiagnostic, DoctorPolicyProfile } from './diagnostics.js';
12
12
  import { analyzeAppManifestTarget } from './manifestAnalysis.js';
13
+ import { analyzeRepositorySourceRules } from './repositorySourceRulesAnalysis.js';
13
14
  import { analyzeSourceArchitecture } from './sourceArchitectureAnalysis.js';
14
- import { getArchitecturePolicyRule } from './utils/getArchitecturePolicyRule.js';
15
+ import { getRepositoryRule } from './utils/getRepositoryRule.js';
15
16
 
16
- const CLI_POLICY = ARCHITECTURE_POLICY.cli;
17
- const DEPENDENCY_POLICY = ARCHITECTURE_POLICY.dependencies;
17
+ const CLI_POLICY = REPOSITORY_RULE_METADATA.cli;
18
+ const DEPENDENCY_POLICY = REPOSITORY_RULE_METADATA.dependencies;
18
19
  const ACTIVE_SOURCE_ROOTS = ['src', 'app', 'apps', 'packages', 'scripts'] as const;
19
20
  const ACTIVE_SOURCE_EXTENSIONS = new Set([
20
21
  '.cjs',
@@ -119,7 +120,7 @@ async function analyzeTargetArchitecture(request: {
119
120
  message: 'Package CLI code must live under src/cli/; root src/cli.ts is not allowed.',
120
121
  path: legacyRootCliPath,
121
122
  profile: request.profile,
122
- ...architectureRuleFields(CLI_POLICY.legacyRootRuleId),
123
+ ...repositoryRuleFields(REPOSITORY_RULE_IDS.cliRootFile),
123
124
  });
124
125
  }
125
126
 
@@ -130,7 +131,7 @@ async function analyzeTargetArchitecture(request: {
130
131
  'CLI-capable packages must export "./cli" from package.json and point it at the metadata-declared provider build output.',
131
132
  path: request.packageJsonPath,
132
133
  profile: request.profile,
133
- ...architectureRuleFields(CLI_POLICY.exportRuleId),
134
+ ...repositoryRuleFields(REPOSITORY_RULE_IDS.cliExport),
134
135
  });
135
136
  }
136
137
 
@@ -143,16 +144,23 @@ async function analyzeTargetArchitecture(request: {
143
144
  }),
144
145
  );
145
146
 
146
- const activeSourceImports = await collectActiveSourceImports(request.targetPath);
147
+ const activeSource = await collectActiveSourceSnapshot(request.targetPath);
147
148
  diagnostics.push(
148
149
  ...validateActiveSourceImports({
149
- activeSourceImports,
150
+ activeSourceImports: activeSource.imports,
150
151
  profile: request.profile,
151
152
  }),
152
153
  );
154
+ diagnostics.push(
155
+ ...analyzeRepositorySourceRules({
156
+ files: activeSource.files,
157
+ imports: activeSource.imports,
158
+ profile: request.profile,
159
+ targetPath: request.targetPath,
160
+ }),
161
+ );
153
162
  diagnostics.push(
154
163
  ...(await analyzeSourceArchitecture({
155
- activeSourceImports,
156
164
  profile: request.profile,
157
165
  targetPath: request.targetPath,
158
166
  })),
@@ -161,7 +169,7 @@ async function analyzeTargetArchitecture(request: {
161
169
  if (request.packageJson.name === STUDIO_PACKAGE_NAME) {
162
170
  diagnostics.push(
163
171
  ...validateStudioOwnership({
164
- activeSourceImports,
172
+ activeSourceImports: activeSource.imports,
165
173
  dependencyEntries,
166
174
  packageJsonPath: request.packageJsonPath,
167
175
  profile: request.profile,
@@ -186,7 +194,7 @@ function validateDependencyArchitecture(request: {
186
194
  message: `Dependency "${dependency.packageName}" in package.json.${dependency.fieldName} is an old ankhorage4 workspace alias. Depend on "${toOwningPackageSpecifier(dependency.packageName)}" directly.`,
187
195
  path: request.packageJsonPath,
188
196
  profile: request.profile,
189
- ...architectureRuleFields(DEPENDENCY_POLICY.rules.compatibilityDependency),
197
+ ...repositoryRuleFields(REPOSITORY_RULE_IDS.compatibilityDependency),
190
198
  });
191
199
  }
192
200
 
@@ -201,7 +209,7 @@ function validateDependencyArchitecture(request: {
201
209
  message: `Published dependency "${dependency.packageName}" must resolve from a registry version, not "${dependency.version}". Standalone packages cannot rely on local/workspace/Git package state.`,
202
210
  path: request.packageJsonPath,
203
211
  profile: request.profile,
204
- ...architectureRuleFields(DEPENDENCY_POLICY.rules.localProtocolDependency),
212
+ ...repositoryRuleFields(REPOSITORY_RULE_IDS.localProtocolDependency),
205
213
  });
206
214
  }
207
215
 
@@ -214,7 +222,7 @@ function validateDependencyArchitecture(request: {
214
222
  message: `Dependency "${dependency.packageName}" references ankhorage4 as active source. Depend on the extracted owning @ankhorage/* package instead.`,
215
223
  path: request.packageJsonPath,
216
224
  profile: request.profile,
217
- ...architectureRuleFields(DEPENDENCY_POLICY.rules.legacySourceDependency),
225
+ ...repositoryRuleFields(REPOSITORY_RULE_IDS.legacySourceDependency),
218
226
  });
219
227
  }
220
228
  }
@@ -235,7 +243,7 @@ function validateActiveSourceImports(request: {
235
243
  message: `Import "${sourceImport.specifier}" is an old ankhorage4 compatibility boundary. Import "${toOwningPackageSpecifier(sourceImport.specifier)}" directly from the owning package.`,
236
244
  path: sourceImport.filePath,
237
245
  profile: request.profile,
238
- ...architectureRuleFields(DEPENDENCY_POLICY.rules.compatibilityImport),
246
+ ...repositoryRuleFields(REPOSITORY_RULE_IDS.compatibilityImport),
239
247
  });
240
248
  }
241
249
 
@@ -245,7 +253,7 @@ function validateActiveSourceImports(request: {
245
253
  message: `Import "${sourceImport.specifier}" references ankhorage4 as active source. Import the extracted owning @ankhorage/* package instead.`,
246
254
  path: sourceImport.filePath,
247
255
  profile: request.profile,
248
- ...architectureRuleFields(DEPENDENCY_POLICY.rules.legacySourceImport),
256
+ ...repositoryRuleFields(REPOSITORY_RULE_IDS.legacySourceImport),
249
257
  });
250
258
  }
251
259
  }
@@ -320,24 +328,37 @@ function validateStudioOwnership(request: {
320
328
  return diagnostics;
321
329
  }
322
330
 
323
- async function collectActiveSourceImports(targetPath: string): Promise<ActiveSourceImport[]> {
324
- const imports: ActiveSourceImport[] = [];
331
+ interface ActiveSourceSnapshot {
332
+ readonly files: readonly string[];
333
+ readonly imports: readonly ActiveSourceImport[];
334
+ }
335
+
336
+ /***
337
+ * Collects active production files and their static import specifiers once for repository checks.
338
+ */
339
+ async function collectActiveSourceSnapshot(targetPath: string): Promise<ActiveSourceSnapshot> {
340
+ const files: string[] = [];
325
341
 
326
342
  for (const sourceRoot of ACTIVE_SOURCE_ROOTS) {
327
343
  const sourceRootPath = path.join(targetPath, sourceRoot);
328
- if (!(await pathExists(sourceRootPath))) {
329
- continue;
330
- }
331
-
332
- for (const filePath of await listActiveSourceFiles(sourceRootPath)) {
333
- const source = await fs.readFile(filePath, 'utf8');
334
- for (const specifier of extractImportSpecifiers(source)) {
335
- imports.push({ filePath, specifier });
336
- }
344
+ if (await pathExists(sourceRootPath)) {
345
+ files.push(...(await listActiveSourceFiles(sourceRootPath)));
337
346
  }
338
347
  }
339
348
 
340
- return imports;
349
+ const imports = (
350
+ await Promise.all(
351
+ files.map(async (filePath) => {
352
+ const source = await fs.readFile(filePath, 'utf8');
353
+ return extractImportSpecifiers(source).map((specifier) => ({ filePath, specifier }));
354
+ }),
355
+ )
356
+ ).flat();
357
+
358
+ return {
359
+ files: [...files].sort((left, right) => left.localeCompare(right)),
360
+ imports,
361
+ };
341
362
  }
342
363
 
343
364
  async function listActiveSourceFiles(rootPath: string): Promise<string[]> {
@@ -428,11 +449,11 @@ function isPackageImport(specifier: string, packageName: string): boolean {
428
449
  return specifier === packageName || specifier.startsWith(`${packageName}/`);
429
450
  }
430
451
 
431
- /*** Resolve Policy-owned rule metadata for generic architecture diagnostics. */
432
- function architectureRuleFields(
452
+ /*** Resolve repository Rules metadata for package and source-boundary diagnostics. */
453
+ function repositoryRuleFields(
433
454
  ruleId: DoctorDiagnostic['ruleId'],
434
455
  ): Pick<DoctorDiagnostic, 'ruleId' | 'severity'> {
435
- const rule = getArchitecturePolicyRule(ruleId);
456
+ const rule = getRepositoryRule(ruleId);
436
457
  return { ruleId: rule.id, severity: rule.severity };
437
458
  }
438
459
 
@@ -0,0 +1,153 @@
1
+ import path from 'node:path';
2
+
3
+ import {
4
+ evaluateRepository,
5
+ REPOSITORY_RULE_IDS,
6
+ type RepositoryImportFact,
7
+ type RepositoryPathFact,
8
+ } from '@ankhorage/rules-repository';
9
+
10
+ import type {
11
+ DoctorDiagnostic,
12
+ DoctorDiagnosticSeverity,
13
+ DoctorPolicyProfile,
14
+ DoctorRuleId,
15
+ } from './diagnostics.js';
16
+
17
+ const ENABLED_REPOSITORY_SOURCE_RULES = [
18
+ REPOSITORY_RULE_IDS.catchAllDirectory,
19
+ REPOSITORY_RULE_IDS.importOutsideRoot,
20
+ ] as const satisfies readonly DoctorRuleId[];
21
+
22
+ interface RepositorySourceRulesInput {
23
+ readonly files: readonly string[];
24
+ readonly imports: readonly {
25
+ readonly filePath: string;
26
+ readonly specifier: string;
27
+ }[];
28
+ readonly profile: DoctorPolicyProfile;
29
+ readonly targetPath: string;
30
+ }
31
+
32
+ /***
33
+ * Evaluates repository-owned source rules from Doctor's normalized active-source snapshot.
34
+ */
35
+ export function analyzeRepositorySourceRules(
36
+ input: RepositorySourceRulesInput,
37
+ ): readonly DoctorDiagnostic[] {
38
+ const result = evaluateRepository(
39
+ {
40
+ cliCommands: [],
41
+ imports: input.imports.map((sourceImport) => toImportFact(input.targetPath, sourceImport)),
42
+ packageJson: {
43
+ dependencies: [],
44
+ exports: [],
45
+ fields: {},
46
+ scripts: {},
47
+ },
48
+ paths: collectDirectoryFacts(input.targetPath, input.files),
49
+ workflowBunVersions: {},
50
+ },
51
+ {
52
+ config: {
53
+ version: 1,
54
+ rules: ENABLED_REPOSITORY_SOURCE_RULES.map((id) => ({ enabled: true, id })),
55
+ },
56
+ },
57
+ );
58
+
59
+ return result.findings.map((finding) => toDoctorDiagnostic(input, finding));
60
+ }
61
+
62
+ /***
63
+ * Converts one active source import into portable repository-boundary evidence.
64
+ */
65
+ function toImportFact(
66
+ targetPath: string,
67
+ sourceImport: RepositorySourceRulesInput['imports'][number],
68
+ ): RepositoryImportFact {
69
+ return {
70
+ path: toPortableRelativePath(targetPath, sourceImport.filePath),
71
+ specifier: sourceImport.specifier,
72
+ ...(sourceImport.specifier.startsWith('.')
73
+ ? { escapesRepositoryRoot: importEscapesRepository(targetPath, sourceImport) }
74
+ : {}),
75
+ };
76
+ }
77
+
78
+ /***
79
+ * Determines whether one relative source import resolves outside the standalone repository root.
80
+ */
81
+ function importEscapesRepository(
82
+ targetPath: string,
83
+ sourceImport: RepositorySourceRulesInput['imports'][number],
84
+ ): boolean {
85
+ const resolvedTarget = path.resolve(path.dirname(sourceImport.filePath), sourceImport.specifier);
86
+ const relativeTarget = path.relative(targetPath, resolvedTarget);
87
+ return relativeTarget.startsWith('..') || path.isAbsolute(relativeTarget);
88
+ }
89
+
90
+ /***
91
+ * Derives unique directory facts from active production source files.
92
+ */
93
+ function collectDirectoryFacts(
94
+ targetPath: string,
95
+ files: readonly string[],
96
+ ): readonly RepositoryPathFact[] {
97
+ const directories = new Set<string>();
98
+
99
+ for (const filePath of files) {
100
+ const segments = toPortableRelativePath(targetPath, filePath).split('/').filter(Boolean);
101
+ for (let index = 1; index < segments.length; index += 1) {
102
+ directories.add(segments.slice(0, index).join('/'));
103
+ }
104
+ }
105
+
106
+ return [...directories]
107
+ .sort((left, right) => left.localeCompare(right))
108
+ .map((directoryPath) => ({ kind: 'directory' as const, path: directoryPath }));
109
+ }
110
+
111
+ /***
112
+ * Converts one repository path into stable forward-slash relative form.
113
+ */
114
+ function toPortableRelativePath(targetPath: string, filePath: string): string {
115
+ return path.relative(targetPath, filePath).split(path.sep).join('/');
116
+ }
117
+
118
+ /***
119
+ * Converts one repository Rule finding into Doctor's stable diagnostic surface.
120
+ */
121
+ function toDoctorDiagnostic(
122
+ input: RepositorySourceRulesInput,
123
+ finding: ReturnType<typeof evaluateRepository>['findings'][number],
124
+ ): DoctorDiagnostic {
125
+ const subjectPath = finding.sourceLocation?.path ?? finding.subjects[0]?.path ?? '.';
126
+ return {
127
+ code: 'field-invalid',
128
+ message: finding.message,
129
+ path: path.resolve(input.targetPath, subjectPath),
130
+ profile: input.profile,
131
+ ruleId: toDoctorRuleId(finding.ruleId),
132
+ severity: toDoctorSeverity(finding.severity),
133
+ };
134
+ }
135
+
136
+ /***
137
+ * Narrows repository-provider ids to the source rules Doctor intentionally evaluates here.
138
+ */
139
+ function toDoctorRuleId(ruleId: string): DoctorRuleId {
140
+ const match = ENABLED_REPOSITORY_SOURCE_RULES.find((candidate) => candidate === ruleId);
141
+ if (match === undefined) throw new Error('Unexpected Doctor repository source rule: ' + ruleId);
142
+ return match;
143
+ }
144
+
145
+ /***
146
+ * Narrows generic Rules severity to Doctor's diagnostic severity surface.
147
+ */
148
+ function toDoctorSeverity(severity: 'error' | 'info' | 'warning'): DoctorDiagnosticSeverity {
149
+ if (severity === 'info') {
150
+ throw new Error('Doctor does not support info-level repository diagnostics.');
151
+ }
152
+ return severity;
153
+ }
@@ -1,205 +1,130 @@
1
- import { promises as fs } from 'node:fs';
2
1
  import path from 'node:path';
3
2
 
4
- import { ARCHITECTURE_POLICY } from '@ankhorage/policy/architecture';
5
-
6
- import type { DoctorDiagnostic, DoctorPolicyProfile } from './diagnostics.js';
7
- import { getArchitecturePolicyRule } from './utils/getArchitecturePolicyRule.js';
8
-
9
- interface SourceImport {
10
- readonly filePath: string;
11
- readonly specifier: string;
12
- }
3
+ import { createSourceGraphAsync, type SourceGraph } from '@ankhorage/dependency-graph';
4
+ import { evaluateArchitectureProfile } from '@ankhorage/rules-architecture';
5
+
6
+ import type {
7
+ DoctorDiagnostic,
8
+ DoctorDiagnosticSeverity,
9
+ DoctorPolicyProfile,
10
+ DoctorRuleId,
11
+ } from './diagnostics.js';
12
+
13
+ const ENABLED_ARCHITECTURE_RULES = [
14
+ 'package.architecture.domain-outward-import.disallowed',
15
+ 'package.architecture.application-outward-import.disallowed',
16
+ 'package.architecture.port-outward-import.disallowed',
17
+ 'package.architecture.delivery-concrete-adapter-import.disallowed',
18
+ ] as const satisfies readonly DoctorRuleId[];
19
+
20
+ const ACTIVE_SOURCE_ROOTS = ['src', 'app', 'apps', 'packages', 'scripts'] as const;
21
+ const IGNORED_SOURCE_DIRECTORIES = new Set([
22
+ '.expo',
23
+ '.git',
24
+ '.next',
25
+ '__fixtures__',
26
+ '__tests__',
27
+ 'build',
28
+ 'coverage',
29
+ 'dist',
30
+ 'docs',
31
+ 'fixtures',
32
+ 'node_modules',
33
+ 'paradox',
34
+ 'test',
35
+ 'tests',
36
+ ]);
13
37
 
14
38
  interface AnalyzeSourceArchitectureInput {
15
- readonly activeSourceImports: readonly SourceImport[];
16
39
  readonly profile: DoctorPolicyProfile;
17
40
  readonly targetPath: string;
18
41
  }
19
42
 
20
- const SOURCE_POLICY = ARCHITECTURE_POLICY.source;
21
-
22
- /*** Validate folder-role combinations and inward source dependency direction. */
43
+ /***
44
+ * Evaluates Doctor's currently enforced source-architecture subset through rules-architecture.
45
+ */
23
46
  export async function analyzeSourceArchitecture(
24
47
  input: AnalyzeSourceArchitectureInput,
25
48
  ): Promise<DoctorDiagnostic[]> {
26
- return [...(await analyzeDirectoryVocabularyAsync(input)), ...analyzeImportDirection(input)];
27
- }
28
-
29
- /*** Validate architectural directory names only when a repository has introduced them. */
30
- async function analyzeDirectoryVocabularyAsync(
31
- input: AnalyzeSourceArchitectureInput,
32
- ): Promise<DoctorDiagnostic[]> {
33
- const diagnostics: DoctorDiagnostic[] = [];
34
- const sourceRoot = path.join(input.targetPath, 'src');
35
-
36
- for (const directoryName of SOURCE_POLICY.catchAllDirectories) {
37
- const directoryPath = path.join(sourceRoot, directoryName);
38
- if (await pathExistsAsync(directoryPath)) {
39
- diagnostics.push(
40
- createDiagnostic(
41
- input,
42
- directoryPath,
43
- ARCHITECTURE_POLICY.rules.catchAllDirectory,
44
- `src/${directoryName}/ is a generic catch-all. Move code to an owning domain, feature, package edge, or utils/ according to the repository profile.`,
45
- ),
46
- );
47
- }
48
- }
49
-
50
- // Folder-role combination enforcement stays deferred until rules-architecture is canonical.
51
- return diagnostics;
52
- }
53
-
54
- /*** Validate that relative source imports point inward across recognized architecture roles. */
55
- function analyzeImportDirection(input: AnalyzeSourceArchitectureInput): DoctorDiagnostic[] {
56
- return input.activeSourceImports.flatMap((sourceImport) =>
57
- analyzeOneImportDirection(input, sourceImport),
58
- );
59
- }
60
-
61
- /*** Validate one relative import against repository and layer boundaries. */
62
- function analyzeOneImportDirection(
63
- input: AnalyzeSourceArchitectureInput,
64
- sourceImport: SourceImport,
65
- ): DoctorDiagnostic[] {
66
- if (!sourceImport.specifier.startsWith('.')) return [];
67
-
68
- const targetPath = path.resolve(path.dirname(sourceImport.filePath), sourceImport.specifier);
69
- const escaped = createEscapedRepositoryDiagnostic(input, sourceImport, targetPath);
70
- if (escaped !== null) return [escaped];
71
-
72
- return analyzeRecognizedRoleDirection(input, sourceImport, targetPath);
73
- }
74
-
75
- /*** Report a relative import that escapes the standalone repository boundary. */
76
- function createEscapedRepositoryDiagnostic(
77
- input: AnalyzeSourceArchitectureInput,
78
- sourceImport: SourceImport,
79
- targetPath: string,
80
- ): DoctorDiagnostic | null {
81
- const relativeTarget = path.relative(input.targetPath, targetPath);
82
- if (!relativeTarget.startsWith('..') && !path.isAbsolute(relativeTarget)) return null;
83
-
84
- return createDiagnostic(
85
- input,
86
- sourceImport.filePath,
87
- getArchitecturePolicyRule(SOURCE_POLICY.repositoryBoundaryRuleId),
88
- `Relative import "${sourceImport.specifier}" escapes the standalone repository root.`,
89
- );
49
+ const graph = await createSourceGraphAsync({
50
+ projects: [{ id: 'doctor-target', rootPath: input.targetPath }],
51
+ });
52
+ const result = evaluateArchitectureProfile(filterActiveSourceGraph(graph), 'ankhorage', {
53
+ config: {
54
+ version: 1,
55
+ rules: ENABLED_ARCHITECTURE_RULES.map((id) => ({ enabled: true, id })),
56
+ },
57
+ });
58
+
59
+ return result.findings.map((finding) => toDoctorDiagnostic(input, finding));
90
60
  }
91
61
 
92
- /*** Apply inward dependency rules for recognized source architecture roles. */
93
- function analyzeRecognizedRoleDirection(
94
- input: AnalyzeSourceArchitectureInput,
95
- sourceImport: SourceImport,
96
- targetPath: string,
97
- ): DoctorDiagnostic[] {
98
- const sourceSegments = splitRelativePath(input.targetPath, sourceImport.filePath);
99
- const targetSegments = splitRelativePath(input.targetPath, targetPath);
100
- const deliveryPolicy = SOURCE_POLICY.thinDeliveryAdapter;
101
-
102
- if (
103
- includesSegmentSequence(sourceSegments, deliveryPolicy.pathSegments) &&
104
- targetSegments.includes(deliveryPolicy.concreteAdapterSegment)
105
- ) {
106
- return [
107
- createDiagnostic(
108
- input,
109
- sourceImport.filePath,
110
- getArchitecturePolicyRule(deliveryPolicy.ruleId),
111
- `Thin delivery adapter must not wire concrete adapter implementation through "${sourceImport.specifier}". Import an application operation or composition boundary instead.`,
112
- ),
113
- ];
114
- }
115
-
116
- const role = resolveSourceRole(sourceSegments);
117
- if (role === null) return [];
118
-
119
- return createOutwardImportDiagnostic(input, sourceImport, targetSegments, role);
62
+ /***
63
+ * Keeps architecture evaluation aligned with Doctor's existing active-source scan contract.
64
+ */
65
+ function filterActiveSourceGraph(graph: SourceGraph): SourceGraph {
66
+ const nodeById = new Map(graph.graph.nodes.map((node) => [node.id, node.data]));
67
+ return {
68
+ ...graph,
69
+ graph: {
70
+ nodes: graph.graph.nodes,
71
+ edges: graph.graph.edges.filter((edge) => {
72
+ if (edge.data.kind !== 'imports') return true;
73
+ const source = nodeById.get(edge.source);
74
+ const sourcePath = source?.path ?? source?.filePath;
75
+ return sourcePath === undefined || isActiveSourcePath(sourcePath);
76
+ }),
77
+ },
78
+ };
120
79
  }
121
80
 
122
- /*** Resolve the dependency rule owned by one recognized inner source role. */
123
- function resolveSourceRole(sourceSegments: readonly string[]): SourceRoleRule | null {
124
- for (const role of Object.values(SOURCE_POLICY.roles)) {
125
- if (
126
- sourceSegments.some((segment) => role.segments.some((roleSegment) => roleSegment === segment))
127
- ) {
128
- return {
129
- forbiddenSegments: new Set<string>(role.forbiddenOutwardSegments),
130
- label: role.label,
131
- rule: getArchitecturePolicyRule(role.ruleId),
132
- };
133
- }
81
+ /***
82
+ * Checks whether one portable path belongs to Doctor's active production-source surface.
83
+ */
84
+ function isActiveSourcePath(sourcePath: string): boolean {
85
+ const normalized = sourcePath.split(path.sep).join('/');
86
+ const [root, ...segments] = normalized.split('/').filter(Boolean);
87
+ if (root === undefined || !ACTIVE_SOURCE_ROOTS.some((candidate) => candidate === root)) {
88
+ return false;
134
89
  }
135
-
136
- return null;
137
- }
138
-
139
- interface SourceRoleRule {
140
- readonly forbiddenSegments: ReadonlySet<string>;
141
- readonly label: string;
142
- readonly rule: ReturnType<typeof getArchitecturePolicyRule>;
90
+ if (segments.some((segment) => IGNORED_SOURCE_DIRECTORIES.has(segment))) return false;
91
+ const fileName = segments.at(-1) ?? '';
92
+ return !/(?:^|\.)(?:spec|test)\.[cm]?[jt]sx?$/u.test(fileName);
143
93
  }
144
94
 
145
- /*** Report one outward dependency when the imported path crosses a forbidden role. */
146
- function createOutwardImportDiagnostic(
95
+ /***
96
+ * Converts one architecture Rule finding into Doctor's stable diagnostic surface.
97
+ */
98
+ function toDoctorDiagnostic(
147
99
  input: AnalyzeSourceArchitectureInput,
148
- sourceImport: SourceImport,
149
- targetSegments: readonly string[],
150
- role: SourceRoleRule,
151
- ): DoctorDiagnostic[] {
152
- const outwardRole = targetSegments.find((segment) => role.forbiddenSegments.has(segment));
153
- if (outwardRole === undefined) return [];
154
-
155
- return [
156
- createDiagnostic(
157
- input,
158
- sourceImport.filePath,
159
- role.rule,
160
- `${role.label} must not import outward ${outwardRole}/ implementation through "${sourceImport.specifier}".`,
161
- ),
162
- ];
163
- }
164
-
165
- /*** Check whether one path segment sequence occurs contiguously in another. */
166
- function includesSegmentSequence(
167
- segments: readonly string[],
168
- expected: readonly string[],
169
- ): boolean {
170
- return segments.some((_, index) =>
171
- expected.every((segment, offset) => segments[index + offset] === segment),
172
- );
173
- }
174
-
175
- /*** Split one repository path into normalized architecture segments. */
176
- function splitRelativePath(targetPath: string, filePath: string): readonly string[] {
177
- return path.relative(targetPath, filePath).split(path.sep).filter(Boolean);
178
- }
179
-
180
- /*** Build one deterministic architecture diagnostic from Policy-owned metadata. */
181
- function createDiagnostic(
182
- input: AnalyzeSourceArchitectureInput,
183
- diagnosticPath: string,
184
- rule: ReturnType<typeof getArchitecturePolicyRule>,
185
- message: string,
100
+ finding: ReturnType<typeof evaluateArchitectureProfile>['findings'][number],
186
101
  ): DoctorDiagnostic {
102
+ const subjectPath = finding.sourceLocation?.path ?? finding.subjects[0]?.path ?? '.';
187
103
  return {
188
104
  code: 'field-invalid',
189
- message,
190
- path: diagnosticPath,
105
+ message: finding.message,
106
+ path: path.resolve(input.targetPath, subjectPath),
191
107
  profile: input.profile,
192
- ruleId: rule.id,
193
- severity: rule.severity,
108
+ ruleId: toDoctorRuleId(finding.ruleId),
109
+ severity: toDoctorSeverity(finding.severity),
194
110
  };
195
111
  }
196
112
 
197
- /*** Check whether a filesystem path exists. */
198
- async function pathExistsAsync(targetPath: string): Promise<boolean> {
199
- try {
200
- await fs.access(targetPath);
201
- return true;
202
- } catch {
203
- return false;
113
+ /***
114
+ * Narrows architecture-provider rule ids to the subset Doctor intentionally enforces.
115
+ */
116
+ function toDoctorRuleId(ruleId: string): DoctorRuleId {
117
+ const match = ENABLED_ARCHITECTURE_RULES.find((candidate) => candidate === ruleId);
118
+ if (match === undefined) throw new Error('Unexpected Doctor architecture rule: ' + ruleId);
119
+ return match;
120
+ }
121
+
122
+ /***
123
+ * Narrows generic Rules severity to Doctor's diagnostic severity surface.
124
+ */
125
+ function toDoctorSeverity(severity: 'error' | 'info' | 'warning'): DoctorDiagnosticSeverity {
126
+ if (severity === 'info') {
127
+ throw new Error('Doctor does not support info-level architecture diagnostics.');
204
128
  }
129
+ return severity;
205
130
  }
@@ -0,0 +1,20 @@
1
+ import { createRepositoryRuleSet } from '@ankhorage/rules-repository';
2
+
3
+ import type { DoctorDiagnosticSeverity, DoctorRuleId } from '../diagnostics.js';
4
+
5
+ /***
6
+ * Resolves one canonical repository rule by its stable Doctor rule id.
7
+ */
8
+ export function getRepositoryRule(ruleId: DoctorRuleId): {
9
+ readonly id: DoctorRuleId;
10
+ readonly severity: DoctorDiagnosticSeverity;
11
+ } {
12
+ const rule = createRepositoryRuleSet().rules.find((candidate) => candidate.id === ruleId);
13
+ if (rule === undefined) {
14
+ throw new Error('Unknown repository rule: ' + ruleId);
15
+ }
16
+ if (rule.defaultSeverity === 'info') {
17
+ throw new Error('Doctor does not support info-level repository diagnostics: ' + ruleId);
18
+ }
19
+ return { id: ruleId, severity: rule.defaultSeverity };
20
+ }