@ankhorage/doctor 0.11.6 → 0.11.8

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
@@ -2,10 +2,12 @@ import { promises as fs } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
4
 
5
+ import { ARCHITECTURE_POLICY } from '@ankhorage/policy/architecture';
5
6
  import { uniqueSortedStrings } from '@ankhorage/utility/array';
6
7
 
7
8
  import type { DoctorDiagnostic, DoctorPolicyProfile, DoctorRuleId } from './diagnostics.js';
8
9
  import type { DoctorReadiness } from './readiness.js';
10
+ import { getArchitecturePolicyRule } from './utils/getArchitecturePolicyRule.js';
9
11
 
10
12
  export type DoctorTargetCheck = 'manifest' | 'package' | 'repo';
11
13
  export type DoctorTargetMode = 'fix' | 'package' | 'repo' | 'validate';
@@ -65,21 +67,7 @@ const REPO_MARKERS = [
65
67
  { label: 'yarn.lock', relativePath: 'yarn.lock' },
66
68
  ] as const;
67
69
 
68
- const REQUIRED_PUBLIC_PACKAGE_SCRIPTS = [
69
- 'build',
70
- 'typecheck',
71
- 'lint',
72
- 'lint:fix',
73
- 'format',
74
- 'format:check',
75
- 'test',
76
- 'test:standalone',
77
- 'knip:check',
78
- 'docs',
79
- 'changeset',
80
- 'changeset:status',
81
- 'version-packages',
82
- ] as const;
70
+ const PUBLIC_PACKAGE_POLICY = ARCHITECTURE_POLICY.publicPackage;
83
71
 
