@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/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
- 'error',
99
- 'repo-markers-missing',
100
- targetPath,
101
- 'Target is not a repo/workspace-root candidate because no repo markers were found.',
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
- 'error',
110
- 'package-json-missing',
111
- packageJsonPath,
112
- 'Target does not contain a readable package.json file.',
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
- 'error',
125
- 'unsupported-target',
126
- targetPath,
127
- 'Target is neither a repo/workspace-root candidate nor a package root.',
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(...(await analyzeRepoLight(targetPath)));
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(...(await analyzePackageLight(targetPath, packageJsonPath)));
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 analyzeRepoLight(targetPath: string): Promise<DoctorDiagnostic[]> {
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
- await maybeWarnForMissingPath(diagnostics, targetPath, 'README.md', 'missing-readme');
155
- await maybeWarnForMissingPath(diagnostics, targetPath, 'CHANGELOG.md', 'missing-changelog');
156
- await maybeWarnForMissingPath(diagnostics, targetPath, 'LICENSE', 'missing-license');
157
- await maybeWarnForMissingPath(
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
- targetPath,
160
- '.changeset',
161
- 'missing-changeset-directory',
162
- );
163
- await maybeWarnForMissingPath(
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
- targetPath,
166
- path.join('.github', 'workflows'),
167
- 'missing-workflows-directory',
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
- return diagnostics;
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 analyzePackageLight(
174
- targetPath: string,
175
- packageJsonPath: string,
176
- ): Promise<DoctorDiagnostic[]> {
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
- let parsed: unknown;
666
+ const providerPackage = await isProviderPackage(request.targetPath);
667
+ const metadata = request.packageJson.ankh;
179
668
 
180
- try {
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
- 'error',
186
- 'invalid-package-json',
187
- packageJsonPath,
188
- createInvalidPackageJsonMessage(error),
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 (!isRecord(parsed)) {
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
- 'error',
198
- 'invalid-package-json',
199
- packageJsonPath,
200
- 'package.json must contain a JSON object.',
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 (!isNonEmptyString(parsed.name)) {
711
+ if (!providerPackage) {
712
+ return diagnostics;
713
+ }
714
+
715
+ if (!isRecord(metadata) || !isNonEmptyString(metadata.provider)) {
207
716
  diagnostics.push(
208
- createDiagnostic(
209
- 'error',
210
- 'package-name-missing',
211
- packageJsonPath,
212
- 'package.json must define a non-empty "name" field.',
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 (!isNonEmptyString(parsed.version)) {
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
- 'error',
221
- 'package-version-missing',
222
- packageJsonPath,
223
- 'package.json must define a non-empty "version" field.',
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 (!isNonEmptyString(parsed.type)) {
764
+ if (!sameStringSet(providerCapabilities, commandCapabilities)) {
229
765
  diagnostics.push(
230
- createDiagnostic(
231
- 'error',
232
- 'package-type-missing',
233
- packageJsonPath,
234
- 'package.json must define a non-empty "type" field.',
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 ('ankh' in parsed) {
240
- const validationError = validateAnkhMetadataShape(parsed.ankh);
241
- if (validationError !== null) {
242
- diagnostics.push(
243
- createDiagnostic('error', 'invalid-ankh-metadata', targetPath, validationError),
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 maybeWarnForMissingPath(
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
- code:
256
- | 'missing-changelog'
257
- | 'missing-changeset-directory'
258
- | 'missing-license'
259
- | 'missing-readme'
260
- | 'missing-workflows-directory',
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
- 'warning',
270
- code,
271
- absolutePath,
272
- `Missing expected repo artifact: ${relativePath}`,
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 validateAnkhMetadataShape(value: unknown): string | null {
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 (!(value.provider === null || isPackageRelativeProviderPath(value.provider))) {
287
- return 'package.json "ankh.provider" must be null or a package-relative "./..." string.';
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 createDiagnostic(
301
- severity: DoctorDiagnostic['severity'],
302
- code: DoctorDiagnostic['code'],
303
- diagnosticPath: string,
304
- message: string,
305
- ): DoctorDiagnostic {
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
- code,
308
- message,
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
+ }