@davidvornholt/standards 0.1.0 → 0.2.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.
Files changed (2) hide show
  1. package/package.json +11 -2
  2. package/src/cli.ts +184 -0
package/package.json CHANGED
@@ -1,8 +1,17 @@
1
1
  {
2
2
  "name": "@davidvornholt/standards",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Bootstrap, synchronize, and validate davidvornholt/standards consumers",
5
5
  "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/davidvornholt/standards.git",
9
+ "directory": "packages/standards-cli"
10
+ },
11
+ "homepage": "https://github.com/davidvornholt/standards#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/davidvornholt/standards/issues"
14
+ },
6
15
  "type": "module",
7
16
  "bin": {
8
17
  "standards": "src/cli.ts"
@@ -19,7 +28,7 @@
19
28
  "test": "bun test"
20
29
  },
21
30
  "devDependencies": {
22
- "@davidvornholt/typescript-config": "workspace:*",
31
+ "@davidvornholt/typescript-config": "0.0.0",
23
32
  "@types/bun": "^1.3.14",
24
33
  "typescript": "7.0.2"
25
34
  },
package/src/cli.ts CHANGED
@@ -33,6 +33,8 @@ import {
33
33
  } from 'node:path';
34
34
  import process from 'node:process';
35
35
 
36
+ const { YAML: BunYaml } = await import('bun');
37
+
36
38
  const DEFAULT_UPSTREAM = 'github:davidvornholt/standards';
37
39
 
38
40
  // Characters of a sha256 hex digest shown in drift reports; enough to identify.
@@ -477,6 +479,179 @@ const hasStandardsImport = (justfile: string): boolean =>
477
479
  );
478
480
  });
479
481
 