84
72
  const PROVIDER_SOURCE_CANDIDATES = [
85
73
  path.join('src', 'ankh.provider.ts'),
@@ -245,46 +233,16 @@ async function analyzeRepoPolicy(request: {
245
233
  const diagnostics: DoctorDiagnostic[] = [];
246
234
 
247
235
  if (request.profile === 'public-package') {
248
- await maybeRequirePath(
249
- diagnostics,
250
- request.plannedChanges,
251
- request.targetPath,
252
- 'README.md',
253
- 'repo.readme.required',
254
- 'create-file',
255
- );
256
- await maybeRequirePath(
257
- diagnostics,
258
- request.plannedChanges,
259
- request.targetPath,
260
- 'CHANGELOG.md',
261
- 'repo.changelog.required',
262
- 'create-file',
263
- );
264
- await maybeRequirePath(
265
- diagnostics,
266
- request.plannedChanges,
267
- request.targetPath,
268
- 'LICENSE',
269
- 'repo.license.required',
270
- 'create-file',
271
- );
272
- await maybeRequirePath(
273
- diagnostics,
274
- request.plannedChanges,
275
- request.targetPath,
276
- '.changeset',
277
- 'repo.changeset.required',
278
- 'create-directory',
279
- );
280
- await maybeRequirePath(
281
- diagnostics,
282
- request.plannedChanges,
283
- request.targetPath,
284
- path.join('.github', 'workflows'),
285
- 'repo.workflows.required',
286
- 'create-directory',
287
- );
236
+ for (const artifact of PUBLIC_PACKAGE_POLICY.requiredRepoPaths) {
237
+ await maybeRequirePath(
238
+ diagnostics,
239
+ request.plannedChanges,
240
+ request.targetPath,
241
+ artifact.path,
242
+ artifact.ruleId,
243
+ artifact.kind === 'file' ? 'create-file' : 'create-directory',
244
+ );
245
+ }
288
246
  }
289
247
 
290
248
  if (request.profile === 'integration-monorepo') {
@@ -362,29 +320,11 @@ async function analyzePackagePolicy(request: {
362
320
  const { packageJson } = request;
363
321
 
364
322
  if (request.profile === 'public-package') {
365
- requireNonEmptyStringField({
323
+ requirePublicPackageFields({
366
324
  diagnostics,
367
325
  packageJson,
368
326
  packageJsonPath: request.packageJsonPath,
369
327
  profile: request.profile,
370
- ruleId: 'package.json.name.required',
371
- fieldName: 'name',
372
- });
373
- requireNonEmptyStringField({
374
- diagnostics,
375
- packageJson,
376
- packageJsonPath: request.packageJsonPath,
377
- profile: request.profile,
378
- ruleId: 'package.json.version.required',
379
- fieldName: 'version',
380
- });
381
- requireNonEmptyStringField({
382
- diagnostics,
383
- packageJson,
384
- packageJsonPath: request.packageJsonPath,
385
- profile: request.profile,
386
- ruleId: 'package.json.type.required',
387
- fieldName: 'type',
388
328
  });
389
329
  }
390
330
 
@@ -401,79 +341,6 @@ async function analyzePackagePolicy(request: {
401
341
  );
402
342
  }
403
343
 
404
- requireNonEmptyStringField({
405
- diagnostics,
406
- packageJson,
407
- packageJsonPath: request.packageJsonPath,
408
- profile: request.profile,
409
- ruleId: 'package.json.description.required',
410
- fieldName: 'description',
411
- });
412
- requireRecordField({
413
- diagnostics,
414
- packageJson,
415
- packageJsonPath: request.packageJsonPath,
416
- profile: request.profile,
417
- ruleId: 'package.json.repository.required',
418
- fieldName: 'repository',
419
- });
420
- requireNonEmptyStringField({
421
- diagnostics,
422
- packageJson,
423
- packageJsonPath: request.packageJsonPath,
424
- profile: request.profile,
425
- ruleId: 'package.json.homepage.required',
426
- fieldName: 'homepage',
427
- });
428
- requireRecordField({
429
- diagnostics,
430
- packageJson,
431
- packageJsonPath: request.packageJsonPath,
432
- profile: request.profile,
433
- ruleId: 'package.json.bugs.required',
434
- fieldName: 'bugs',
435
- });
436
- requireNonEmptyStringField({
437
- diagnostics,
438
- packageJson,
439
- packageJsonPath: request.packageJsonPath,
440
- profile: request.profile,
441
- ruleId: 'package.json.license.required',
442
- fieldName: 'license',
443
- });
444
- requireStringArrayField({
445
- diagnostics,
446
- packageJson,
447
- packageJsonPath: request.packageJsonPath,
448
- profile: request.profile,
449
- ruleId: 'package.json.keywords.required',
450
- fieldName: 'keywords',
451
- });
452
- requireStringArrayField({
453
- diagnostics,
454
- packageJson,
455
- packageJsonPath: request.packageJsonPath,
456
- profile: request.profile,
457
- ruleId: 'package.json.files.required',
458
- fieldName: 'files',
459
- });
460
- requireRecordField({
461
- diagnostics,
462
- packageJson,
463
- packageJsonPath: request.packageJsonPath,
464
- profile: request.profile,
465
- ruleId: 'package.json.exports.required',
466
- fieldName: 'exports',
467
- });
468
- requireRecordField({
469
- diagnostics,
470
- packageJson,
471
- packageJsonPath: request.packageJsonPath,
472
- profile: request.profile,
473
- ruleId: 'package.json.publish-config.required',
474
- fieldName: 'publishConfig',
475
- });
476
-
477
344
  if (packageJson.private === true) {
478
345
  diagnostics.push(
479
346
  createDiagnostic({
@@ -481,35 +348,38 @@ async function analyzePackagePolicy(request: {
481
348
  message: 'Public package profiles must not set private: true.',
482
349
  path: request.packageJsonPath,
483
350
  profile: request.profile,
484
- ruleId: 'package.json.private.public-package-disallowed',
485
- severity: 'error',
351
+ ...architecturePolicyRuleFields(PUBLIC_PACKAGE_POLICY.privateDisallowedRuleId),
486
352
  }),
487
353
  );
488
354
  }
489
355
 
490
- if (packageJson.type !== undefined && packageJson.type !== 'module') {
356
+ if (
357
+ packageJson.type !== undefined &&
358
+ packageJson.type !== PUBLIC_PACKAGE_POLICY.packageType.value
359
+ ) {
491
360
  diagnostics.push(
492
361
  createDiagnostic({
493
362
  code: 'field-invalid',
494
- message: 'package.json "type" must be "module".',
363
+ message: `package.json "type" must be "${PUBLIC_PACKAGE_POLICY.packageType.value}".`,
495
364
  path: request.packageJsonPath,
496
365
  profile: request.profile,
497
- ruleId: 'package.json.type.module',
498
- severity: 'error',
366
+ ...architecturePolicyRuleFields(PUBLIC_PACKAGE_POLICY.packageType.ruleId),
499
367
  }),
500
368
  );
501
369
  }
502
370
 
503
371
  const { publishConfig } = packageJson;
504
- if (isRecord(publishConfig) && publishConfig.access !== 'public') {
372
+ if (
373
+ isRecord(publishConfig) &&
374
+ publishConfig.access !== PUBLIC_PACKAGE_POLICY.publishAccess.value
375
+ ) {
505
376
  diagnostics.push(
506
377
  createDiagnostic({
507
378
  code: isNonEmptyString(publishConfig.access) ? 'field-invalid' : 'field-missing',
508
- message: 'package.json publishConfig.access must exist and equal "public".',
379
+ message: `package.json publishConfig.access must exist and equal "${PUBLIC_PACKAGE_POLICY.publishAccess.value}".`,
509
380
  path: request.packageJsonPath,
510
381
  profile: request.profile,
511
- ruleId: 'package.json.publish-config.public',
512
- severity: 'error',
382
+ ...architecturePolicyRuleFields(PUBLIC_PACKAGE_POLICY.publishAccess.ruleId),
513
383
  }),
514
384
  );
515
385
  }
@@ -521,8 +391,7 @@ async function analyzePackagePolicy(request: {
521
391
  message: 'package.json must define a Bun-aligned packageManager field.',
522
392
  path: request.packageJsonPath,
523
393
  profile: request.profile,
524
- ruleId: 'package.json.package-manager.required',
525
- severity: 'error',
394
+ ...architecturePolicyRuleFields(PUBLIC_PACKAGE_POLICY.packageManager.requiredRuleId),
526
395
  }),
527
396
  );
528
397
  request.plannedChanges.push(
@@ -530,7 +399,7 @@ async function analyzePackagePolicy(request: {
530
399
  description: `Add packageManager: "bun@${Bun.version}" to package.json.`,
531
400
  filePath: request.packageJsonPath,
532
401
  kind: 'update-json',
533
- ruleId: 'package.json.package-manager.required',
402
+ ruleId: PUBLIC_PACKAGE_POLICY.packageManager.requiredRuleId,
534
403
  }),
535
404
  );
536
405
  } else if (!packageJson.packageManager.startsWith('bun@')) {
@@ -540,23 +409,21 @@ async function analyzePackagePolicy(request: {
540
409
  message: 'package.json packageManager must be Bun-aligned, for example "bun@1.x".',
541
410
  path: request.packageJsonPath,
542
411
  profile: request.profile,
543
- ruleId: 'package.json.package-manager.bun',
544
- severity: 'error',
412
+ ...architecturePolicyRuleFields(PUBLIC_PACKAGE_POLICY.packageManager.bunRuleId),
545
413
  }),
546
414
  );
547
415
  }
548
416
 
549
417
  const scripts = isRecord(packageJson.scripts) ? packageJson.scripts : null;
550
- for (const scriptName of REQUIRED_PUBLIC_PACKAGE_SCRIPTS) {
551
- if (scripts === null || !isNonEmptyString(scripts[scriptName])) {
418
+ for (const requirement of PUBLIC_PACKAGE_POLICY.requiredScripts) {
419
+ if (scripts === null || !isNonEmptyString(scripts[requirement.name])) {
552
420
  diagnostics.push(
553
421
  createDiagnostic({
554
422
  code: 'missing-script',
555
- message: `Missing required package script: ${scriptName}`,
423
+ message: `Missing required package script: ${requirement.name}`,
556
424
  path: request.packageJsonPath,
557
425
  profile: request.profile,
558
- ruleId: mapScriptRule(scriptName),
559
- severity: 'error',
426
+ ...architecturePolicyRuleFields(requirement.ruleId),
560
427
  }),
561
428
  );
562
429
  }
@@ -573,8 +440,7 @@ async function analyzePackagePolicy(request: {
573
440
  message: 'Public package repos must declare TypeScript in dependencies or devDependencies.',
574
441
  path: request.packageJsonPath,
575
442
  profile: request.profile,
576
- ruleId: 'package.dependencies.typescript.required',
577
- severity: 'error',
443
+ ...architecturePolicyRuleFields(PUBLIC_PACKAGE_POLICY.dependencyRules.typescript),
578
444
  }),
579
445
  );
580
446
  }
@@ -597,8 +463,7 @@ async function analyzePackagePolicy(request: {
597
463
  message: 'Public package repos must declare @types/bun in devDependencies.',
598
464
  path: request.packageJsonPath,
599
465
  profile: request.profile,
600
- ruleId: 'package.dependencies.types-bun.required',
601
- severity: 'error',
466
+ ...architecturePolicyRuleFields(PUBLIC_PACKAGE_POLICY.dependencyRules.bunTypes),
602
467
  }),
603
468
  );
604
469
  }
@@ -610,8 +475,7 @@ async function analyzePackagePolicy(request: {
610
475
  message: 'Public package repos must declare @types/node in devDependencies.',
611
476
  path: request.packageJsonPath,
612
477
  profile: request.profile,
613
- ruleId: 'package.dependencies.types-node.required',
614
- severity: 'error',
478
+ ...architecturePolicyRuleFields(PUBLIC_PACKAGE_POLICY.dependencyRules.nodeTypes),
615
479
  }),
616
480
  );
617
481
  }
@@ -624,8 +488,7 @@ async function analyzePackagePolicy(request: {
624
488
  'This package consumes shared Ankhorage lint/format/knip tooling and must declare @ankhorage/devtools.',
625
489
  path: request.packageJsonPath,
626
490
  profile: request.profile,
627
- ruleId: 'package.dependencies.devtools.required',
628
- severity: 'error',
491
+ ...architecturePolicyRuleFields(PUBLIC_PACKAGE_POLICY.dependencyRules.devtools),
629
492
  }),
630
493
  );
631
494
  }
@@ -641,8 +504,7 @@ async function analyzePackagePolicy(request: {
641
504
  message: 'This package owns Paradox docs generation and must declare @ankhorage/paradox.',
642
505
  path: request.packageJsonPath,
643
506
  profile: request.profile,
644
- ruleId: 'package.dependencies.paradox.required',
645
- severity: 'error',
507
+ ...architecturePolicyRuleFields(PUBLIC_PACKAGE_POLICY.dependencyRules.paradox),
646
508
  }),
647
509
  );
648
510
  }
@@ -684,8 +546,7 @@ function createChangesetsDependencyDiagnostic(request: {
684
546
  : 'Public package repos must not declare @changesets/cli directly; @ankhorage/devtools owns Changesets execution.',
685
547
  path: request.packageJsonPath,
686
548
  profile: request.profile,
687
- ruleId: 'package.dependencies.changesets.required',
688
- severity: 'error',
549
+ ...architecturePolicyRuleFields(PUBLIC_PACKAGE_POLICY.dependencyRules.changesets),
689
550
  });
690
551
  }
691
552
 
@@ -1027,6 +888,37 @@ async function isProviderPackage(targetPath: string): Promise<boolean> {
1027
888
  return false;
1028
889
  }
1029
890
 
891
+ /*** Validate every generic public-package metadata field from canonical Policy. */
892
+ function requirePublicPackageFields(request: {
893
+ readonly diagnostics: DoctorDiagnostic[];
894
+ readonly packageJson: Record<string, unknown>;
895
+ readonly packageJsonPath: string;
896
+ readonly profile: DoctorPolicyProfile;
897
+ }): void {
898
+ for (const requirement of PUBLIC_PACKAGE_POLICY.requiredFields) {
899
+ const input = {
900
+ diagnostics: request.diagnostics,
901
+ fieldName: requirement.name,
902
+ packageJson: request.packageJson,
903
+ packageJsonPath: request.packageJsonPath,
904
+ profile: request.profile,
905
+ ruleId: requirement.ruleId,
906
+ };
907
+
908
+ switch (requirement.kind) {
909
+ case 'non-empty-string':
910
+ requireNonEmptyStringField(input);
911
+ break;
912
+ case 'record':
913
+ requireRecordField(input);
914
+ break;
915
+ case 'string-array':
916
+ requireStringArrayField(input);
917
+ break;
918
+ }
919
+ }
920
+ }
921
+
1030
922
  async function maybeRequirePath(
1031
923
  diagnostics: DoctorDiagnostic[],
1032
924
  plannedChanges: DoctorPlannedChange[],
@@ -1051,8 +943,7 @@ async function maybeRequirePath(
1051
943
  message: `Missing expected repo artifact: ${relativePath}`,
1052
944
  path: absolutePath,
1053
945
  profile: 'public-package',
1054
- ruleId,
1055
- severity: 'error',
946
+ ...architecturePolicyRuleFields(ruleId),
1056
947
  }),
1057
948
  );
