@ankhorage/doctor 0.2.1 → 0.3.1

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/dist/analysis.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { promises as fs } from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
3
4
  const REPO_MARKERS = [
4
5
  { label: '.git', relativePath: '.git' },
5
6
  { label: '.github/workflows', relativePath: path.join('.github', 'workflows') },
@@ -11,123 +12,796 @@ const REPO_MARKERS = [
11
12
  { label: 'package-lock.json', relativePath: 'package-lock.json' },
12
13
  { label: 'yarn.lock', relativePath: 'yarn.lock' },
13
14
  ];
15
+ const REQUIRED_PUBLIC_PACKAGE_SCRIPTS = [
16
+ 'build',
17
+ 'typecheck',
18
+ 'lint',
19
+ 'lint:fix',
20
+ 'format',
21
+ 'format:check',
22
+ 'test',
23
+ 'knip',
24
+ 'docs',
25
+ 'changeset',
26
+ 'changeset:status',
27
+ 'version-packages',
28
+ ];
29
+ const PROVIDER_SOURCE_CANDIDATES = [
30
+ path.join('src', 'ankh.provider.ts'),
31
+ path.join('src', 'ankh.provider.tsx'),
32
+ path.join('src', 'ankh.provider.js'),
33
+ path.join('src', 'ankh.provider.mjs'),
34
+ ];
14
35
  export async function analyzeDoctorTarget(request) {
15
36
  const targetPath = path.resolve(request.cwd, request.inputPath ?? '.');
16
37
  const stats = await statOrNull(targetPath);
17
38
  if (stats === null) {
18
- return {
19
- appliedChecks: [],
20
- diagnostics: [
21
- createDiagnostic('error', 'target-not-found', targetPath, `Target path does not exist: ${targetPath}`),
22
- ],
23
- hasPackageJson: false,
24
- repoMarkers: [],
25
- targetPath,
26
- };
39
+ return createEarlyResult(targetPath, createDiagnostic({
40
+ code: 'target-not-found',
41
+ message: `Target path does not exist: ${targetPath}`,
42
+ path: targetPath,
43
+ profile: 'unknown',
44
+ ruleId: 'target.path.exists',
45
+ severity: 'error',
46
+ }));
27
47
  }
28
48
  if (!stats.isDirectory()) {
29
- return {
30
- appliedChecks: [],
31
- diagnostics: [
32
- createDiagnostic('error', 'target-not-directory', targetPath, `Target path must be a directory: ${targetPath}`),
33
- ],
34
- hasPackageJson: false,
35
- repoMarkers: [],
36
- targetPath,
37
- };
49
+ return createEarlyResult(targetPath, createDiagnostic({
50
+ code: 'target-not-directory',
51
+ message: `Target path must be a directory: ${targetPath}`,
52
+ path: targetPath,
53
+ profile: 'unknown',
54
+ ruleId: 'target.path.directory',
55
+ severity: 'error',
56
+ }));
38
57
  }
39
- const repoMarkers = (await Promise.all(REPO_MARKERS.map(async (marker) => (await pathExists(path.join(targetPath, marker.relativePath))) ? marker.label : null))).filter((marker) => marker !== null);
58
+ const repoMarkers = await findRepoMarkers(targetPath);
40
59
  const packageJsonPath = path.join(targetPath, 'package.json');
41
60
  const hasPackageJson = repoMarkers.includes('package.json');
42
61
  const appliedChecks = [];
43
62
  const diagnostics = [];
63
+ const plannedChanges = [];
64
+ const packageJson = hasPackageJson ? await readPackageJson(packageJsonPath) : null;
65
+ if (packageJson !== null && packageJson.diagnostic !== null) {
66
+ diagnostics.push(packageJson.diagnostic);
67
+ }
68
+ const profile = detectProfile(repoMarkers, packageJson?.parsed ?? null);
44
69
  const shouldRunRepoChecks = request.mode === 'repo' ||
45
70
  ((request.mode === 'validate' || request.mode === 'fix') && repoMarkers.length > 0);
46
71
  const shouldRunPackageChecks = request.mode === 'package' ||
47
72
  ((request.mode === 'validate' || request.mode === 'fix') && hasPackageJson);
48
73
  if (request.mode === 'repo' && repoMarkers.length === 0) {
49
- diagnostics.push(createDiagnostic('error', 'repo-markers-missing', targetPath, 'Target is not a repo/workspace-root candidate because no repo markers were found.'));
74
+ diagnostics.push(createDiagnostic({
75
+ code: 'unsupported-target',
76
+ message: 'Target is not a repo/workspace-root candidate because no repo markers were found.',
77
+ path: targetPath,
78
+ profile,
79
+ ruleId: 'target.repo-markers.required',
80
+ severity: 'error',
81
+ }));
50
82
  }
51
83
  if (request.mode === 'package' && !hasPackageJson) {
52
- diagnostics.push(createDiagnostic('error', 'package-json-missing', packageJsonPath, 'Target does not contain a readable package.json file.'));
84
+ diagnostics.push(createDiagnostic({
85
+ code: 'unsupported-target',
86
+ message: 'Target does not contain a readable package.json file.',
87
+ path: packageJsonPath,
88
+ profile,
89
+ ruleId: 'target.package-json.required',
90
+ severity: 'error',
91
+ }));
53
92
  }
54
93
  if ((request.mode === 'validate' || request.mode === 'fix') &&
55
94
  repoMarkers.length === 0 &&
56
95
  !hasPackageJson) {
57
- diagnostics.push(createDiagnostic('error', 'unsupported-target', targetPath, 'Target is neither a repo/workspace-root candidate nor a package root.'));
96
+ diagnostics.push(createDiagnostic({
97
+ code: 'unsupported-target',
98
+ message: 'Target is neither a repo/workspace-root candidate nor a package root.',
99
+ path: targetPath,
100
+ profile,
101
+ ruleId: 'target.profile.supported',
102
+ severity: 'error',
103
+ }));
58
104
  }
59
105
  if (shouldRunRepoChecks && repoMarkers.length > 0) {
60
106
  appliedChecks.push('repo');
61
- diagnostics.push(...(await analyzeRepoLight(targetPath)));
107
+ diagnostics.push(...(await analyzeRepoPolicy({
108
+ packageJson: packageJson?.parsed ?? null,
109
+ plannedChanges,
110
+ profile,
111
+ repoMarkers,
112
+ targetPath,
113
+ })));
62
114
  }
63
115
  if (shouldRunPackageChecks && hasPackageJson) {
64
116
  appliedChecks.push('package');
65
- diagnostics.push(...(await analyzePackageLight(targetPath, packageJsonPath)));
117
+ diagnostics.push(...(await analyzePackagePolicy({
118
+ packageJson: packageJson?.parsed ?? null,
119
+ packageJsonPath,
120
+ plannedChanges,
121
+ profile,
122
+ targetPath,
123
+ })));
66
124
  }
125
+ const fixPlan = request.mode === 'fix'
126
+ ? {
127
+ changes: plannedChanges,
128
+ diagnostics,
129
+ profile,
130
+ targetPath,
131
+ }
132
+ : null;
67
133
  return {
68
134
  appliedChecks,
69
135
  diagnostics,
136
+ fixPlan,
70
137
  hasPackageJson,
138
+ plannedChanges,
139
+ profile,
71
140
  repoMarkers,
72
141
  targetPath,
73
142
  };
74
143
  }
75
- async function analyzeRepoLight(targetPath) {
144
+ async function analyzeRepoPolicy(request) {
76
145
  const diagnostics = [];
77
- await maybeWarnForMissingPath(diagnostics, targetPath, 'README.md', 'missing-readme');
78
- await maybeWarnForMissingPath(diagnostics, targetPath, 'CHANGELOG.md', 'missing-changelog');
79
- await maybeWarnForMissingPath(diagnostics, targetPath, 'LICENSE', 'missing-license');
80
- await maybeWarnForMissingPath(diagnostics, targetPath, '.changeset', 'missing-changeset-directory');
81
- await maybeWarnForMissingPath(diagnostics, targetPath, path.join('.github', 'workflows'), 'missing-workflows-directory');
146
+ if (request.profile === 'public-package') {
147
+ await maybeRequirePath(diagnostics, request.plannedChanges, request.targetPath, 'README.md', 'repo.readme.required', 'create-file');
148
+ await maybeRequirePath(diagnostics, request.plannedChanges, request.targetPath, 'CHANGELOG.md', 'repo.changelog.required', 'create-file');
149
+ await maybeRequirePath(diagnostics, request.plannedChanges, request.targetPath, 'LICENSE', 'repo.license.required', 'create-file');
150
+ await maybeRequirePath(diagnostics, request.plannedChanges, request.targetPath, '.changeset', 'repo.changeset.required', 'create-directory');
151
+ await maybeRequirePath(diagnostics, request.plannedChanges, request.targetPath, path.join('.github', 'workflows'), 'repo.workflows.required', 'create-directory');
152
+ }
153
+ if (request.profile === 'integration-monorepo') {
154
+ if (!request.repoMarkers.includes('package.json')) {
155
+ diagnostics.push(createDiagnostic({
156
+ code: 'missing-path',
157
+ message: 'The integration monorepo root must include a package.json file.',
158
+ path: path.join(request.targetPath, 'package.json'),
159
+ profile: request.profile,
160
+ ruleId: 'target.package-json.required',
161
+ severity: 'error',
162
+ }));
163
+ }
164
+ if (request.packageJson !== null) {
165
+ if (request.packageJson.ankh !== undefined) {
166
+ diagnostics.push(createDiagnostic({
167
+ code: 'field-invalid',
168
+ message: 'The integration monorepo root must not publish top-level package.json.ankh metadata.',
169
+ path: path.join(request.targetPath, 'package.json'),
170
+ profile: request.profile,
171
+ ruleId: 'integration.package.ankh.disallowed',
172
+ severity: 'error',
173
+ }));
174
+ }
175
+ if (request.packageJson.private !== true) {
176
+ diagnostics.push(createDiagnostic({
177
+ code: 'field-invalid',
178
+ message: 'The integration monorepo root must remain private.',
179
+ path: path.join(request.targetPath, 'package.json'),
180
+ profile: request.profile,
181
+ ruleId: 'integration.package.private.required',
182
+ severity: 'error',
183
+ }));
184
+ }
185
+ if (!Array.isArray(request.packageJson.workspaces)) {
186
+ diagnostics.push(createDiagnostic({
187
+ code: 'field-missing',
188
+ message: 'The integration monorepo root must declare workspaces.',
189
+ path: path.join(request.targetPath, 'package.json'),
190
+ profile: request.profile,
191
+ ruleId: 'integration.package.workspaces.required',
192
+ severity: 'error',
193
+ }));
194
+ }
195
+ }
196
+ }
82
197
  return diagnostics;
83
198
  }
84
- async function analyzePackageLight(targetPath, packageJsonPath) {
199
+ async function analyzePackagePolicy(request) {
200
+ if (request.packageJson === null) {
201
+ return [];
202
+ }
203
+ const diagnostics = [];
204
+ const { packageJson } = request;
205
+ if (request.profile === 'public-package') {
206
+ requireNonEmptyStringField({
207
+ diagnostics,
208
+ packageJson,
209
+ packageJsonPath: request.packageJsonPath,
210
+ profile: request.profile,
211
+ ruleId: 'package.json.name.required',
212
+ fieldName: 'name',
213
+ });
214
+ requireNonEmptyStringField({
215
+ diagnostics,
216
+ packageJson,
217
+ packageJsonPath: request.packageJsonPath,
218
+ profile: request.profile,
219
+ ruleId: 'package.json.version.required',
220
+ fieldName: 'version',
221
+ });
222
+ requireNonEmptyStringField({
223
+ diagnostics,
224
+ packageJson,
225
+ packageJsonPath: request.packageJsonPath,
226
+ profile: request.profile,
227
+ ruleId: 'package.json.type.required',
228
+ fieldName: 'type',
229
+ });
230
+ }
231
+ if (request.profile !== 'public-package') {
232
+ return diagnostics.concat(await validateAnkhMetadataAndProvider({
233
+ diagnostics,
234
+ packageJson,
235
+ packageJsonPath: request.packageJsonPath,
236
+ plannedChanges: request.plannedChanges,
237
+ profile: request.profile,
238
+ targetPath: request.targetPath,
239
+ }));
240
+ }
241
+ requireNonEmptyStringField({
242
+ diagnostics,
243
+ packageJson,
244
+ packageJsonPath: request.packageJsonPath,
245
+ profile: request.profile,
246
+ ruleId: 'package.json.description.required',
247
+ fieldName: 'description',
248
+ });
249
+ requireRecordField({
250
+ diagnostics,
251
+ packageJson,
252
+ packageJsonPath: request.packageJsonPath,
253
+ profile: request.profile,
254
+ ruleId: 'package.json.repository.required',
255
+ fieldName: 'repository',
256
+ });
257
+ requireNonEmptyStringField({
258
+ diagnostics,
259
+ packageJson,
260
+ packageJsonPath: request.packageJsonPath,
261
+ profile: request.profile,
262
+ ruleId: 'package.json.homepage.required',
263
+ fieldName: 'homepage',
264
+ });
265
+ requireRecordField({
266
+ diagnostics,
267
+ packageJson,
268
+ packageJsonPath: request.packageJsonPath,
269
+ profile: request.profile,
270
+ ruleId: 'package.json.bugs.required',
271
+ fieldName: 'bugs',
272
+ });
273
+ requireNonEmptyStringField({
274
+ diagnostics,
275
+ packageJson,
276
+ packageJsonPath: request.packageJsonPath,
277
+ profile: request.profile,
278
+ ruleId: 'package.json.license.required',
279
+ fieldName: 'license',
280
+ });
281
+ requireStringArrayField({
282
+ diagnostics,
283
+ packageJson,
284
+ packageJsonPath: request.packageJsonPath,
285
+ profile: request.profile,
286
+ ruleId: 'package.json.keywords.required',
287
+ fieldName: 'keywords',
288
+ });
289
+ requireStringArrayField({
290
+ diagnostics,
291
+ packageJson,
292
+ packageJsonPath: request.packageJsonPath,
293
+ profile: request.profile,
294
+ ruleId: 'package.json.files.required',
295
+ fieldName: 'files',
296
+ });
297
+ requireRecordField({
298
+ diagnostics,
299
+ packageJson,
300
+ packageJsonPath: request.packageJsonPath,
301
+ profile: request.profile,
302
+ ruleId: 'package.json.exports.required',
303
+ fieldName: 'exports',
304
+ });
305
+ requireRecordField({
306
+ diagnostics,
307
+ packageJson,
308
+ packageJsonPath: request.packageJsonPath,
309
+ profile: request.profile,
310
+ ruleId: 'package.json.publish-config.required',
311
+ fieldName: 'publishConfig',
312
+ });
313
+ if (packageJson.private === true) {
314
+ diagnostics.push(createDiagnostic({
315
+ code: 'field-invalid',
316
+ message: 'Public package profiles must not set private: true.',
317
+ path: request.packageJsonPath,
318
+ profile: request.profile,
319
+ ruleId: 'package.json.private.public-package-disallowed',
320
+ severity: 'error',
321
+ }));
322
+ }
323
+ if (packageJson.type !== undefined && packageJson.type !== 'module') {
324
+ diagnostics.push(createDiagnostic({
325
+ code: 'field-invalid',
326
+ message: 'package.json "type" must be "module".',
327
+ path: request.packageJsonPath,
328
+ profile: request.profile,
329
+ ruleId: 'package.json.type.module',
330
+ severity: 'error',
331
+ }));
332
+ }
333
+ const { publishConfig } = packageJson;
334
+ if (isRecord(publishConfig) && publishConfig.access !== 'public') {
335
+ diagnostics.push(createDiagnostic({
336
+ code: isNonEmptyString(publishConfig.access) ? 'field-invalid' : 'field-missing',
337
+ message: 'package.json publishConfig.access must exist and equal "public".',
338
+ path: request.packageJsonPath,
339
+ profile: request.profile,
340
+ ruleId: 'package.json.publish-config.public',
341
+ severity: 'error',
342
+ }));
343
+ }
344
+ if (!isNonEmptyString(packageJson.packageManager)) {
345
+ diagnostics.push(createDiagnostic({
346
+ code: 'field-missing',
347
+ message: 'package.json must define a Bun-aligned packageManager field.',
348
+ path: request.packageJsonPath,
349
+ profile: request.profile,
350
+ ruleId: 'package.json.package-manager.required',
351
+ severity: 'error',
352
+ }));
353
+ request.plannedChanges.push(createPlannedChange({
354
+ description: `Add packageManager: "bun@${Bun.version}" to package.json.`,
355
+ filePath: request.packageJsonPath,
356
+ kind: 'update-json',
357
+ ruleId: 'package.json.package-manager.required',
358
+ }));
359
+ }
360
+ else if (!packageJson.packageManager.startsWith('bun@')) {
361
+ diagnostics.push(createDiagnostic({
362
+ code: 'field-invalid',
363
+ message: 'package.json packageManager must be Bun-aligned, for example "bun@1.x".',
364
+ path: request.packageJsonPath,
365
+ profile: request.profile,
366
+ ruleId: 'package.json.package-manager.bun',
367
+ severity: 'error',
368
+ }));
369
+ }
370
+ const scripts = isRecord(packageJson.scripts) ? packageJson.scripts : null;
371
+ for (const scriptName of REQUIRED_PUBLIC_PACKAGE_SCRIPTS) {
372
+ if (scripts === null || !isNonEmptyString(scripts[scriptName])) {
373
+ diagnostics.push(createDiagnostic({
374
+ code: 'missing-script',
375
+ message: `Missing required package script: ${scriptName}`,
376
+ path: request.packageJsonPath,
377
+ profile: request.profile,
378
+ ruleId: mapScriptRule(scriptName),
379
+ severity: 'error',
380
+ }));
381
+ }
382
+ }
383
+ const devDependencies = isRecord(packageJson.devDependencies)
384
+ ? packageJson.devDependencies
385
+ : null;
386
+ if (!hasDependency(devDependencies, 'typescript')) {
387
+ diagnostics.push(createDiagnostic({
388
+ code: 'missing-dependency',
389
+ message: 'Public package repos must declare TypeScript in devDependencies.',
390
+ path: request.packageJsonPath,
391
+ profile: request.profile,
392
+ ruleId: 'package.dependencies.typescript.required',
393
+ severity: 'error',
394
+ }));
395
+ }
396
+ if (!hasDependency(devDependencies, '@changesets/cli')) {
397
+ diagnostics.push(createDiagnostic({
398
+ code: 'missing-dependency',
399
+ message: 'Public package repos must declare @changesets/cli in devDependencies.',
400
+ path: request.packageJsonPath,
401
+ profile: request.profile,
402
+ ruleId: 'package.dependencies.changesets.required',
403
+ severity: 'error',
404
+ }));
405
+ }
406
+ if (!hasDependency(devDependencies, '@types/bun')) {
407
+ diagnostics.push(createDiagnostic({
408
+ code: 'missing-dependency',
409
+ message: 'Public package repos must declare @types/bun in devDependencies.',
410
+ path: request.packageJsonPath,
411
+ profile: request.profile,
412
+ ruleId: 'package.dependencies.types-bun.required',
413
+ severity: 'error',
414
+ }));
415
+ }
416
+ if (!hasDependency(devDependencies, '@types/node')) {
417
+ diagnostics.push(createDiagnostic({
418
+ code: 'missing-dependency',
419
+ message: 'Public package repos must declare @types/node in devDependencies.',
420
+ path: request.packageJsonPath,
421
+ profile: request.profile,
422
+ ruleId: 'package.dependencies.types-node.required',
423
+ severity: 'error',
424
+ }));
425
+ }
426
+ if (expectsDevtools(packageJson) && !hasDependency(devDependencies, '@ankhorage/devtools')) {
427
+ diagnostics.push(createDiagnostic({
428
+ code: 'missing-dependency',
429
+ message: 'This package consumes shared Ankhorage lint/format/knip tooling and must declare @ankhorage/devtools.',
430
+ path: request.packageJsonPath,
431
+ profile: request.profile,
432
+ ruleId: 'package.dependencies.devtools.required',
433
+ severity: 'error',
434
+ }));
435
+ }
436
+ if ((await expectsParadox(request.targetPath, packageJson)) &&
437
+ !hasDependency(devDependencies, '@ankhorage/paradox')) {
438
+ diagnostics.push(createDiagnostic({
439
+ code: 'missing-dependency',
440
+ message: 'This package owns Paradox docs generation and must declare @ankhorage/paradox.',
441
+ path: request.packageJsonPath,
442
+ profile: request.profile,
443
+ ruleId: 'package.dependencies.paradox.required',
444
+ severity: 'error',
445
+ }));
446
+ }
447
+ return diagnostics.concat(await validateAnkhMetadataAndProvider({
448
+ diagnostics,
449
+ packageJson,
450
+ packageJsonPath: request.packageJsonPath,
451
+ plannedChanges: request.plannedChanges,
452
+ profile: request.profile,
453
+ targetPath: request.targetPath,
454
+ }));
455
+ }
456
+ async function validateAnkhMetadataAndProvider(request) {
85
457
  const diagnostics = [];
458
+ const providerPackage = await isProviderPackage(request.targetPath);
459
+ const metadata = request.packageJson.ankh;
460
+ if (providerPackage && metadata === undefined) {
461
+ diagnostics.push(createDiagnostic({
462
+ code: 'field-missing',
463
+ message: 'Provider packages must define package.json.ankh metadata.',
464
+ path: request.packageJsonPath,
465
+ profile: request.profile,
466
+ ruleId: 'package.ankh.required-for-provider',
467
+ severity: 'error',
468
+ }));
469
+ request.plannedChanges.push(createPlannedChange({
470
+ description: 'Add a package.json.ankh block describing the implemented provider category, provider path, and capabilities.',
471
+ filePath: request.packageJsonPath,
472
+ kind: 'update-json',
473
+ ruleId: 'package.ankh.required-for-provider',
474
+ }));
475
+ return diagnostics;
476
+ }
477
+ if (metadata === undefined) {
478
+ return diagnostics;
479
+ }
480
+ const metadataValidation = validateAnkhMetadataShape(metadata, providerPackage);
481
+ if (metadataValidation !== null) {
482
+ diagnostics.push(createDiagnostic({
483
+ code: 'invalid-ankh-metadata',
484
+ message: metadataValidation,
485
+ path: request.packageJsonPath,
486
+ profile: request.profile,
487
+ ruleId: 'package.ankh.present.valid-shape',
488
+ severity: 'error',
489
+ }));
490
+ return diagnostics;
491
+ }
492
+ if (!providerPackage) {
493
+ return diagnostics;
494
+ }
495
+ if (!isRecord(metadata) || !isNonEmptyString(metadata.provider)) {
496
+ diagnostics.push(createDiagnostic({
497
+ code: 'invalid-ankh-metadata',
498
+ message: 'Provider package metadata must define a package-relative provider path.',
499
+ path: request.packageJsonPath,
500
+ profile: request.profile,
501
+ ruleId: 'package.ankh.provider-path.required',
502
+ severity: 'error',
503
+ }));
504
+ return diagnostics;
505
+ }
506
+ const inspection = await inspectProviderSource({
507
+ metadataProviderPath: metadata.provider,
508
+ packageRoot: request.targetPath,
509
+ profile: request.profile,
510
+ });
511
+ if (inspection.diagnostic !== null) {
512
+ diagnostics.push(inspection.diagnostic);
513
+ return diagnostics;
514
+ }
515
+ if (inspection.result === null) {
516
+ return diagnostics;
517
+ }
518
+ const metadataCapabilities = Array.isArray(metadata.capabilities)
519
+ ? metadata.capabilities.filter(isNonEmptyString)
520
+ : [];
521
+ const providerCapabilities = uniqueSorted(inspection.result.capabilities);
522
+ const commandCapabilities = uniqueSorted(inspection.result.commandCapabilities);
523
+ if (!sameStringSet(metadataCapabilities, commandCapabilities)) {
524
+ diagnostics.push(createDiagnostic({
525
+ code: 'field-invalid',
526
+ message: 'package.json.ankh capabilities must match the implemented provider command capabilities exactly.',
527
+ path: request.packageJsonPath,
528
+ profile: request.profile,
529
+ ruleId: 'package.ankh.capabilities.match-provider',
530
+ severity: 'error',
531
+ }));
532
+ }
533
+ if (!sameStringSet(providerCapabilities, commandCapabilities)) {
534
+ diagnostics.push(createDiagnostic({
535
+ code: 'field-invalid',
536
+ message: 'Provider export capabilities must match the provider command capability surface.',
537
+ path: inspection.result.sourcePath,
538
+ profile: request.profile,
539
+ ruleId: 'provider.commands.match-capabilities',
540
+ severity: 'error',
541
+ }));
542
+ }
543
+ if (!sameStringSet(inspection.result.commandPaths, inspection.result.handlerPaths)) {
544
+ diagnostics.push(createDiagnostic({
545
+ code: 'field-invalid',
546
+ message: 'Every provider command descriptor must have a matching handler path.',
547
+ path: inspection.result.sourcePath,
548
+ profile: request.profile,
549
+ ruleId: 'provider.commands.match-handlers',
550
+ severity: 'error',
551
+ }));
552
+ }
553
+ return diagnostics;
554
+ }
555
+ async function inspectProviderSource(request) {
556
+ const sourcePath = await resolveProviderSourcePath(request.packageRoot, request.metadataProviderPath);
557
+ if (sourcePath === null) {
558
+ return {
559
+ diagnostic: createDiagnostic({
560
+ code: 'provider-source-missing',
561
+ message: 'Provider package metadata is present, but no provider source file could be resolved for inspection.',
562
+ path: request.packageRoot,
563
+ profile: request.profile,
564
+ ruleId: 'provider.source.required',
565
+ severity: 'error',
566
+ }),
567
+ result: null,
568
+ };
569
+ }
570
+ try {
571
+ const importedModule = await import(pathToFileURL(sourcePath).href);
572
+ const provider = isRecord(importedModule) ? importedModule.default : undefined;
573
+ if (!isRecord(provider)) {
574
+ return {
575
+ diagnostic: createDiagnostic({
576
+ code: 'provider-source-import-failed',
577
+ message: 'Provider source default export must be an object.',
578
+ path: sourcePath,
579
+ profile: request.profile,
580
+ ruleId: 'provider.source.importable',
581
+ severity: 'error',
582
+ }),
583
+ result: null,
584
+ };
585
+ }
586
+ const capabilities = readStringArray(provider.capabilities);
587
+ const commands = Array.isArray(provider.commands) ? provider.commands : [];
588
+ const handlers = Array.isArray(provider.handlers) ? provider.handlers : [];
589
+ const commandCapabilities = commands.flatMap((command) => isRecord(command) && isNonEmptyString(command.capability) ? [command.capability] : []);
590
+ const commandPaths = commands.flatMap((command) => {
591
+ const pathValue = isRecord(command) ? command.path : undefined;
592
+ return isStringTuple(pathValue) ? [pathValue.join(' ')] : [];
593
+ });
594
+ const handlerPaths = handlers.flatMap((handler) => {
595
+ const pathValue = isRecord(handler) ? handler.path : undefined;
596
+ const handlerFunction = isRecord(handler) ? handler.handler : undefined;
597
+ return isStringTuple(pathValue) && typeof handlerFunction === 'function'
598
+ ? [pathValue.join(' ')]
599
+ : [];
600
+ });
601
+ return {
602
+ diagnostic: null,
603
+ result: {
604
+ capabilities,
605
+ commandCapabilities,
606
+ commandPaths,
607
+ handlerPaths,
608
+ sourcePath,
609
+ },
610
+ };
611
+ }
612
+ catch (error) {
613
+ const message = error instanceof Error ? error.message : String(error);
614
+ return {
615
+ diagnostic: createDiagnostic({
616
+ code: 'provider-source-import-failed',
617
+ message: `Failed to import provider source: ${message}`,
618
+ path: sourcePath,
619
+ profile: request.profile,
620
+ ruleId: 'provider.source.importable',
621
+ severity: 'error',
622
+ }),
623
+ result: null,
624
+ };
625
+ }
626
+ }
627
+ async function resolveProviderSourcePath(packageRoot, metadataProviderPath) {
628
+ const candidates = new Set();
629
+ candidates.add(path.resolve(packageRoot, metadataProviderPath));
630
+ if (metadataProviderPath.startsWith('./dist/') && metadataProviderPath.endsWith('.js')) {
631
+ const relativeStem = metadataProviderPath.slice('./dist/'.length, -'.js'.length);
632
+ candidates.add(path.resolve(packageRoot, 'src', `${relativeStem}.ts`));
633
+ candidates.add(path.resolve(packageRoot, 'src', `${relativeStem}.tsx`));
634
+ candidates.add(path.resolve(packageRoot, 'src', `${relativeStem}.js`));
635
+ candidates.add(path.resolve(packageRoot, 'src', `${relativeStem}.mjs`));
636
+ }
637
+ for (const providerSourceCandidate of PROVIDER_SOURCE_CANDIDATES) {
638
+ candidates.add(path.resolve(packageRoot, providerSourceCandidate));
639
+ }
640
+ for (const candidate of candidates) {
641
+ if (await pathExists(candidate)) {
642
+ return candidate;
643
+ }
644
+ }
645
+ return null;
646
+ }
647
+ async function findRepoMarkers(targetPath) {
648
+ return (await Promise.all(REPO_MARKERS.map(async (marker) => (await pathExists(path.join(targetPath, marker.relativePath))) ? marker.label : null))).filter((marker) => marker !== null);
649
+ }
650
+ async function readPackageJson(packageJsonPath) {
86
651
  let parsed;
87
652
  try {
88
653
  parsed = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
89
654
  }
90
655
  catch (error) {
91
- diagnostics.push(createDiagnostic('error', 'invalid-package-json', packageJsonPath, createInvalidPackageJsonMessage(error)));
92
- return diagnostics;
656
+ return {
657
+ diagnostic: createDiagnostic({
658
+ code: 'invalid-package-json',
659
+ message: createInvalidPackageJsonMessage(error),
660
+ path: packageJsonPath,
661
+ profile: 'unknown',
662
+ ruleId: 'target.package-json.required',
663
+ severity: 'error',
664
+ }),
665
+ parsed: null,
666
+ };
93
667
  }
94
668
  if (!isRecord(parsed)) {
95
- diagnostics.push(createDiagnostic('error', 'invalid-package-json', packageJsonPath, 'package.json must contain a JSON object.'));
96
- return diagnostics;
97
- }
98
- if (!isNonEmptyString(parsed.name)) {
99
- diagnostics.push(createDiagnostic('error', 'package-name-missing', packageJsonPath, 'package.json must define a non-empty "name" field.'));
669
+ return {
670
+ diagnostic: createDiagnostic({
671
+ code: 'invalid-package-json',
672
+ message: 'package.json must contain a JSON object.',
673
+ path: packageJsonPath,
674
+ profile: 'unknown',
675
+ ruleId: 'target.package-json.required',
676
+ severity: 'error',
677
+ }),
678
+ parsed: null,
679
+ };
100
680
  }
101
- if (!isNonEmptyString(parsed.version)) {
102
- diagnostics.push(createDiagnostic('error', 'package-version-missing', packageJsonPath, 'package.json must define a non-empty "version" field.'));
681
+ return {
682
+ diagnostic: null,
683
+ parsed,
684
+ };
685
+ }
686
+ function detectProfile(repoMarkers, packageJson) {
687
+ if (packageJson !== null) {
688
+ if (packageJson.name === 'ankhorage4') {
689
+ return 'integration-monorepo';
690
+ }
691
+ if (isNonEmptyString(packageJson.name) && packageJson.name.startsWith('@ankhorage/')) {
692
+ return packageJson.private === true ? 'unknown' : 'public-package';
693
+ }
103
694
  }
104
- if (!isNonEmptyString(parsed.type)) {
105
- diagnostics.push(createDiagnostic('error', 'package-type-missing', packageJsonPath, 'package.json must define a non-empty "type" field.'));
695
+ if (repoMarkers.length > 0) {
696
+ return 'unknown';
106
697
  }
107
- if ('ankh' in parsed) {
108
- const validationError = validateAnkhMetadataShape(parsed.ankh);
109
- if (validationError !== null) {
110
- diagnostics.push(createDiagnostic('error', 'invalid-ankh-metadata', targetPath, validationError));
698
+ return 'unknown';
699
+ }
700
+ async function isProviderPackage(targetPath) {
701
+ for (const candidate of PROVIDER_SOURCE_CANDIDATES) {
702
+ if (await pathExists(path.join(targetPath, candidate))) {
703
+ return true;
111
704
  }
112
705
  }
113
- return diagnostics;
706
+ return false;
114
707
  }
115
- async function maybeWarnForMissingPath(diagnostics, rootPath, relativePath, code) {
708
+ async function maybeRequirePath(diagnostics, plannedChanges, rootPath, relativePath, ruleId, kind) {
116
709
  const absolutePath = path.join(rootPath, relativePath);
117
710
  if (await pathExists(absolutePath)) {
118
711
  return;
119
712
  }
120
- diagnostics.push(createDiagnostic('warning', code, absolutePath, `Missing expected repo artifact: ${relativePath}`));
713
+ diagnostics.push(createDiagnostic({
714
+ code: 'missing-path',
715
+ message: `Missing expected repo artifact: ${relativePath}`,
716
+ path: absolutePath,
717
+ profile: 'public-package',
718
+ ruleId,
719
+ severity: 'error',
720
+ }));
721
+ plannedChanges.push(createPlannedChange({
722
+ description: `Create missing ${relativePath}.`,
723
+ filePath: absolutePath,
724
+ kind,
725
+ ruleId,
726
+ }));
727
+ }
728
+ function requireNonEmptyStringField(request) {
729
+ if (isNonEmptyString(request.packageJson[request.fieldName])) {
730
+ return;
731
+ }
732
+ request.diagnostics.push(createDiagnostic({
733
+ code: 'field-missing',
734
+ message: `package.json must define a non-empty "${request.fieldName}" field.`,
735
+ path: request.packageJsonPath,
736
+ profile: request.profile,
737
+ ruleId: request.ruleId,
738
+ severity: 'error',
739
+ }));
740
+ }
741
+ function requireRecordField(request) {
742
+ if (isRecord(request.packageJson[request.fieldName])) {
743
+ return;
744
+ }
745
+ request.diagnostics.push(createDiagnostic({
746
+ code: 'field-missing',
747
+ message: `package.json must define an object "${request.fieldName}" field.`,
748
+ path: request.packageJsonPath,
749
+ profile: request.profile,
750
+ ruleId: request.ruleId,
751
+ severity: 'error',
752
+ }));
753
+ }
754
+ function requireStringArrayField(request) {
755
+ if (readStringArray(request.packageJson[request.fieldName]).length > 0) {
756
+ return;
757
+ }
758
+ request.diagnostics.push(createDiagnostic({
759
+ code: 'field-missing',
760
+ message: `package.json must define a non-empty string array "${request.fieldName}" field.`,
761
+ path: request.packageJsonPath,
762
+ profile: request.profile,
763
+ ruleId: request.ruleId,
764
+ severity: 'error',
765
+ }));
766
+ }
767
+ async function expectsParadox(targetPath, packageJson) {
768
+ const scripts = isRecord(packageJson.scripts) ? packageJson.scripts : null;
769
+ const docsScript = scripts?.docs;
770
+ if (isNonEmptyString(docsScript) &&
771
+ (docsScript.includes('paradox') || docsScript.includes('@ankhorage/paradox'))) {
772
+ return true;
773
+ }
774
+ return ((await pathExists(path.join(targetPath, 'paradox.config.ts'))) ||
775
+ (await pathExists(path.join(targetPath, 'src', 'readme-usage.ts'))));
121
776
  }
122
- function validateAnkhMetadataShape(value) {
777
+ function expectsDevtools(packageJson) {
778
+ const scripts = isRecord(packageJson.scripts) ? packageJson.scripts : null;
779
+ if (scripts === null) {
780
+ return false;
781
+ }
782
+ return Object.values(scripts).some((scriptValue) => isNonEmptyString(scriptValue) &&
783
+ (scriptValue.includes('ankhorage-eslint') ||
784
+ scriptValue.includes('ankhorage-prettier') ||
785
+ scriptValue.includes('ankhorage-knip')));
786
+ }
787
+ function hasDependency(dependencyMap, dependencyName) {
788
+ return dependencyMap !== null && isNonEmptyString(dependencyMap[dependencyName]);
789
+ }
790
+ function validateAnkhMetadataShape(value, providerPackage) {
123
791
  if (!isRecord(value)) {
124
792
  return 'package.json "ankh" metadata must be an object when present.';
125
793
  }
126
794
  if (!isNonEmptyString(value.category)) {
127
795
  return 'package.json "ankh.category" must be a non-empty string.';
128
796
  }
129
- if (!(value.provider === null || isPackageRelativeProviderPath(value.provider))) {
130
- return 'package.json "ankh.provider" must be null or a package-relative "./..." string.';
797
+ if (providerPackage &&
798
+ !(isNonEmptyString(value.provider) && isPackageRelativeProviderPath(value.provider))) {
799
+ return 'Provider package metadata must define a package-relative "./..." provider path.';
800
+ }
801
+ if (!providerPackage &&
802
+ value.provider !== undefined &&
803
+ !(value.provider === null || isPackageRelativeProviderPath(value.provider))) {
804
+ return 'Non-provider package metadata, when present, must use null or a package-relative "./..." provider value.';
131
805
  }
132
806
  if (!Array.isArray(value.capabilities) ||
133
807
  value.capabilities.some((capability) => !isNonEmptyString(capability))) {
@@ -135,12 +809,53 @@ function validateAnkhMetadataShape(value) {
135
809
  }
136
810
  return null;
137
811
  }
138
- function createDiagnostic(severity, code, diagnosticPath, message) {
812
+ function mapScriptRule(scriptName) {
813
+ switch (scriptName) {
814
+ case 'build':
815
+ return 'package.scripts.build.required';
816
+ case 'typecheck':
817
+ return 'package.scripts.typecheck.required';
818
+ case 'lint':
819
+ return 'package.scripts.lint.required';
820
+ case 'lint:fix':
821
+ return 'package.scripts.lint-fix.required';
822
+ case 'format':
823
+ return 'package.scripts.format.required';
824
+ case 'format:check':
825
+ return 'package.scripts.format-check.required';
826
+ case 'test':
827
+ return 'package.scripts.test.required';
828
+ case 'knip':
829
+ return 'package.scripts.knip.required';
830
+ case 'docs':
831
+ return 'package.scripts.docs.required';
832
+ case 'changeset':
833
+ return 'package.scripts.changeset.required';
834
+ case 'changeset:status':
835
+ return 'package.scripts.changeset-status.required';
836
+ case 'version-packages':
837
+ return 'package.scripts.version-packages.required';
838
+ }
839
+ }
840
+ function createEarlyResult(targetPath, diagnostic) {
139
841
  return {
140
- code,
141
- message,
142
- path: diagnosticPath,
143
- severity,
842
+ appliedChecks: [],
843
+ diagnostics: [diagnostic],
844
+ fixPlan: null,
845
+ hasPackageJson: false,
846
+ plannedChanges: [],
847
+ profile: 'unknown',
848
+ repoMarkers: [],
849
+ targetPath,
850
+ };
851
+ }
852
+ function createDiagnostic(diagnostic) {
853
+ return diagnostic;
854
+ }
855
+ function createPlannedChange(change) {
856
+ return {
857
+ ...change,
858
+ safe: true,
144
859
  };
145
860
  }
146
861
  function createInvalidPackageJsonMessage(error) {
@@ -176,4 +891,18 @@ function isPackageRelativeProviderPath(value) {
176
891
  function isRecord(value) {
177
892
  return typeof value === 'object' && value !== null && !Array.isArray(value);
178
893
  }
894
+ function isStringTuple(value) {
895
+ return Array.isArray(value) && value.length > 0 && value.every(isNonEmptyString);
896
+ }
897
+ function readStringArray(value) {
898
+ return Array.isArray(value) ? value.filter(isNonEmptyString) : [];
899
+ }
900
+ function sameStringSet(left, right) {
901
+ const leftSet = uniqueSorted(left);
902
+ const rightSet = uniqueSorted(right);
903
+ return (leftSet.length === rightSet.length && leftSet.every((value, index) => value === rightSet[index]));
904
+ }
905
+ function uniqueSorted(values) {
906
+ return [...new Set(values)].sort((left, right) => left.localeCompare(right));
907
+ }
179
908
  //# sourceMappingURL=analysis.js.map