@ankhorage/doctor 0.11.95 → 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.
- package/CHANGELOG.md +14 -0
- package/README.md +1 -1
- package/dist/analysis.js +22 -22
- package/dist/analysis.js.map +1 -1
- package/dist/bunPolicyAnalysis.d.ts.map +1 -1
- package/dist/bunPolicyAnalysis.js +5 -14
- package/dist/bunPolicyAnalysis.js.map +1 -1
- package/dist/cliLayoutAnalysis.d.ts.map +1 -1
- package/dist/cliLayoutAnalysis.js +39 -29
- package/dist/cliLayoutAnalysis.js.map +1 -1
- package/dist/repositorySourceRulesAnalysis.d.ts +16 -0
- package/dist/repositorySourceRulesAnalysis.d.ts.map +1 -0
- package/dist/repositorySourceRulesAnalysis.js +103 -0
- package/dist/repositorySourceRulesAnalysis.js.map +1 -0
- package/dist/sourceArchitectureAnalysis.d.ts +3 -6
- package/dist/sourceArchitectureAnalysis.d.ts.map +1 -1
- package/dist/sourceArchitectureAnalysis.js +93 -97
- package/dist/sourceArchitectureAnalysis.js.map +1 -1
- package/dist/utils/getRepositoryRule.d.ts +9 -0
- package/dist/utils/getRepositoryRule.d.ts.map +1 -0
- package/dist/utils/getRepositoryRule.js +15 -0
- package/dist/utils/getRepositoryRule.js.map +1 -0
- package/package.json +7 -5
- package/src/analysis.ts +22 -24
- package/src/bunPolicyAnalysis.ts +7 -17
- package/src/cliLayoutAnalysis.ts +51 -30
- package/src/repositorySourceRulesAnalysis.ts +153 -0
- package/src/sourceArchitectureAnalysis.ts +102 -177
- package/src/utils/getRepositoryRule.ts +20 -0
- package/dist/utils/getArchitecturePolicyRule.d.ts +0 -286
- package/dist/utils/getArchitecturePolicyRule.d.ts.map +0 -1
- package/dist/utils/getArchitecturePolicyRule.js +0 -12
- package/dist/utils/getArchitecturePolicyRule.js.map +0 -1
- package/src/utils/getArchitecturePolicyRule.ts +0 -15
package/src/cliLayoutAnalysis.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { promises as fs } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
|
-
import {
|
|
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 {
|
|
15
|
+
import { getRepositoryRule } from './utils/getRepositoryRule.js';
|
|
15
16
|
|
|
16
|
-
const CLI_POLICY =
|
|
17
|
-
const DEPENDENCY_POLICY =
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
-
|
|
324
|
-
|
|
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 (
|
|
329
|
-
|
|
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
|
-
|
|
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
|
|
432
|
-
function
|
|
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 =
|
|
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 {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
):
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
/***
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
/***
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
/***
|
|
146
|
-
|
|
95
|
+
/***
|
|
96
|
+
* Converts one architecture Rule finding into Doctor's stable diagnostic surface.
|
|
97
|
+
*/
|
|
98
|
+
function toDoctorDiagnostic(
|
|
147
99
|
input: AnalyzeSourceArchitectureInput,
|
|
148
|
-
|
|
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:
|
|
105
|
+
message: finding.message,
|
|
106
|
+
path: path.resolve(input.targetPath, subjectPath),
|
|
191
107
|
profile: input.profile,
|
|
192
|
-
ruleId:
|
|
193
|
-
severity:
|
|
108
|
+
ruleId: toDoctorRuleId(finding.ruleId),
|
|
109
|
+
severity: toDoctorSeverity(finding.severity),
|
|
194
110
|
};
|
|
195
111
|
}
|
|
196
112
|
|
|
197
|
-
/***
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
+
}
|