1058
949
  plannedChanges.push(
@@ -1083,8 +974,7 @@ function requireNonEmptyStringField(request: {
1083
974
  message: `package.json must define a non-empty "${request.fieldName}" field.`,
1084
975
  path: request.packageJsonPath,
1085
976
  profile: request.profile,
1086
- ruleId: request.ruleId,
1087
- severity: 'error',
977
+ ...architecturePolicyRuleFields(request.ruleId),
1088
978
  }),
1089
979
  );
1090
980
  }
@@ -1107,8 +997,7 @@ function requireRecordField(request: {
1107
997
  message: `package.json must define an object "${request.fieldName}" field.`,
1108
998
  path: request.packageJsonPath,
1109
999
  profile: request.profile,
1110
- ruleId: request.ruleId,
1111
- severity: 'error',
1000
+ ...architecturePolicyRuleFields(request.ruleId),
1112
1001
  }),
1113
1002
  );
1114
1003
  }
@@ -1131,8 +1020,7 @@ function requireStringArrayField(request: {
1131
1020
  message: `package.json must define a non-empty string array "${request.fieldName}" field.`,
1132
1021
  path: request.packageJsonPath,
1133
1022
  profile: request.profile,
1134
- ruleId: request.ruleId,
1135
- severity: 'error',
1023
+ ...architecturePolicyRuleFields(request.ruleId),
1136
1024
  }),