482
+ const DEPENDABOT_BASELINE_ECOSYSTEMS = ['bun', 'github-actions'] as const;
483
+ const DEPENDABOT_SCHEDULE_INTERVALS = new Set([
484
+ 'daily',
485
+ 'weekly',
486
+ 'monthly',
487
+ 'quarterly',
488
+ 'semiannually',
489
+ 'yearly',
490
+ 'cron',
491
+ ]);
492
+
493
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
494
+ typeof value === 'object' && value !== null && !Array.isArray(value);
495
+
496
+ const isNonEmptyString = (value: unknown): value is string =>
497
+ typeof value === 'string' && value.length > 0;
498
+
499
+ type DependabotUpdateInspection = {
500
+ readonly problems: ReadonlyArray<string>;
501
+ readonly rootEcosystem: string | null;
502
+ };
503
+
504
+ const inspectDependabotSchedule = (
505
+ schedule: unknown,
506
+ label: string,
507
+ ): ReadonlyArray<string> => {
508
+ if (!(isRecord(schedule) && isNonEmptyString(schedule.interval))) {
509
+ return [`${label} must define schedule.interval`];
510
+ }
511
+ if (!DEPENDABOT_SCHEDULE_INTERVALS.has(schedule.interval)) {
512
+ return [`${label} has an unsupported schedule.interval`];
513
+ }
514
+ if (schedule.interval === 'cron' && !isNonEmptyString(schedule.cronjob)) {
515
+ return [`${label} must define schedule.cronjob for a cron interval`];
516
+ }
517
+ return [];
518
+ };
519
+
520
+ type DependabotGroupInspection = {
521
+ readonly problems: ReadonlyArray<string>;
522
+ readonly scheduledGroups: ReadonlySet<string>;
523
+ };
524
+
525
+ const inspectDependabotGroups = (
526
+ groups: unknown,
527
+ ): DependabotGroupInspection => {
528
+ if (groups === undefined) {
529
+ return { problems: [], scheduledGroups: new Set() };
530
+ }
531
+ if (!isRecord(groups)) {
532
+ return {
533
+ problems: [
534
+ '.github/dependabot.yml multi-ecosystem-groups must be a mapping',
535
+ ],
536
+ scheduledGroups: new Set(),
537
+ };
538
+ }
539
+
540
+ const problems: Array<string> = [];
541
+ const scheduledGroups = new Set<string>();
542
+ for (const [name, group] of Object.entries(groups)) {
543
+ const label = `.github/dependabot.yml multi-ecosystem-groups.${name}`;
544
+ const groupProblems = isRecord(group)
545
+ ? inspectDependabotSchedule(group.schedule, label)
546
+ : [`${label} must be a mapping`];
547
+ problems.push(...groupProblems);
548
+ if (groupProblems.length === 0) {
549
+ scheduledGroups.add(name);
550
+ }
551
+ }
552
+ return { problems, scheduledGroups };
553
+ };
554
+
555
+ const inspectDependabotUpdate = (
556
+ update: unknown,
557
+ index: number,
558
+ scheduledGroups: ReadonlySet<string>,
559
+ ): DependabotUpdateInspection => {
560
+ const label = `.github/dependabot.yml updates[${index}]`;
561
+ if (!isRecord(update)) {
562
+ return { problems: [`${label} must be a mapping`], rootEcosystem: null };
563
+ }
564
+
565
+ const {
566
+ directory,
567
+ directories,
568
+ schedule,
569
+ 'multi-ecosystem-group': multiEcosystemGroup,
570
+ 'package-ecosystem': ecosystem,
571
+ } = update;
572
+ const problems: Array<string> = [];
573
+ if (!isNonEmptyString(ecosystem)) {
574
+ problems.push(`${label} must define package-ecosystem`);
575
+ }
576
+
577
+ const hasDirectory = isNonEmptyString(directory);
578
+ const hasDirectories =
579
+ Array.isArray(directories) &&
580
+ directories.length > 0 &&
581
+ directories.every(isNonEmptyString);
582
+ if (hasDirectory === hasDirectories) {
583
+ problems.push(
584
+ `${label} must define exactly one of directory or directories`,
585
+ );
586
+ }
587
+
588
+ if (schedule === undefined && isNonEmptyString(multiEcosystemGroup)) {
589
+ if (!scheduledGroups.has(multiEcosystemGroup)) {
590
+ problems.push(
591
+ `${label} must reference a scheduled multi-ecosystem group`,
592
+ );
593
+ }
594
+ } else {
595
+ problems.push(...inspectDependabotSchedule(schedule, label));
596
+ }
597
+
598
+ const targetsRoot =
599
+ directory === '/' ||
600
+ (Array.isArray(directories) && directories.includes('/'));
601
+ return {
602
+ problems,
603
+ rootEcosystem:
604
+ isNonEmptyString(ecosystem) && targetsRoot ? ecosystem : null,
605
+ };
606
+ };
607
+
608
+ const inspectDependabot = (raw: string): ReadonlyArray<string> => {
609
+ const problems: Array<string> = [];
610
+ let config: unknown;
611
+ try {
612
+ config = BunYaml.parse(raw);
613
+ } catch {
614
+ return ['.github/dependabot.yml must contain valid YAML'];
615
+ }
616
+
617
+ if (!isRecord(config)) {
618
+ return ['.github/dependabot.yml must contain a YAML mapping'];
619
+ }
620
+ if (config.version !== 2) {
621
+ problems.push('.github/dependabot.yml must use version: 2');
622
+ }
623
+
624
+ const { updates, 'multi-ecosystem-groups': multiEcosystemGroups } = config;
625
+ if (!Array.isArray(updates)) {
626
+ problems.push('.github/dependabot.yml must define an updates list');
627
+ return problems;
628
+ }
629
+
630
+ const groupInspection = inspectDependabotGroups(multiEcosystemGroups);
631
+ problems.push(...groupInspection.problems);
632
+ const rootEcosystems = new Set<string>();
633
+ for (const [index, update] of updates.entries()) {
634
+ const inspection = inspectDependabotUpdate(
635
+ update,
636
+ index,
637
+ groupInspection.scheduledGroups,
638
+ );
639
+ problems.push(...inspection.problems);
640
+ if (inspection.rootEcosystem !== null) {
641
+ rootEcosystems.add(inspection.rootEcosystem);
642
+ }
643
+ }
644
+
645
+ for (const ecosystem of DEPENDABOT_BASELINE_ECOSYSTEMS) {
646
+ if (!rootEcosystems.has(ecosystem)) {
647
+ problems.push(
648
+ `.github/dependabot.yml must include a root-directory ${ecosystem} ecosystem`,
649
+ );
650
+ }
651
+ }
652
+ return problems;
653
+ };
654
+
480
655
  const inspectPackageJson = (packageRaw: string): ReadonlyArray<string> => {
481
656
  const problems: Array<string> = [];
482
657
  const packageJson = JSON.parse(packageRaw) as Record<string, unknown>;
@@ -514,6 +689,15 @@ const runDoctor = async (consumer: string): Promise<boolean> => {
514
689
  problems.push('AGENTS.local.md must exist for project-specific guidance');
515
690
  }
516
691
 
692
+ const dependabot = await readTextIfPresent(
693
+ join(consumer, '.github/dependabot.yml'),
694
+ );
695
+ if (dependabot === null) {
696
+ problems.push('.github/dependabot.yml must exist');
697
+ } else {
698
+ problems.push(...inspectDependabot(dependabot));
699
+ }
700
+
517
701
  const packagePath = join(consumer, 'package.json');
518
702
  const packageRaw = await readTextIfPresent(packagePath);
519
703
  if (packageRaw === null) {