@ankhorage/doctor 0.2.1 → 0.3.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 +6 -0
- package/README.md +12 -4
- package/dist/analysis.d.ts +18 -1
- package/dist/analysis.d.ts.map +1 -1
- package/dist/analysis.js +786 -57
- package/dist/analysis.js.map +1 -1
- package/dist/commands.d.ts +2 -2
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +19 -4
- package/dist/commands.js.map +1 -1
- package/dist/diagnostics.d.ts +5 -1
- package/dist/diagnostics.d.ts.map +1 -1
- package/dist/diagnostics.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/readme-usage.js +11 -3
- package/dist/readme-usage.js.map +1 -1
- package/package.json +1 -1
- package/src/analysis.ts +1082 -145
- package/src/commands.ts +22 -5
- package/src/diagnostics.ts +67 -10
- package/src/index.ts +5 -0
- package/src/readme-usage.ts +11 -3
package/src/analysis.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { promises as fs } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
3
4
|
|
|
4
|
-
import type { DoctorDiagnostic } from './diagnostics.js';
|
|
5
|
+
import type { DoctorDiagnostic, DoctorPolicyProfile, DoctorRuleId } from './diagnostics.js';
|
|
5
6
|
|
|
6
7
|
export type DoctorTargetCheck = 'package' | 'repo';
|
|
7
8
|
export type DoctorTargetMode = 'fix' | 'package' | 'repo' | 'validate';
|
|
9
|
+
export type DoctorPlannedChangeKind =
|
|
10
|
+
'create-directory' | 'create-file' | 'update-json' | 'update-text';
|
|
8
11
|
|
|
9
12
|
export interface DoctorAnalysisRequest {
|
|
10
13
|
readonly cwd: string;
|
|
@@ -12,14 +15,40 @@ export interface DoctorAnalysisRequest {
|
|
|
12
15
|
readonly mode: DoctorTargetMode;
|
|
13
16
|
}
|
|
14
17
|
|
|
18
|
+
export interface DoctorPlannedChange {
|
|
19
|
+
readonly description: string;
|
|
20
|
+
readonly filePath: string;
|
|
21
|
+
readonly kind: DoctorPlannedChangeKind;
|
|
22
|
+
readonly ruleId: DoctorRuleId;
|
|
23
|
+
readonly safe: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface DoctorFixPlan {
|
|
27
|
+
readonly changes: readonly DoctorPlannedChange[];
|
|
28
|
+
readonly diagnostics: readonly DoctorDiagnostic[];
|
|
29
|
+
readonly profile: DoctorPolicyProfile;
|
|
30
|
+
readonly targetPath: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
15
33
|
export interface DoctorAnalysisResult {
|
|
16
34
|
readonly appliedChecks: readonly DoctorTargetCheck[];
|
|
17
35
|
readonly diagnostics: readonly DoctorDiagnostic[];
|
|
36
|
+
readonly fixPlan: DoctorFixPlan | null;
|
|
18
37
|
readonly hasPackageJson: boolean;
|
|
38
|
+
readonly plannedChanges: readonly DoctorPlannedChange[];
|
|
39
|
+
readonly profile: DoctorPolicyProfile;
|
|
19
40
|
readonly repoMarkers: readonly string[];
|
|
20
41
|
readonly targetPath: string;
|
|
21
42
|
}
|
|
22
43
|
|
|
44
|
+
interface ProviderInspectionResult {
|
|
45
|
+
readonly capabilities: readonly string[];
|
|
46
|
+
readonly commandCapabilities: readonly string[];
|
|
47
|
+
readonly commandPaths: readonly string[];
|
|
48
|
+
readonly handlerPaths: readonly string[];
|
|
49
|
+
readonly sourcePath: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
23
52
|
const REPO_MARKERS = [
|
|
24
53
|
{ label: '.git', relativePath: '.git' },
|
|
25
54
|
{ label: '.github/workflows', relativePath: path.join('.github', 'workflows') },
|
|
@@ -32,6 +61,28 @@ const REPO_MARKERS = [
|
|
|
32
61
|
{ label: 'yarn.lock', relativePath: 'yarn.lock' },
|
|
33
62
|
] as const;
|
|
34
63
|
|
|
64
|
+
const REQUIRED_PUBLIC_PACKAGE_SCRIPTS = [
|
|
65
|
+
'build',
|
|
66
|
+
'typecheck',
|
|
67
|
+
'lint',
|
|
68
|
+
'lint:fix',
|
|
69
|
+
'format',
|
|
70
|
+
'format:check',
|
|
71
|
+
'test',
|
|
72
|
+
'knip',
|
|
73
|
+
'docs',
|
|
74
|
+
'changeset',
|
|
75
|
+
'changeset:status',
|
|
76
|
+
'version-packages',
|
|
77
|
+
] as const;
|
|
78
|
+
|
|
79
|
+
const PROVIDER_SOURCE_CANDIDATES = [
|
|
80
|
+
path.join('src', 'ankh.provider.ts'),
|
|
81
|
+
path.join('src', 'ankh.provider.tsx'),
|
|
82
|
+
path.join('src', 'ankh.provider.js'),
|
|
83
|
+
path.join('src', 'ankh.provider.mjs'),
|
|
84
|
+
] as const;
|
|
85
|
+
|
|
35
86
|
export async function analyzeDoctorTarget(
|
|
36
87
|
request: DoctorAnalysisRequest,
|
|
37
88
|
): Promise<DoctorAnalysisResult> {
|
|
@@ -39,51 +90,46 @@ export async function analyzeDoctorTarget(
|
|
|
39
90
|
const stats = await statOrNull(targetPath);
|
|
40
91
|
|
|
41
92
|
if (stats === null) {
|
|
42
|
-
return
|
|
43
|
-
appliedChecks: [],
|
|
44
|
-
diagnostics: [
|
|
45
|
-
createDiagnostic(
|
|
46
|
-
'error',
|
|
47
|
-
'target-not-found',
|
|
48
|
-
targetPath,
|
|
49
|
-
`Target path does not exist: ${targetPath}`,
|
|
50
|
-
),
|
|
51
|
-
],
|
|
52
|
-
hasPackageJson: false,
|
|
53
|
-
repoMarkers: [],
|
|
93
|
+
return createEarlyResult(
|
|
54
94
|
targetPath,
|
|
55
|
-
|
|
95
|
+
createDiagnostic({
|
|
96
|
+
code: 'target-not-found',
|
|
97
|
+
message: `Target path does not exist: ${targetPath}`,
|
|
98
|
+
path: targetPath,
|
|
99
|
+
profile: 'unknown',
|
|
100
|
+
ruleId: 'target.path.exists',
|
|
101
|
+
severity: 'error',
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
56
104
|
}
|
|
57
105
|
|
|
58
106
|
if (!stats.isDirectory()) {
|
|
59
|
-
return
|
|
60
|
-
appliedChecks: [],
|
|
61
|
-
diagnostics: [
|
|
62
|
-
createDiagnostic(
|
|
63
|
-
'error',
|
|
64
|
-
'target-not-directory',
|
|
65
|
-
targetPath,
|
|
66
|
-
`Target path must be a directory: ${targetPath}`,
|
|
67
|
-
),
|
|
68
|
-
],
|
|
69
|
-
hasPackageJson: false,
|
|
70
|
-
repoMarkers: [],
|
|
107
|
+
return createEarlyResult(
|
|
71
108
|
targetPath,
|
|
72
|
-
|
|
109
|
+
createDiagnostic({
|
|
110
|
+
code: 'target-not-directory',
|
|
111
|
+
message: `Target path must be a directory: ${targetPath}`,
|
|
112
|
+
path: targetPath,
|
|
113
|
+
profile: 'unknown',
|
|
114
|
+
ruleId: 'target.path.directory',
|
|
115
|
+
severity: 'error',
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
73
118
|
}
|
|
74
119
|
|
|
75
|
-
const repoMarkers = (
|
|
76
|
-
await Promise.all(
|
|
77
|
-
REPO_MARKERS.map(async (marker) =>
|
|
78
|
-
(await pathExists(path.join(targetPath, marker.relativePath))) ? marker.label : null,
|
|
79
|
-
),
|
|
80
|
-
)
|
|
81
|
-
).filter((marker): marker is (typeof REPO_MARKERS)[number]['label'] => marker !== null);
|
|
82
|
-
|
|
120
|
+
const repoMarkers = await findRepoMarkers(targetPath);
|
|
83
121
|
const packageJsonPath = path.join(targetPath, 'package.json');
|
|
84
122
|
const hasPackageJson = repoMarkers.includes('package.json');
|
|
85
123
|
const appliedChecks: DoctorTargetCheck[] = [];
|
|
86
124
|
const diagnostics: DoctorDiagnostic[] = [];
|
|
125
|
+
const plannedChanges: DoctorPlannedChange[] = [];
|
|
126
|
+
|
|
127
|
+
const packageJson = hasPackageJson ? await readPackageJson(packageJsonPath) : null;
|
|
128
|
+
if (packageJson !== null && packageJson.diagnostic !== null) {
|
|
129
|
+
diagnostics.push(packageJson.diagnostic);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const profile = detectProfile(repoMarkers, packageJson?.parsed ?? null);
|
|
87
133
|
|
|
88
134
|
const shouldRunRepoChecks =
|
|
89
135
|
request.mode === 'repo' ||
|
|
@@ -94,23 +140,28 @@ export async function analyzeDoctorTarget(
|
|
|
94
140
|
|
|
95
141
|
if (request.mode === 'repo' && repoMarkers.length === 0) {
|
|
96
142
|
diagnostics.push(
|
|
97
|
-
createDiagnostic(
|
|
98
|
-
'
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
143
|
+
createDiagnostic({
|
|
144
|
+
code: 'unsupported-target',
|
|
145
|
+
message:
|
|
146
|
+
'Target is not a repo/workspace-root candidate because no repo markers were found.',
|
|
147
|
+
path: targetPath,
|
|
148
|
+
profile,
|
|
149
|
+
ruleId: 'target.repo-markers.required',
|
|
150
|
+
severity: 'error',
|
|
151
|
+
}),
|
|
103
152
|
);
|
|
104
153
|
}
|
|
105
154
|
|
|
106
155
|
if (request.mode === 'package' && !hasPackageJson) {
|
|
107
156
|
diagnostics.push(
|
|
108
|
-
createDiagnostic(
|
|
109
|
-
'
|
|
110
|
-
'package
|
|
111
|
-
packageJsonPath,
|
|
112
|
-
|
|
113
|
-
|
|
157
|
+
createDiagnostic({
|
|
158
|
+
code: 'unsupported-target',
|
|
159
|
+
message: 'Target does not contain a readable package.json file.',
|
|
160
|
+
path: packageJsonPath,
|
|
161
|
+
profile,
|
|
162
|
+
ruleId: 'target.package-json.required',
|
|
163
|
+
severity: 'error',
|
|
164
|
+
}),
|
|
114
165
|
);
|
|
115
166
|
}
|
|
116
167
|
|
|
@@ -120,144 +171,837 @@ export async function analyzeDoctorTarget(
|
|
|
120
171
|
!hasPackageJson
|
|
121
172
|
) {
|
|
122
173
|
diagnostics.push(
|
|
123
|
-
createDiagnostic(
|
|
124
|
-
'
|
|
125
|
-
'
|
|
126
|
-
targetPath,
|
|
127
|
-
|
|
128
|
-
|
|
174
|
+
createDiagnostic({
|
|
175
|
+
code: 'unsupported-target',
|
|
176
|
+
message: 'Target is neither a repo/workspace-root candidate nor a package root.',
|
|
177
|
+
path: targetPath,
|
|
178
|
+
profile,
|
|
179
|
+
ruleId: 'target.profile.supported',
|
|
180
|
+
severity: 'error',
|
|
181
|
+
}),
|
|
129
182
|
);
|
|
130
183
|
}
|
|
131
184
|
|
|
132
185
|
if (shouldRunRepoChecks && repoMarkers.length > 0) {
|
|
133
186
|
appliedChecks.push('repo');
|
|
134
|
-
diagnostics.push(
|
|
187
|
+
diagnostics.push(
|
|
188
|
+
...(await analyzeRepoPolicy({
|
|
189
|
+
packageJson: packageJson?.parsed ?? null,
|
|
190
|
+
plannedChanges,
|
|
191
|
+
profile,
|
|
192
|
+
repoMarkers,
|
|
193
|
+
targetPath,
|
|
194
|
+
})),
|
|
195
|
+
);
|
|
135
196
|
}
|
|
136
197
|
|
|
137
198
|
if (shouldRunPackageChecks && hasPackageJson) {
|
|
138
199
|
appliedChecks.push('package');
|
|
139
|
-
diagnostics.push(
|
|
200
|
+
diagnostics.push(
|
|
201
|
+
...(await analyzePackagePolicy({
|
|
202
|
+
packageJson: packageJson?.parsed ?? null,
|
|
203
|
+
packageJsonPath,
|
|
204
|
+
plannedChanges,
|
|
205
|
+
profile,
|
|
206
|
+
targetPath,
|
|
207
|
+
})),
|
|
208
|
+
);
|
|
140
209
|
}
|
|
141
210
|
|
|
211
|
+
const fixPlan =
|
|
212
|
+
request.mode === 'fix'
|
|
213
|
+
? {
|
|
214
|
+
changes: plannedChanges,
|
|
215
|
+
diagnostics,
|
|
216
|
+
profile,
|
|
217
|
+
targetPath,
|
|
218
|
+
}
|
|
219
|
+
: null;
|
|
220
|
+
|
|
142
221
|
return {
|
|
143
222
|
appliedChecks,
|
|
144
223
|
diagnostics,
|
|
224
|
+
fixPlan,
|
|
145
225
|
hasPackageJson,
|
|
226
|
+
plannedChanges,
|
|
227
|
+
profile,
|
|
146
228
|
repoMarkers,
|
|
147
229
|
targetPath,
|
|
148
230
|
};
|
|
149
231
|
}
|
|
150
232
|
|
|
151
|
-
async function
|
|
233
|
+
async function analyzeRepoPolicy(request: {
|
|
234
|
+
readonly packageJson: Record<string, unknown> | null;
|
|
235
|
+
readonly plannedChanges: DoctorPlannedChange[];
|
|
236
|
+
readonly profile: DoctorPolicyProfile;
|
|
237
|
+
readonly repoMarkers: readonly string[];
|
|
238
|
+
readonly targetPath: string;
|
|
239
|
+
}): Promise<DoctorDiagnostic[]> {
|
|
152
240
|
const diagnostics: DoctorDiagnostic[] = [];
|
|
153
241
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
242
|
+
if (request.profile === 'public-package') {
|
|
243
|
+
await maybeRequirePath(
|
|
244
|
+
diagnostics,
|
|
245
|
+
request.plannedChanges,
|
|
246
|
+
request.targetPath,
|
|
247
|
+
'README.md',
|
|
248
|
+
'repo.readme.required',
|
|
249
|
+
'create-file',
|
|
250
|
+
);
|
|
251
|
+
await maybeRequirePath(
|
|
252
|
+
diagnostics,
|
|
253
|
+
request.plannedChanges,
|
|
254
|
+
request.targetPath,
|
|
255
|
+
'CHANGELOG.md',
|
|
256
|
+
'repo.changelog.required',
|
|
257
|
+
'create-file',
|
|
258
|
+
);
|
|
259
|
+
await maybeRequirePath(
|
|
260
|
+
diagnostics,
|
|
261
|
+
request.plannedChanges,
|
|
262
|
+
request.targetPath,
|
|
263
|
+
'LICENSE',
|
|
264
|
+
'repo.license.required',
|
|
265
|
+
'create-file',
|
|
266
|
+
);
|
|
267
|
+
await maybeRequirePath(
|
|
268
|
+
diagnostics,
|
|
269
|
+
request.plannedChanges,
|
|
270
|
+
request.targetPath,
|
|
271
|
+
'.changeset',
|
|
272
|
+
'repo.changeset.required',
|
|
273
|
+
'create-directory',
|
|
274
|
+
);
|
|
275
|
+
await maybeRequirePath(
|
|
276
|
+
diagnostics,
|
|
277
|
+
request.plannedChanges,
|
|
278
|
+
request.targetPath,
|
|
279
|
+
path.join('.github', 'workflows'),
|
|
280
|
+
'repo.workflows.required',
|
|
281
|
+
'create-directory',
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (request.profile === 'integration-monorepo') {
|
|
286
|
+
if (!request.repoMarkers.includes('package.json')) {
|
|
287
|
+
diagnostics.push(
|
|
288
|
+
createDiagnostic({
|
|
289
|
+
code: 'missing-path',
|
|
290
|
+
message: 'The integration monorepo root must include a package.json file.',
|
|
291
|
+
path: path.join(request.targetPath, 'package.json'),
|
|
292
|
+
profile: request.profile,
|
|
293
|
+
ruleId: 'target.package-json.required',
|
|
294
|
+
severity: 'error',
|
|
295
|
+
}),
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (request.packageJson !== null) {
|
|
300
|
+
if (request.packageJson.ankh !== undefined) {
|
|
301
|
+
diagnostics.push(
|
|
302
|
+
createDiagnostic({
|
|
303
|
+
code: 'field-invalid',
|
|
304
|
+
message:
|
|
305
|
+
'The integration monorepo root must not publish top-level package.json.ankh metadata.',
|
|
306
|
+
path: path.join(request.targetPath, 'package.json'),
|
|
307
|
+
profile: request.profile,
|
|
308
|
+
ruleId: 'integration.package.ankh.disallowed',
|
|
309
|
+
severity: 'error',
|
|
310
|
+
}),
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (request.packageJson.private !== true) {
|
|
315
|
+
diagnostics.push(
|
|
316
|
+
createDiagnostic({
|
|
317
|
+
code: 'field-invalid',
|
|
318
|
+
message: 'The integration monorepo root must remain private.',
|
|
319
|
+
path: path.join(request.targetPath, 'package.json'),
|
|
320
|
+
profile: request.profile,
|
|
321
|
+
ruleId: 'integration.package.private.required',
|
|
322
|
+
severity: 'error',
|
|
323
|
+
}),
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (!Array.isArray(request.packageJson.workspaces)) {
|
|
328
|
+
diagnostics.push(
|
|
329
|
+
createDiagnostic({
|
|
330
|
+
code: 'field-missing',
|
|
331
|
+
message: 'The integration monorepo root must declare workspaces.',
|
|
332
|
+
path: path.join(request.targetPath, 'package.json'),
|
|
333
|
+
profile: request.profile,
|
|
334
|
+
ruleId: 'integration.package.workspaces.required',
|
|
335
|
+
severity: 'error',
|
|
336
|
+
}),
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return diagnostics;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function analyzePackagePolicy(request: {
|
|
346
|
+
readonly packageJson: Record<string, unknown> | null;
|
|
347
|
+
readonly packageJsonPath: string;
|
|
348
|
+
readonly plannedChanges: DoctorPlannedChange[];
|
|
349
|
+
readonly profile: DoctorPolicyProfile;
|
|
350
|
+
readonly targetPath: string;
|
|
351
|
+
}): Promise<DoctorDiagnostic[]> {
|
|
352
|
+
if (request.packageJson === null) {
|
|
353
|
+
return [];
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const diagnostics: DoctorDiagnostic[] = [];
|
|
357
|
+
const { packageJson } = request;
|
|
358
|
+
|
|
359
|
+
if (request.profile === 'public-package') {
|
|
360
|
+
requireNonEmptyStringField({
|
|
361
|
+
diagnostics,
|
|
362
|
+
packageJson,
|
|
363
|
+
packageJsonPath: request.packageJsonPath,
|
|
364
|
+
profile: request.profile,
|
|
365
|
+
ruleId: 'package.json.name.required',
|
|
366
|
+
fieldName: 'name',
|
|
367
|
+
});
|
|
368
|
+
requireNonEmptyStringField({
|
|
369
|
+
diagnostics,
|
|
370
|
+
packageJson,
|
|
371
|
+
packageJsonPath: request.packageJsonPath,
|
|
372
|
+
profile: request.profile,
|
|
373
|
+
ruleId: 'package.json.version.required',
|
|
374
|
+
fieldName: 'version',
|
|
375
|
+
});
|
|
376
|
+
requireNonEmptyStringField({
|
|
377
|
+
diagnostics,
|
|
378
|
+
packageJson,
|
|
379
|
+
packageJsonPath: request.packageJsonPath,
|
|
380
|
+
profile: request.profile,
|
|
381
|
+
ruleId: 'package.json.type.required',
|
|
382
|
+
fieldName: 'type',
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (request.profile !== 'public-package') {
|
|
387
|
+
return diagnostics.concat(
|
|
388
|
+
await validateAnkhMetadataAndProvider({
|
|
389
|
+
diagnostics,
|
|
390
|
+
packageJson,
|
|
391
|
+
packageJsonPath: request.packageJsonPath,
|
|
392
|
+
plannedChanges: request.plannedChanges,
|
|
393
|
+
profile: request.profile,
|
|
394
|
+
targetPath: request.targetPath,
|
|
395
|
+
}),
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
requireNonEmptyStringField({
|
|
158
400
|
diagnostics,
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
401
|
+
packageJson,
|
|
402
|
+
packageJsonPath: request.packageJsonPath,
|
|
403
|
+
profile: request.profile,
|
|
404
|
+
ruleId: 'package.json.description.required',
|
|
405
|
+
fieldName: 'description',
|
|
406
|
+
});
|
|
407
|
+
requireRecordField({
|
|
164
408
|
diagnostics,
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
409
|
+
packageJson,
|
|
410
|
+
packageJsonPath: request.packageJsonPath,
|
|
411
|
+
profile: request.profile,
|
|
412
|
+
ruleId: 'package.json.repository.required',
|
|
413
|
+
fieldName: 'repository',
|
|
414
|
+
});
|
|
415
|
+
requireNonEmptyStringField({
|
|
416
|
+
diagnostics,
|
|
417
|
+
packageJson,
|
|
418
|
+
packageJsonPath: request.packageJsonPath,
|
|
419
|
+
profile: request.profile,
|
|
420
|
+
ruleId: 'package.json.homepage.required',
|
|
421
|
+
fieldName: 'homepage',
|
|
422
|
+
});
|
|
423
|
+
requireRecordField({
|
|
424
|
+
diagnostics,
|
|
425
|
+
packageJson,
|
|
426
|
+
packageJsonPath: request.packageJsonPath,
|
|
427
|
+
profile: request.profile,
|
|
428
|
+
ruleId: 'package.json.bugs.required',
|
|
429
|
+
fieldName: 'bugs',
|
|
430
|
+
});
|
|
431
|
+
requireNonEmptyStringField({
|
|
432
|
+
diagnostics,
|
|
433
|
+
packageJson,
|
|
434
|
+
packageJsonPath: request.packageJsonPath,
|
|
435
|
+
profile: request.profile,
|
|
436
|
+
ruleId: 'package.json.license.required',
|
|
437
|
+
fieldName: 'license',
|
|
438
|
+
});
|
|
439
|
+
requireStringArrayField({
|
|
440
|
+
diagnostics,
|
|
441
|
+
packageJson,
|
|
442
|
+
packageJsonPath: request.packageJsonPath,
|
|
443
|
+
profile: request.profile,
|
|
444
|
+
ruleId: 'package.json.keywords.required',
|
|
445
|
+
fieldName: 'keywords',
|
|
446
|
+
});
|
|
447
|
+
requireStringArrayField({
|
|
448
|
+
diagnostics,
|
|
449
|
+
packageJson,
|
|
450
|
+
packageJsonPath: request.packageJsonPath,
|
|
451
|
+
profile: request.profile,
|
|
452
|
+
ruleId: 'package.json.files.required',
|
|
453
|
+
fieldName: 'files',
|
|
454
|
+
});
|
|
455
|
+
requireRecordField({
|
|
456
|
+
diagnostics,
|
|
457
|
+
packageJson,
|
|
458
|
+
packageJsonPath: request.packageJsonPath,
|
|
459
|
+
profile: request.profile,
|
|
460
|
+
ruleId: 'package.json.exports.required',
|
|
461
|
+
fieldName: 'exports',
|
|
462
|
+
});
|
|
463
|
+
requireRecordField({
|
|
464
|
+
diagnostics,
|
|
465
|
+
packageJson,
|
|
466
|
+
packageJsonPath: request.packageJsonPath,
|
|
467
|
+
profile: request.profile,
|
|
468
|
+
ruleId: 'package.json.publish-config.required',
|
|
469
|
+
fieldName: 'publishConfig',
|
|
470
|
+
});
|
|
169
471
|
|
|
170
|
-
|
|
472
|
+
if (packageJson.private === true) {
|
|
473
|
+
diagnostics.push(
|
|
474
|
+
createDiagnostic({
|
|
475
|
+
code: 'field-invalid',
|
|
476
|
+
message: 'Public package profiles must not set private: true.',
|
|
477
|
+
path: request.packageJsonPath,
|
|
478
|
+
profile: request.profile,
|
|
479
|
+
ruleId: 'package.json.private.public-package-disallowed',
|
|
480
|
+
severity: 'error',
|
|
481
|
+
}),
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (packageJson.type !== undefined && packageJson.type !== 'module') {
|
|
486
|
+
diagnostics.push(
|
|
487
|
+
createDiagnostic({
|
|
488
|
+
code: 'field-invalid',
|
|
489
|
+
message: 'package.json "type" must be "module".',
|
|
490
|
+
path: request.packageJsonPath,
|
|
491
|
+
profile: request.profile,
|
|
492
|
+
ruleId: 'package.json.type.module',
|
|
493
|
+
severity: 'error',
|
|
494
|
+
}),
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const { publishConfig } = packageJson;
|
|
499
|
+
if (isRecord(publishConfig) && publishConfig.access !== 'public') {
|
|
500
|
+
diagnostics.push(
|
|
501
|
+
createDiagnostic({
|
|
502
|
+
code: isNonEmptyString(publishConfig.access) ? 'field-invalid' : 'field-missing',
|
|
503
|
+
message: 'package.json publishConfig.access must exist and equal "public".',
|
|
504
|
+
path: request.packageJsonPath,
|
|
505
|
+
profile: request.profile,
|
|
506
|
+
ruleId: 'package.json.publish-config.public',
|
|
507
|
+
severity: 'error',
|
|
508
|
+
}),
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (!isNonEmptyString(packageJson.packageManager)) {
|
|
513
|
+
diagnostics.push(
|
|
514
|
+
createDiagnostic({
|
|
515
|
+
code: 'field-missing',
|
|
516
|
+
message: 'package.json must define a Bun-aligned packageManager field.',
|
|
517
|
+
path: request.packageJsonPath,
|
|
518
|
+
profile: request.profile,
|
|
519
|
+
ruleId: 'package.json.package-manager.required',
|
|
520
|
+
severity: 'error',
|
|
521
|
+
}),
|
|
522
|
+
);
|
|
523
|
+
request.plannedChanges.push(
|
|
524
|
+
createPlannedChange({
|
|
525
|
+
description: `Add packageManager: "bun@${Bun.version}" to package.json.`,
|
|
526
|
+
filePath: request.packageJsonPath,
|
|
527
|
+
kind: 'update-json',
|
|
528
|
+
ruleId: 'package.json.package-manager.required',
|
|
529
|
+
}),
|
|
530
|
+
);
|
|
531
|
+
} else if (!packageJson.packageManager.startsWith('bun@')) {
|
|
532
|
+
diagnostics.push(
|
|
533
|
+
createDiagnostic({
|
|
534
|
+
code: 'field-invalid',
|
|
535
|
+
message: 'package.json packageManager must be Bun-aligned, for example "bun@1.x".',
|
|
536
|
+
path: request.packageJsonPath,
|
|
537
|
+
profile: request.profile,
|
|
538
|
+
ruleId: 'package.json.package-manager.bun',
|
|
539
|
+
severity: 'error',
|
|
540
|
+
}),
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const scripts = isRecord(packageJson.scripts) ? packageJson.scripts : null;
|
|
545
|
+
for (const scriptName of REQUIRED_PUBLIC_PACKAGE_SCRIPTS) {
|
|
546
|
+
if (scripts === null || !isNonEmptyString(scripts[scriptName])) {
|
|
547
|
+
diagnostics.push(
|
|
548
|
+
createDiagnostic({
|
|
549
|
+
code: 'missing-script',
|
|
550
|
+
message: `Missing required package script: ${scriptName}`,
|
|
551
|
+
path: request.packageJsonPath,
|
|
552
|
+
profile: request.profile,
|
|
553
|
+
ruleId: mapScriptRule(scriptName),
|
|
554
|
+
severity: 'error',
|
|
555
|
+
}),
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const devDependencies = isRecord(packageJson.devDependencies)
|
|
561
|
+
? packageJson.devDependencies
|
|
562
|
+
: null;
|
|
563
|
+
if (!hasDependency(devDependencies, 'typescript')) {
|
|
564
|
+
diagnostics.push(
|
|
565
|
+
createDiagnostic({
|
|
566
|
+
code: 'missing-dependency',
|
|
567
|
+
message: 'Public package repos must declare TypeScript in devDependencies.',
|
|
568
|
+
path: request.packageJsonPath,
|
|
569
|
+
profile: request.profile,
|
|
570
|
+
ruleId: 'package.dependencies.typescript.required',
|
|
571
|
+
severity: 'error',
|
|
572
|
+
}),
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (!hasDependency(devDependencies, '@changesets/cli')) {
|
|
577
|
+
diagnostics.push(
|
|
578
|
+
createDiagnostic({
|
|
579
|
+
code: 'missing-dependency',
|
|
580
|
+
message: 'Public package repos must declare @changesets/cli in devDependencies.',
|
|
581
|
+
path: request.packageJsonPath,
|
|
582
|
+
profile: request.profile,
|
|
583
|
+
ruleId: 'package.dependencies.changesets.required',
|
|
584
|
+
severity: 'error',
|
|
585
|
+
}),
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (!hasDependency(devDependencies, '@types/bun')) {
|
|
590
|
+
diagnostics.push(
|
|
591
|
+
createDiagnostic({
|
|
592
|
+
code: 'missing-dependency',
|
|
593
|
+
message: 'Public package repos must declare @types/bun in devDependencies.',
|
|
594
|
+
path: request.packageJsonPath,
|
|
595
|
+
profile: request.profile,
|
|
596
|
+
ruleId: 'package.dependencies.types-bun.required',
|
|
597
|
+
severity: 'error',
|
|
598
|
+
}),
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (!hasDependency(devDependencies, '@types/node')) {
|
|
603
|
+
diagnostics.push(
|
|
604
|
+
createDiagnostic({
|
|
605
|
+
code: 'missing-dependency',
|
|
606
|
+
message: 'Public package repos must declare @types/node in devDependencies.',
|
|
607
|
+
path: request.packageJsonPath,
|
|
608
|
+
profile: request.profile,
|
|
609
|
+
ruleId: 'package.dependencies.types-node.required',
|
|
610
|
+
severity: 'error',
|
|
611
|
+
}),
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (expectsDevtools(packageJson) && !hasDependency(devDependencies, '@ankhorage/devtools')) {
|
|
616
|
+
diagnostics.push(
|
|
617
|
+
createDiagnostic({
|
|
618
|
+
code: 'missing-dependency',
|
|
619
|
+
message:
|
|
620
|
+
'This package consumes shared Ankhorage lint/format/knip tooling and must declare @ankhorage/devtools.',
|
|
621
|
+
path: request.packageJsonPath,
|
|
622
|
+
profile: request.profile,
|
|
623
|
+
ruleId: 'package.dependencies.devtools.required',
|
|
624
|
+
severity: 'error',
|
|
625
|
+
}),
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
if (
|
|
630
|
+
(await expectsParadox(request.targetPath, packageJson)) &&
|
|
631
|
+
!hasDependency(devDependencies, '@ankhorage/paradox')
|
|
632
|
+
) {
|
|
633
|
+
diagnostics.push(
|
|
634
|
+
createDiagnostic({
|
|
635
|
+
code: 'missing-dependency',
|
|
636
|
+
message: 'This package owns Paradox docs generation and must declare @ankhorage/paradox.',
|
|
637
|
+
path: request.packageJsonPath,
|
|
638
|
+
profile: request.profile,
|
|
639
|
+
ruleId: 'package.dependencies.paradox.required',
|
|
640
|
+
severity: 'error',
|
|
641
|
+
}),
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
return diagnostics.concat(
|
|
646
|
+
await validateAnkhMetadataAndProvider({
|
|
647
|
+
diagnostics,
|
|
648
|
+
packageJson,
|
|
649
|
+
packageJsonPath: request.packageJsonPath,
|
|
650
|
+
plannedChanges: request.plannedChanges,
|
|
651
|
+
profile: request.profile,
|
|
652
|
+
targetPath: request.targetPath,
|
|
653
|
+
}),
|
|
654
|
+
);
|
|
171
655
|
}
|
|
172
656
|
|
|
173
|
-
async function
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
657
|
+
async function validateAnkhMetadataAndProvider(request: {
|
|
658
|
+
readonly diagnostics: readonly DoctorDiagnostic[];
|
|
659
|
+
readonly packageJson: Record<string, unknown>;
|
|
660
|
+
readonly packageJsonPath: string;
|
|
661
|
+
readonly plannedChanges: DoctorPlannedChange[];
|
|
662
|
+
readonly profile: DoctorPolicyProfile;
|
|
663
|
+
readonly targetPath: string;
|
|
664
|
+
}): Promise<DoctorDiagnostic[]> {
|
|
177
665
|
const diagnostics: DoctorDiagnostic[] = [];
|
|
178
|
-
|
|
666
|
+
const providerPackage = await isProviderPackage(request.targetPath);
|
|
667
|
+
const metadata = request.packageJson.ankh;
|
|
179
668
|
|
|
180
|
-
|
|
181
|
-
parsed = JSON.parse(await fs.readFile(packageJsonPath, 'utf8')) as unknown;
|
|
182
|
-
} catch (error) {
|
|
669
|
+
if (providerPackage && metadata === undefined) {
|
|
183
670
|
diagnostics.push(
|
|
184
|
-
createDiagnostic(
|
|
185
|
-
'
|
|
186
|
-
'
|
|
187
|
-
packageJsonPath,
|
|
188
|
-
|
|
189
|
-
|
|
671
|
+
createDiagnostic({
|
|
672
|
+
code: 'field-missing',
|
|
673
|
+
message: 'Provider packages must define package.json.ankh metadata.',
|
|
674
|
+
path: request.packageJsonPath,
|
|
675
|
+
profile: request.profile,
|
|
676
|
+
ruleId: 'package.ankh.required-for-provider',
|
|
677
|
+
severity: 'error',
|
|
678
|
+
}),
|
|
679
|
+
);
|
|
680
|
+
request.plannedChanges.push(
|
|
681
|
+
createPlannedChange({
|
|
682
|
+
description:
|
|
683
|
+
'Add a package.json.ankh block describing the implemented provider category, provider path, and capabilities.',
|
|
684
|
+
filePath: request.packageJsonPath,
|
|
685
|
+
kind: 'update-json',
|
|
686
|
+
ruleId: 'package.ankh.required-for-provider',
|
|
687
|
+
}),
|
|
190
688
|
);
|
|
191
689
|
return diagnostics;
|
|
192
690
|
}
|
|
193
691
|
|
|
194
|
-
if (
|
|
692
|
+
if (metadata === undefined) {
|
|
693
|
+
return diagnostics;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const metadataValidation = validateAnkhMetadataShape(metadata, providerPackage);
|
|
697
|
+
if (metadataValidation !== null) {
|
|
195
698
|
diagnostics.push(
|
|
196
|
-
createDiagnostic(
|
|
197
|
-
'
|
|
198
|
-
|
|
199
|
-
packageJsonPath,
|
|
200
|
-
|
|
201
|
-
|
|
699
|
+
createDiagnostic({
|
|
700
|
+
code: 'invalid-ankh-metadata',
|
|
701
|
+
message: metadataValidation,
|
|
702
|
+
path: request.packageJsonPath,
|
|
703
|
+
profile: request.profile,
|
|
704
|
+
ruleId: 'package.ankh.present.valid-shape',
|
|
705
|
+
severity: 'error',
|
|
706
|
+
}),
|
|
202
707
|
);
|
|
203
708
|
return diagnostics;
|
|
204
709
|
}
|
|
205
710
|
|
|
206
|
-
if (!
|
|
711
|
+
if (!providerPackage) {
|
|
712
|
+
return diagnostics;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
if (!isRecord(metadata) || !isNonEmptyString(metadata.provider)) {
|
|
207
716
|
diagnostics.push(
|
|
208
|
-
createDiagnostic(
|
|
209
|
-
'
|
|
210
|
-
'package-
|
|
211
|
-
packageJsonPath,
|
|
212
|
-
|
|
213
|
-
|
|
717
|
+
createDiagnostic({
|
|
718
|
+
code: 'invalid-ankh-metadata',
|
|
719
|
+
message: 'Provider package metadata must define a package-relative provider path.',
|
|
720
|
+
path: request.packageJsonPath,
|
|
721
|
+
profile: request.profile,
|
|
722
|
+
ruleId: 'package.ankh.provider-path.required',
|
|
723
|
+
severity: 'error',
|
|
724
|
+
}),
|
|
214
725
|
);
|
|
726
|
+
return diagnostics;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const inspection = await inspectProviderSource({
|
|
730
|
+
metadataProviderPath: metadata.provider,
|
|
731
|
+
packageRoot: request.targetPath,
|
|
732
|
+
profile: request.profile,
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
if (inspection.diagnostic !== null) {
|
|
736
|
+
diagnostics.push(inspection.diagnostic);
|
|
737
|
+
return diagnostics;
|
|
215
738
|
}
|
|
216
739
|
|
|
217
|
-
if (
|
|
740
|
+
if (inspection.result === null) {
|
|
741
|
+
return diagnostics;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const metadataCapabilities = Array.isArray(metadata.capabilities)
|
|
745
|
+
? metadata.capabilities.filter(isNonEmptyString)
|
|
746
|
+
: [];
|
|
747
|
+
const providerCapabilities = uniqueSorted(inspection.result.capabilities);
|
|
748
|
+
const commandCapabilities = uniqueSorted(inspection.result.commandCapabilities);
|
|
749
|
+
|
|
750
|
+
if (!sameStringSet(metadataCapabilities, commandCapabilities)) {
|
|
218
751
|
diagnostics.push(
|
|
219
|
-
createDiagnostic(
|
|
220
|
-
'
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
752
|
+
createDiagnostic({
|
|
753
|
+
code: 'field-invalid',
|
|
754
|
+
message:
|
|
755
|
+
'package.json.ankh capabilities must match the implemented provider command capabilities exactly.',
|
|
756
|
+
path: request.packageJsonPath,
|
|
757
|
+
profile: request.profile,
|
|
758
|
+
ruleId: 'package.ankh.capabilities.match-provider',
|
|
759
|
+
severity: 'error',
|
|
760
|
+
}),
|
|
225
761
|
);
|
|
226
762
|
}
|
|
227
763
|
|
|
228
|
-
if (!
|
|
764
|
+
if (!sameStringSet(providerCapabilities, commandCapabilities)) {
|
|
229
765
|
diagnostics.push(
|
|
230
|
-
createDiagnostic(
|
|
231
|
-
'
|
|
232
|
-
'
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
766
|
+
createDiagnostic({
|
|
767
|
+
code: 'field-invalid',
|
|
768
|
+
message: 'Provider export capabilities must match the provider command capability surface.',
|
|
769
|
+
path: inspection.result.sourcePath,
|
|
770
|
+
profile: request.profile,
|
|
771
|
+
ruleId: 'provider.commands.match-capabilities',
|
|
772
|
+
severity: 'error',
|
|
773
|
+
}),
|
|
236
774
|
);
|
|
237
775
|
}
|
|
238
776
|
|
|
239
|
-
if (
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
777
|
+
if (!sameStringSet(inspection.result.commandPaths, inspection.result.handlerPaths)) {
|
|
778
|
+
diagnostics.push(
|
|
779
|
+
createDiagnostic({
|
|
780
|
+
code: 'field-invalid',
|
|
781
|
+
message: 'Every provider command descriptor must have a matching handler path.',
|
|
782
|
+
path: inspection.result.sourcePath,
|
|
783
|
+
profile: request.profile,
|
|
784
|
+
ruleId: 'provider.commands.match-handlers',
|
|
785
|
+
severity: 'error',
|
|
786
|
+
}),
|
|
787
|
+
);
|
|
246
788
|
}
|
|
247
789
|
|
|
248
790
|
return diagnostics;
|
|
249
791
|
}
|
|
250
792
|
|
|
251
|
-
async function
|
|
793
|
+
async function inspectProviderSource(request: {
|
|
794
|
+
readonly metadataProviderPath: string;
|
|
795
|
+
readonly packageRoot: string;
|
|
796
|
+
readonly profile: DoctorPolicyProfile;
|
|
797
|
+
}): Promise<{
|
|
798
|
+
readonly diagnostic: DoctorDiagnostic | null;
|
|
799
|
+
readonly result: ProviderInspectionResult | null;
|
|
800
|
+
}> {
|
|
801
|
+
const sourcePath = await resolveProviderSourcePath(
|
|
802
|
+
request.packageRoot,
|
|
803
|
+
request.metadataProviderPath,
|
|
804
|
+
);
|
|
805
|
+
if (sourcePath === null) {
|
|
806
|
+
return {
|
|
807
|
+
diagnostic: createDiagnostic({
|
|
808
|
+
code: 'provider-source-missing',
|
|
809
|
+
message:
|
|
810
|
+
'Provider package metadata is present, but no provider source file could be resolved for inspection.',
|
|
811
|
+
path: request.packageRoot,
|
|
812
|
+
profile: request.profile,
|
|
813
|
+
ruleId: 'provider.source.required',
|
|
814
|
+
severity: 'error',
|
|
815
|
+
}),
|
|
816
|
+
result: null,
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
try {
|
|
821
|
+
const importedModule: unknown = await import(pathToFileURL(sourcePath).href);
|
|
822
|
+
const provider = isRecord(importedModule) ? importedModule.default : undefined;
|
|
823
|
+
if (!isRecord(provider)) {
|
|
824
|
+
return {
|
|
825
|
+
diagnostic: createDiagnostic({
|
|
826
|
+
code: 'provider-source-import-failed',
|
|
827
|
+
message: 'Provider source default export must be an object.',
|
|
828
|
+
path: sourcePath,
|
|
829
|
+
profile: request.profile,
|
|
830
|
+
ruleId: 'provider.source.importable',
|
|
831
|
+
severity: 'error',
|
|
832
|
+
}),
|
|
833
|
+
result: null,
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const capabilities = readStringArray(provider.capabilities);
|
|
838
|
+
const commands = Array.isArray(provider.commands) ? provider.commands : [];
|
|
839
|
+
const handlers = Array.isArray(provider.handlers) ? provider.handlers : [];
|
|
840
|
+
|
|
841
|
+
const commandCapabilities = commands.flatMap((command) =>
|
|
842
|
+
isRecord(command) && isNonEmptyString(command.capability) ? [command.capability] : [],
|
|
843
|
+
);
|
|
844
|
+
const commandPaths = commands.flatMap((command) => {
|
|
845
|
+
const pathValue = isRecord(command) ? command.path : undefined;
|
|
846
|
+
return isStringTuple(pathValue) ? [pathValue.join(' ')] : [];
|
|
847
|
+
});
|
|
848
|
+
const handlerPaths = handlers.flatMap((handler) => {
|
|
849
|
+
const pathValue = isRecord(handler) ? handler.path : undefined;
|
|
850
|
+
const handlerFunction = isRecord(handler) ? handler.handler : undefined;
|
|
851
|
+
return isStringTuple(pathValue) && typeof handlerFunction === 'function'
|
|
852
|
+
? [pathValue.join(' ')]
|
|
853
|
+
: [];
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
return {
|
|
857
|
+
diagnostic: null,
|
|
858
|
+
result: {
|
|
859
|
+
capabilities,
|
|
860
|
+
commandCapabilities,
|
|
861
|
+
commandPaths,
|
|
862
|
+
handlerPaths,
|
|
863
|
+
sourcePath,
|
|
864
|
+
},
|
|
865
|
+
};
|
|
866
|
+
} catch (error) {
|
|
867
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
868
|
+
return {
|
|
869
|
+
diagnostic: createDiagnostic({
|
|
870
|
+
code: 'provider-source-import-failed',
|
|
871
|
+
message: `Failed to import provider source: ${message}`,
|
|
872
|
+
path: sourcePath,
|
|
873
|
+
profile: request.profile,
|
|
874
|
+
ruleId: 'provider.source.importable',
|
|
875
|
+
severity: 'error',
|
|
876
|
+
}),
|
|
877
|
+
result: null,
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
async function resolveProviderSourcePath(
|
|
883
|
+
packageRoot: string,
|
|
884
|
+
metadataProviderPath: string,
|
|
885
|
+
): Promise<string | null> {
|
|
886
|
+
const candidates = new Set<string>();
|
|
887
|
+
candidates.add(path.resolve(packageRoot, metadataProviderPath));
|
|
888
|
+
|
|
889
|
+
if (metadataProviderPath.startsWith('./dist/') && metadataProviderPath.endsWith('.js')) {
|
|
890
|
+
const relativeStem = metadataProviderPath.slice('./dist/'.length, -'.js'.length);
|
|
891
|
+
candidates.add(path.resolve(packageRoot, 'src', `${relativeStem}.ts`));
|
|
892
|
+
candidates.add(path.resolve(packageRoot, 'src', `${relativeStem}.tsx`));
|
|
893
|
+
candidates.add(path.resolve(packageRoot, 'src', `${relativeStem}.js`));
|
|
894
|
+
candidates.add(path.resolve(packageRoot, 'src', `${relativeStem}.mjs`));
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
for (const providerSourceCandidate of PROVIDER_SOURCE_CANDIDATES) {
|
|
898
|
+
candidates.add(path.resolve(packageRoot, providerSourceCandidate));
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
for (const candidate of candidates) {
|
|
902
|
+
if (await pathExists(candidate)) {
|
|
903
|
+
return candidate;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
return null;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
async function findRepoMarkers(targetPath: string): Promise<readonly string[]> {
|
|
911
|
+
return (
|
|
912
|
+
await Promise.all(
|
|
913
|
+
REPO_MARKERS.map(async (marker) =>
|
|
914
|
+
(await pathExists(path.join(targetPath, marker.relativePath))) ? marker.label : null,
|
|
915
|
+
),
|
|
916
|
+
)
|
|
917
|
+
).filter((marker): marker is (typeof REPO_MARKERS)[number]['label'] => marker !== null);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
async function readPackageJson(packageJsonPath: string): Promise<{
|
|
921
|
+
readonly diagnostic: DoctorDiagnostic | null;
|
|
922
|
+
readonly parsed: Record<string, unknown> | null;
|
|
923
|
+
}> {
|
|
924
|
+
let parsed: unknown;
|
|
925
|
+
|
|
926
|
+
try {
|
|
927
|
+
parsed = JSON.parse(await fs.readFile(packageJsonPath, 'utf8')) as unknown;
|
|
928
|
+
} catch (error) {
|
|
929
|
+
return {
|
|
930
|
+
diagnostic: createDiagnostic({
|
|
931
|
+
code: 'invalid-package-json',
|
|
932
|
+
message: createInvalidPackageJsonMessage(error),
|
|
933
|
+
path: packageJsonPath,
|
|
934
|
+
profile: 'unknown',
|
|
935
|
+
ruleId: 'target.package-json.required',
|
|
936
|
+
severity: 'error',
|
|
937
|
+
}),
|
|
938
|
+
parsed: null,
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
if (!isRecord(parsed)) {
|
|
943
|
+
return {
|
|
944
|
+
diagnostic: createDiagnostic({
|
|
945
|
+
code: 'invalid-package-json',
|
|
946
|
+
message: 'package.json must contain a JSON object.',
|
|
947
|
+
path: packageJsonPath,
|
|
948
|
+
profile: 'unknown',
|
|
949
|
+
ruleId: 'target.package-json.required',
|
|
950
|
+
severity: 'error',
|
|
951
|
+
}),
|
|
952
|
+
parsed: null,
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
return {
|
|
957
|
+
diagnostic: null,
|
|
958
|
+
parsed,
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function detectProfile(
|
|
963
|
+
repoMarkers: readonly string[],
|
|
964
|
+
packageJson: Record<string, unknown> | null,
|
|
965
|
+
): DoctorPolicyProfile {
|
|
966
|
+
if (packageJson !== null) {
|
|
967
|
+
if (packageJson.name === 'ankhorage4') {
|
|
968
|
+
return 'integration-monorepo';
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
if (isNonEmptyString(packageJson.name) && packageJson.name.startsWith('@ankhorage/')) {
|
|
972
|
+
return packageJson.private === true ? 'unknown' : 'public-package';
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
if (repoMarkers.length > 0) {
|
|
977
|
+
return 'unknown';
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
return 'unknown';
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
async function isProviderPackage(targetPath: string): Promise<boolean> {
|
|
984
|
+
for (const candidate of PROVIDER_SOURCE_CANDIDATES) {
|
|
985
|
+
if (await pathExists(path.join(targetPath, candidate))) {
|
|
986
|
+
return true;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
return false;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
async function maybeRequirePath(
|
|
252
994
|
diagnostics: DoctorDiagnostic[],
|
|
995
|
+
plannedChanges: DoctorPlannedChange[],
|
|
253
996
|
rootPath: string,
|
|
254
997
|
relativePath: string,
|
|
255
|
-
|
|
256
|
-
| '
|
|
257
|
-
| '
|
|
258
|
-
| '
|
|
259
|
-
| '
|
|
260
|
-
| '
|
|
998
|
+
ruleId:
|
|
999
|
+
| 'repo.changelog.required'
|
|
1000
|
+
| 'repo.changeset.required'
|
|
1001
|
+
| 'repo.license.required'
|
|
1002
|
+
| 'repo.readme.required'
|
|
1003
|
+
| 'repo.workflows.required',
|
|
1004
|
+
kind: DoctorPlannedChangeKind,
|
|
261
1005
|
): Promise<void> {
|
|
262
1006
|
const absolutePath = path.join(rootPath, relativePath);
|
|
263
1007
|
if (await pathExists(absolutePath)) {
|
|
@@ -265,16 +1009,139 @@ async function maybeWarnForMissingPath(
|
|
|
265
1009
|
}
|
|
266
1010
|
|
|
267
1011
|
diagnostics.push(
|
|
268
|
-
createDiagnostic(
|
|
269
|
-
'
|
|
270
|
-
|
|
271
|
-
absolutePath,
|
|
272
|
-
|
|
273
|
-
|
|
1012
|
+
createDiagnostic({
|
|
1013
|
+
code: 'missing-path',
|
|
1014
|
+
message: `Missing expected repo artifact: ${relativePath}`,
|
|
1015
|
+
path: absolutePath,
|
|
1016
|
+
profile: 'public-package',
|
|
1017
|
+
ruleId,
|
|
1018
|
+
severity: 'error',
|
|
1019
|
+
}),
|
|
1020
|
+
);
|
|
1021
|
+
plannedChanges.push(
|
|
1022
|
+
createPlannedChange({
|
|
1023
|
+
description: `Create missing ${relativePath}.`,
|
|
1024
|
+
filePath: absolutePath,
|
|
1025
|
+
kind,
|
|
1026
|
+
ruleId,
|
|
1027
|
+
}),
|
|
274
1028
|
);
|
|
275
1029
|
}
|
|
276
1030
|
|
|
277
|
-
function
|
|
1031
|
+
function requireNonEmptyStringField(request: {
|
|
1032
|
+
readonly diagnostics: DoctorDiagnostic[];
|
|
1033
|
+
readonly fieldName: string;
|
|
1034
|
+
readonly packageJson: Record<string, unknown>;
|
|
1035
|
+
readonly packageJsonPath: string;
|
|
1036
|
+
readonly profile: DoctorPolicyProfile;
|
|
1037
|
+
readonly ruleId: DoctorRuleId;
|
|
1038
|
+
}): void {
|
|
1039
|
+
if (isNonEmptyString(request.packageJson[request.fieldName])) {
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
request.diagnostics.push(
|
|
1044
|
+
createDiagnostic({
|
|
1045
|
+
code: 'field-missing',
|
|
1046
|
+
message: `package.json must define a non-empty "${request.fieldName}" field.`,
|
|
1047
|
+
path: request.packageJsonPath,
|
|
1048
|
+
profile: request.profile,
|
|
1049
|
+
ruleId: request.ruleId,
|
|
1050
|
+
severity: 'error',
|
|
1051
|
+
}),
|
|
1052
|
+
);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
function requireRecordField(request: {
|
|
1056
|
+
readonly diagnostics: DoctorDiagnostic[];
|
|
1057
|
+
readonly fieldName: string;
|
|
1058
|
+
readonly packageJson: Record<string, unknown>;
|
|
1059
|
+
readonly packageJsonPath: string;
|
|
1060
|
+
readonly profile: DoctorPolicyProfile;
|
|
1061
|
+
readonly ruleId: DoctorRuleId;
|
|
1062
|
+
}): void {
|
|
1063
|
+
if (isRecord(request.packageJson[request.fieldName])) {
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
request.diagnostics.push(
|
|
1068
|
+
createDiagnostic({
|
|
1069
|
+
code: 'field-missing',
|
|
1070
|
+
message: `package.json must define an object "${request.fieldName}" field.`,
|
|
1071
|
+
path: request.packageJsonPath,
|
|
1072
|
+
profile: request.profile,
|
|
1073
|
+
ruleId: request.ruleId,
|
|
1074
|
+
severity: 'error',
|
|
1075
|
+
}),
|
|
1076
|
+
);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
function requireStringArrayField(request: {
|
|
1080
|
+
readonly diagnostics: DoctorDiagnostic[];
|
|
1081
|
+
readonly fieldName: string;
|
|
1082
|
+
readonly packageJson: Record<string, unknown>;
|
|
1083
|
+
readonly packageJsonPath: string;
|
|
1084
|
+
readonly profile: DoctorPolicyProfile;
|
|
1085
|
+
readonly ruleId: DoctorRuleId;
|
|
1086
|
+
}): void {
|
|
1087
|
+
if (readStringArray(request.packageJson[request.fieldName]).length > 0) {
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
request.diagnostics.push(
|
|
1092
|
+
createDiagnostic({
|
|
1093
|
+
code: 'field-missing',
|
|
1094
|
+
message: `package.json must define a non-empty string array "${request.fieldName}" field.`,
|
|
1095
|
+
path: request.packageJsonPath,
|
|
1096
|
+
profile: request.profile,
|
|
1097
|
+
ruleId: request.ruleId,
|
|
1098
|
+
severity: 'error',
|
|
1099
|
+
}),
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
async function expectsParadox(
|
|
1104
|
+
targetPath: string,
|
|
1105
|
+
packageJson: Record<string, unknown>,
|
|
1106
|
+
): Promise<boolean> {
|
|
1107
|
+
const scripts = isRecord(packageJson.scripts) ? packageJson.scripts : null;
|
|
1108
|
+
const docsScript = scripts?.docs;
|
|
1109
|
+
if (
|
|
1110
|
+
isNonEmptyString(docsScript) &&
|
|
1111
|
+
(docsScript.includes('paradox') || docsScript.includes('@ankhorage/paradox'))
|
|
1112
|
+
) {
|
|
1113
|
+
return true;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
return (
|
|
1117
|
+
(await pathExists(path.join(targetPath, 'paradox.config.ts'))) ||
|
|
1118
|
+
(await pathExists(path.join(targetPath, 'src', 'readme-usage.ts')))
|
|
1119
|
+
);
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function expectsDevtools(packageJson: Record<string, unknown>): boolean {
|
|
1123
|
+
const scripts = isRecord(packageJson.scripts) ? packageJson.scripts : null;
|
|
1124
|
+
if (scripts === null) {
|
|
1125
|
+
return false;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
return Object.values(scripts).some(
|
|
1129
|
+
(scriptValue) =>
|
|
1130
|
+
isNonEmptyString(scriptValue) &&
|
|
1131
|
+
(scriptValue.includes('ankhorage-eslint') ||
|
|
1132
|
+
scriptValue.includes('ankhorage-prettier') ||
|
|
1133
|
+
scriptValue.includes('ankhorage-knip')),
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function hasDependency(
|
|
1138
|
+
dependencyMap: Record<string, unknown> | null,
|
|
1139
|
+
dependencyName: string,
|
|
1140
|
+
): boolean {
|
|
1141
|
+
return dependencyMap !== null && isNonEmptyString(dependencyMap[dependencyName]);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
function validateAnkhMetadataShape(value: unknown, providerPackage: boolean): string | null {
|
|
278
1145
|
if (!isRecord(value)) {
|
|
279
1146
|
return 'package.json "ankh" metadata must be an object when present.';
|
|
280
1147
|
}
|
|
@@ -283,8 +1150,19 @@ function validateAnkhMetadataShape(value: unknown): string | null {
|
|
|
283
1150
|
return 'package.json "ankh.category" must be a non-empty string.';
|
|
284
1151
|
}
|
|
285
1152
|
|
|
286
|
-
if (
|
|
287
|
-
|
|
1153
|
+
if (
|
|
1154
|
+
providerPackage &&
|
|
1155
|
+
!(isNonEmptyString(value.provider) && isPackageRelativeProviderPath(value.provider))
|
|
1156
|
+
) {
|
|
1157
|
+
return 'Provider package metadata must define a package-relative "./..." provider path.';
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
if (
|
|
1161
|
+
!providerPackage &&
|
|
1162
|
+
value.provider !== undefined &&
|
|
1163
|
+
!(value.provider === null || isPackageRelativeProviderPath(value.provider))
|
|
1164
|
+
) {
|
|
1165
|
+
return 'Non-provider package metadata, when present, must use null or a package-relative "./..." provider value.';
|
|
288
1166
|
}
|
|
289
1167
|
|
|
290
1168
|
if (
|
|
@@ -297,17 +1175,56 @@ function validateAnkhMetadataShape(value: unknown): string | null {
|
|
|
297
1175
|
return null;
|
|
298
1176
|
}
|
|
299
1177
|
|
|
300
|
-
function
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
1178
|
+
function mapScriptRule(scriptName: (typeof REQUIRED_PUBLIC_PACKAGE_SCRIPTS)[number]): DoctorRuleId {
|
|
1179
|
+
switch (scriptName) {
|
|
1180
|
+
case 'build':
|
|
1181
|
+
return 'package.scripts.build.required';
|
|
1182
|
+
case 'typecheck':
|
|
1183
|
+
return 'package.scripts.typecheck.required';
|
|
1184
|
+
case 'lint':
|
|
1185
|
+
return 'package.scripts.lint.required';
|
|
1186
|
+
case 'lint:fix':
|
|
1187
|
+
return 'package.scripts.lint-fix.required';
|
|
1188
|
+
case 'format':
|
|
1189
|
+
return 'package.scripts.format.required';
|
|
1190
|
+
case 'format:check':
|
|
1191
|
+
return 'package.scripts.format-check.required';
|
|
1192
|
+
case 'test':
|
|
1193
|
+
return 'package.scripts.test.required';
|
|
1194
|
+
case 'knip':
|
|
1195
|
+
return 'package.scripts.knip.required';
|
|
1196
|
+
case 'docs':
|
|
1197
|
+
return 'package.scripts.docs.required';
|
|
1198
|
+
case 'changeset':
|
|
1199
|
+
return 'package.scripts.changeset.required';
|
|
1200
|
+
case 'changeset:status':
|
|
1201
|
+
return 'package.scripts.changeset-status.required';
|
|
1202
|
+
case 'version-packages':
|
|
1203
|
+
return 'package.scripts.version-packages.required';
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
function createEarlyResult(targetPath: string, diagnostic: DoctorDiagnostic): DoctorAnalysisResult {
|
|
1208
|
+
return {
|
|
1209
|
+
appliedChecks: [],
|
|
1210
|
+
diagnostics: [diagnostic],
|
|
1211
|
+
fixPlan: null,
|
|
1212
|
+
hasPackageJson: false,
|
|
1213
|
+
plannedChanges: [],
|
|
1214
|
+
profile: 'unknown',
|
|
1215
|
+
repoMarkers: [],
|
|
1216
|
+
targetPath,
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
function createDiagnostic(diagnostic: DoctorDiagnostic): DoctorDiagnostic {
|
|
1221
|
+
return diagnostic;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
function createPlannedChange(change: Omit<DoctorPlannedChange, 'safe'>): DoctorPlannedChange {
|
|
306
1225
|
return {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
path: diagnosticPath,
|
|
310
|
-
severity,
|
|
1226
|
+
...change,
|
|
1227
|
+
safe: true,
|
|
311
1228
|
};
|
|
312
1229
|
}
|
|
313
1230
|
|
|
@@ -352,3 +1269,23 @@ function isPackageRelativeProviderPath(value: unknown): value is string {
|
|
|
352
1269
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
353
1270
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
354
1271
|
}
|
|
1272
|
+
|
|
1273
|
+
function isStringTuple(value: unknown): value is readonly string[] {
|
|
1274
|
+
return Array.isArray(value) && value.length > 0 && value.every(isNonEmptyString);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
function readStringArray(value: unknown): string[] {
|
|
1278
|
+
return Array.isArray(value) ? value.filter(isNonEmptyString) : [];
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
function sameStringSet(left: readonly string[], right: readonly string[]): boolean {
|
|
1282
|
+
const leftSet = uniqueSorted(left);
|
|
1283
|
+
const rightSet = uniqueSorted(right);
|
|
1284
|
+
return (
|
|
1285
|
+
leftSet.length === rightSet.length && leftSet.every((value, index) => value === rightSet[index])
|
|
1286
|
+
);
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
function uniqueSorted(values: readonly string[]): string[] {
|
|
1290
|
+
return [...new Set(values)].sort((left, right) => left.localeCompare(right));
|
|
1291
|
+
}
|