1137
1025
  );
1138
1026
  }
@@ -1212,37 +1100,6 @@ function validateAnkhMetadataShape(value: unknown, providerPackage: boolean): st
1212
1100
  return null;
1213
1101
  }
1214
1102
 
1215
- function mapScriptRule(scriptName: (typeof REQUIRED_PUBLIC_PACKAGE_SCRIPTS)[number]): DoctorRuleId {
1216
- switch (scriptName) {
1217
- case 'build':
1218
- return 'package.scripts.build.required';
1219
- case 'typecheck':
1220
- return 'package.scripts.typecheck.required';
1221
- case 'lint':
1222
- return 'package.scripts.lint.required';
1223
- case 'lint:fix':
1224
- return 'package.scripts.lint-fix.required';
1225
- case 'format':
1226
- return 'package.scripts.format.required';
1227
- case 'format:check':
1228
- return 'package.scripts.format-check.required';
1229
- case 'test':
1230
- return 'package.scripts.test.required';
1231
- case 'test:standalone':
1232
- return 'package.scripts.standalone.required';
1233
- case 'knip:check':
1234
- return 'package.scripts.knip.required';
1235
- case 'docs':
1236
- return 'package.scripts.docs.required';
1237
- case 'changeset':
1238
- return 'package.scripts.changeset.required';
1239
- case 'changeset:status':
1240
- return 'package.scripts.changeset-status.required';
1241
- case 'version-packages':
1242
- return 'package.scripts.version-packages.required';
1243
- }
1244
- }
1245
-
1246
1103
  function createEarlyResult(targetPath: string, diagnostic: DoctorDiagnostic): DoctorAnalysisResult {
1247
1104
  return {
1248
1105
  appliedChecks: [],
@@ -1256,6 +1113,14 @@ function createEarlyResult(targetPath: string, diagnostic: DoctorDiagnostic): Do
1256
1113
  };
1257
1114
  }
1258
1115
 
1116
+ /*** Return canonical rule id and severity for a Policy-owned generic package rule. */
1117
+ function architecturePolicyRuleFields(
1118
+ ruleId: DoctorRuleId,
1119
+ ): Pick<DoctorDiagnostic, 'ruleId' | 'severity'> {
1120
+ const rule = getArchitecturePolicyRule(ruleId);
1121
+ return { ruleId: rule.id, severity: rule.severity };
1122
+ }
1123
+
1259
1124
  function createDiagnostic(diagnostic: DoctorDiagnostic): DoctorDiagnostic {
1260
1125
  return diagnostic;
1261
1126
  }
@@ -1,6 +1,8 @@
1
1
  import { promises as fs } from 'node:fs';
2
2
  import path from 'node:path';
3
3
 
4
+ import { ARCHITECTURE_POLICY } from '@ankhorage/policy/architecture';
5
+
4
6
  import {
5
7
  analyzeDoctorTarget,
6
8
  type DoctorAnalysisRequest,
@@ -9,8 +11,10 @@ import {
9
11
  import type { DoctorDiagnostic, DoctorPolicyProfile } from './diagnostics.js';
10
12
  import { analyzeAppManifestTarget } from './manifestAnalysis.js';
11
13
  import { analyzeSourceArchitecture } from './sourceArchitectureAnalysis.js';
14
+ import { getArchitecturePolicyRule } from './utils/getArchitecturePolicyRule.js';
12
15
 
13
- const LEGACY_ROOT_CLI_SOURCE = path.join('src', 'cli.ts');
16
+ const CLI_POLICY = ARCHITECTURE_POLICY.cli;
17
+ const DEPENDENCY_POLICY = ARCHITECTURE_POLICY.dependencies;
14
18
  const ACTIVE_SOURCE_ROOTS = ['src', 'app', 'apps', 'packages', 'scripts'] as const;
15
19
  const ACTIVE_SOURCE_EXTENSIONS = new Set([
16
20
  '.cjs',
@@ -38,7 +42,6 @@ const IGNORED_SOURCE_DIRECTORIES = new Set([
38
42
  'test',
39
43
  'tests',
40
44
  ]);
41
- const COMPATIBILITY_PACKAGE_PREFIX = '@ankh/';
42
45
  const STUDIO_PACKAGE_NAME = '@ankhorage/studio';
43
46
  const STUDIO_DND_PACKAGE_NAME = '@ankhorage/react-native-reanimated-dnd-web';
44
47
  const STUDIO_RUNTIME_PACKAGE_NAME = '@ankhorage/runtime';
@@ -105,9 +108,10 @@ async function analyzeTargetArchitecture(request: {
105
108
  readonly targetPath: string;
106
109
  }): Promise<DoctorDiagnostic[]> {
107
110
  const diagnostics: DoctorDiagnostic[] = [];
108
- const legacyRootCliPath = path.join(request.targetPath, LEGACY_ROOT_CLI_SOURCE);
111
+ const legacyRootCliPath = path.join(request.targetPath, CLI_POLICY.legacyRootFile);
109
112
  const exportsField = isRecord(request.packageJson.exports) ? request.packageJson.exports : null;
110
- const cliCapable = hasAnkhProvider(request.packageJson) || exportsField?.['./cli'] !== undefined;
113
+ const cliCapable =
114
+ hasAnkhProvider(request.packageJson) || exportsField?.[CLI_POLICY.packageExport] !== undefined;
111
115
 
112
116
  if (await pathExists(legacyRootCliPath)) {
113
117
  diagnostics.push({
@@ -115,20 +119,18 @@ async function analyzeTargetArchitecture(request: {
115
119
  message: 'Package CLI code must live under src/cli/; root src/cli.ts is not allowed.',
116
120
  path: legacyRootCliPath,
117
121
  profile: request.profile,
118
- ruleId: 'package.cli.root-file.disallowed',
119
- severity: 'error',
122
+ ...architectureRuleFields(CLI_POLICY.legacyRootRuleId),
120
123
  });
121
124
  }
122
125
 
123
- if (cliCapable && exportsField?.['./cli'] === undefined) {
126
+ if (cliCapable && exportsField?.[CLI_POLICY.packageExport] === undefined) {
124
127
  diagnostics.push({
125
128
  code: 'field-missing',
126
129
  message:
127
130
  'CLI-capable packages must export "./cli" from package.json and point it at the metadata-declared provider build output.',
128
131
  path: request.packageJsonPath,
129
132
  profile: request.profile,
130
- ruleId: 'package.cli.export.required',
131
- severity: 'error',
133
+ ...architectureRuleFields(CLI_POLICY.exportRuleId),
132
134
  });
133
135
  }
134
136
 
@@ -178,42 +180,41 @@ function validateDependencyArchitecture(request: {
178
180
  const diagnostics: DoctorDiagnostic[] = [];
179
181
 
180
182
  for (const dependency of request.dependencyEntries) {
181
- if (dependency.packageName.startsWith(COMPATIBILITY_PACKAGE_PREFIX)) {
183
+ if (dependency.packageName.startsWith(DEPENDENCY_POLICY.compatibilityPackagePrefix)) {
182
184
  diagnostics.push({
183
185
  code: 'field-invalid',
184
186
  message: `Dependency "${dependency.packageName}" in package.json.${dependency.fieldName} is an old ankhorage4 workspace alias. Depend on "${toOwningPackageSpecifier(dependency.packageName)}" directly.`,
185
187
  path: request.packageJsonPath,
186
188
  profile: request.profile,
187
- ruleId: 'package.dependencies.ankh-workspace-alias.disallowed',
188
- severity: 'error',
189
+ ...architectureRuleFields(DEPENDENCY_POLICY.rules.compatibilityDependency),
189
190
  });
190
191
  }
191
192
 
192
193
  if (
193
194
  request.profile === 'public-package' &&
194
- /^(?:file:|link:|workspace:|github:|git(?:\+[^:]+)?:)/u.test(dependency.version)
195
+ DEPENDENCY_POLICY.localProtocolPrefixes.some((prefix) =>
196
+ dependency.version.startsWith(prefix),
197
+ )
195
198
  ) {
196
199
  diagnostics.push({
197
200
  code: 'field-invalid',
198
201
  message: `Published dependency "${dependency.packageName}" must resolve from a registry version, not "${dependency.version}". Standalone packages cannot rely on local/workspace/Git package state.`,
199
202
  path: request.packageJsonPath,
200
203
  profile: request.profile,
201
- ruleId: 'package.dependencies.local-protocol.disallowed',
202
- severity: 'error',
204
+ ...architectureRuleFields(DEPENDENCY_POLICY.rules.localProtocolDependency),
203
205
  });
204
206
  }
205
207
 
206
208
  if (
207
- dependency.packageName.includes('ankhorage4') ||
208
- dependency.version.includes('ankhorage4')
209
+ dependency.packageName.includes(DEPENDENCY_POLICY.legacySourceMarker) ||
210
+ dependency.version.includes(DEPENDENCY_POLICY.legacySourceMarker)
209
211
  ) {
210
212
  diagnostics.push({
211
213
  code: 'field-invalid',
212
214
  message: `Dependency "${dependency.packageName}" references ankhorage4 as active source. Depend on the extracted owning @ankhorage/* package instead.`,
213
215
  path: request.packageJsonPath,
214
216
  profile: request.profile,
215
- ruleId: 'package.dependencies.ankhorage4-source.disallowed',
216
- severity: 'error',
217
+ ...architectureRuleFields(DEPENDENCY_POLICY.rules.legacySourceDependency),
217
218
  });
218
219
  }
219
220
  }
@@ -228,25 +229,23 @@ function validateActiveSourceImports(request: {
228
229
  const diagnostics: DoctorDiagnostic[] = [];
229
230
 
230
231
  for (const sourceImport of request.activeSourceImports) {
231
- if (sourceImport.specifier.startsWith(COMPATIBILITY_PACKAGE_PREFIX)) {
232
+ if (sourceImport.specifier.startsWith(DEPENDENCY_POLICY.compatibilityPackagePrefix)) {
232
233
  diagnostics.push({
233
234
  code: 'field-invalid',
234
235
  message: `Import "${sourceImport.specifier}" is an old ankhorage4 compatibility boundary. Import "${toOwningPackageSpecifier(sourceImport.specifier)}" directly from the owning package.`,
235
236
  path: sourceImport.filePath,
236
237
  profile: request.profile,
237
- ruleId: 'package.imports.ankh-workspace-alias.disallowed',
238
- severity: 'error',
238
+ ...architectureRuleFields(DEPENDENCY_POLICY.rules.compatibilityImport),
239
239
  });
240
240
  }
241
241
 
242
- if (sourceImport.specifier.includes('ankhorage4')) {
242
+ if (sourceImport.specifier.includes(DEPENDENCY_POLICY.legacySourceMarker)) {
243
243
  diagnostics.push({
244
244
  code: 'field-invalid',
245
245
  message: `Import "${sourceImport.specifier}" references ankhorage4 as active source. Import the extracted owning @ankhorage/* package instead.`,
246
246
  path: sourceImport.filePath,
247
247
  profile: request.profile,
248
- ruleId: 'package.imports.ankhorage4-source.disallowed',
249
- severity: 'error',
248
+ ...architectureRuleFields(DEPENDENCY_POLICY.rules.legacySourceImport),
250
249
  });
251
250
  }
252
251
  }
@@ -429,8 +428,16 @@ function isPackageImport(specifier: string, packageName: string): boolean {
429
428
  return specifier === packageName || specifier.startsWith(`${packageName}/`);
430
429
  }
431
430
 
431
+ /*** Resolve Policy-owned rule metadata for generic architecture diagnostics. */
432
+ function architectureRuleFields(
433
+ ruleId: DoctorDiagnostic['ruleId'],
434
+ ): Pick<DoctorDiagnostic, 'ruleId' | 'severity'> {
435
+ const rule = getArchitecturePolicyRule(ruleId);
436
+ return { ruleId: rule.id, severity: rule.severity };
437
+ }
438
+
432
439
  function toOwningPackageSpecifier(specifier: string): string {
433
- return `@ankhorage/${specifier.slice(COMPATIBILITY_PACKAGE_PREFIX.length)}`;
440
+ return `@ankhorage/${specifier.slice(DEPENDENCY_POLICY.compatibilityPackagePrefix.length)}`;
434
441
  }
435
442
 
436
443
  async function readPackageJson(packageJsonPath: string): Promise<Record<string, unknown> | null> {