@davidvornholt/standards 0.10.0 → 0.10.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/README.md CHANGED
@@ -8,18 +8,23 @@ standards sync
8
8
  standards check
9
9
  standards doctor
10
10
  standards structure
11
+ standards dependabot --check
12
+ standards dependabot --write
11
13
  standards github --check
12
14
  standards github --apply
13
15
  ```
14
16
 
17
+ `dependabot --write` regenerates the composed `.github/dependabot.yml` from the canonical `.github/dependabot.base.yml` and the optional repo-owned `.github/dependabot.local.yml`; `init` and `sync` do the same automatically. `dependabot --check` verifies the generated file still matches its sources, and `doctor`/`check` include the same verification. The overlay is deliberately lean and additive only: new update blocks for repo-specific ecosystems, top-level private registry definitions, and `ignore` or `registries` entries appended by repeating a canonical block's normalized target. Matching blocks cannot add labels, groups, cooldowns, pull-request limits, or other policy.
18
+
15
19
  `github --check` compares the live GitHub repository (merge settings and rulesets) against the merged declaration in `.github/settings.json` + `.github/settings.local.json`; `github --apply` converges the live repository to exactly the declared state. `check` runs the same comparison whenever `.github/settings.json` is present, and fails closed: declared state the token cannot see fails the gate with a message naming the token fix instead of passing unverified. `--apply` re-diffs the PATCH response, so a setting GitHub accepts with HTTP 200 but silently ignores (plan-unavailable features) is a reported failure, not a false success. A repository whose GitHub plan cannot enforce rulesets can declare that in the seam (see `rulesetEnforcement` below); both commands then skip rulesets and plan-gated repository settings, converge the rest, and print an unprotected-branch notice on every run.
16
20
 
17
21
  `structure` enforces the canonical monorepo structure contract: workspace scripts (`check-types`, `lint`, `lint:fix`, `test`), root gate scripts, filtered Turbo convenience aliases, internal `0.0.0`/`workspace:*` versioning, package `exports`, shared tsconfig inheritance, and browser a11y wiring. Only a workspace containing an explicit `*.a11y.ts` suite must provide a `test:a11y` script and direct `@axe-core/playwright` and `@playwright/test` dependencies. `check` includes `structure`, so these rules gate every consumer PR.
18
22
 
19
- `structure --profile source` validates the standards source repository itself, which is deliberately not a consumer. The profile pins its intentional exceptions so they cannot drift silently: exact, ordered root gate scripts that run this CLI from the local checkout (`structure --profile source`, `github --check`, and the Turbo gate) instead of a recursive `standards check`, and a non-private published bin-only CLI workspace that carries a stable release SemVer and exactly maps the `standards` bin to `src/cli.ts` without `exports`. Every other consumer rule still applies.
23
+ `structure --profile source` validates the standards source repository itself, which is deliberately not a consumer. The profile pins its intentional exceptions so they cannot drift silently: exact, ordered root gate scripts that run this CLI from the local checkout (`structure --profile source`, `dependabot --check` or `dependabot --write`, `github --check`, and the Turbo gate) instead of a recursive `standards check`, and a non-private published bin-only CLI workspace that carries a stable release SemVer and exactly maps the `standards` bin to `src/cli.ts` without `exports`. Every other consumer rule still applies.
20
24
 
21
25
  ## Configuration
22
26
 
27
+ - **`.github/dependabot.local.yml`** (optional) — repo-owned Dependabot extension composed with the canonical `.github/dependabot.base.yml` into the generated `.github/dependabot.yml`. May define `updates` and top-level `registries`; a block matching a canonical normalized target may only add `ignore` and/or `registries` entries. Other matching-block policy is rejected deliberately. A missing or comments-only file means no additions. CLI 0.10.1 requires the selected content ref to contain the canonical base and rejects older refs before mutation.
23
28
  - **`sync-standards.local.json`** (optional) — consumer-owned sync policy at the repo root, validated by `doctor`/`check` and every `init`/`sync` even when an explicit ref or local source makes its `"ref"` irrelevant. All fields optional; a missing file means the defaults. `"ref"` is a non-empty single-line string that pins a tag, branch, or full commit sha to sync from instead of `main` (an explicit `--ref` overrides it; a local-path `--from` source is used as-is and ignores only the validated pin). `"autoSync": false` is read by the standards-sync workflow, not the CLI, and skips the weekly scheduled run. Version 0.7.0 removes the legacy `STANDARDS_AUTO_SYNC` and `STANDARDS_SYNC_REF` variable behavior; consumers must upgrade the package and lockfile before adopting a policy file.
24
29
  - **`.github/settings.local.json` `"rulesetEnforcement": "unavailable-on-plan"`** (optional) — declares that the repository's GitHub plan cannot enforce rulesets (private repositories on GitHub Free, both personal accounts and organizations). `github --check` and `--apply` then skip rulesets instead of comparing them, because a comparison cannot be trusted there: personal accounts answer ruleset reads with HTTP 403, and free-plan organizations report declared rulesets as active while silently not enforcing them. Plan-gated repository settings that only function alongside branch protection (`allow_auto_merge`) are skipped for the same reason — GitHub accepts a PATCH for them with HTTP 200 and silently keeps the old value. The remaining repository merge settings are still checked and converged, and both commands print an unprotected-branch notice on every run. The only accepted value is `"unavailable-on-plan"` (enforcement is the default; omit the key on paid plans), and combining the opt-out with additional local rulesets is rejected. After upgrading the plan, remove the declaration and run `bun standards github --apply` to restore enforcement.
25
30
  - **`GH_TOKEN` / `GITHUB_TOKEN`** (optional) — GitHub API token for the `github` command and the GitHub portion of `check`. When neither is set, the token from the local `gh` CLI is used; with no token at all, reads still work on public repositories. `--apply` needs an admin token. GitHub's REST API reveals repo merge settings only to write-capable viewers — `check` retries REST-hidden keys over GraphQL, which serves them to read-only tokens — and ruleset `bypass_actors` requires repository Administration read; a token that still cannot see declared state fails the check rather than passing unverified. In CI, where the workflow token can never hold that permission, the canonical workflow decrypts `ci.github_settings_read_token` from the SOPS-encrypted `secrets/ci.yaml` — a fine-grained PAT with read-only "Administration" access to the repository.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davidvornholt/standards",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
4
  "description": "Bootstrap, synchronize, and validate davidvornholt/standards consumers",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -20,6 +20,10 @@
20
20
  "LICENSE",
21
21
  "README.md",
22
22
  "src/cli.ts",
23
+ "src/dependabot-compose-input.ts",
24
+ "src/dependabot-compose.ts",
25
+ "src/dependabot-inspect.ts",
26
+ "src/dependabot-update.ts",
23
27
  "src/github-api.ts",
24
28
  "src/github-apply.ts",
25
29
  "src/github-command-shared.ts",
@@ -31,7 +35,9 @@
31
35
  "src/structure-check.ts",
32
36
  "src/structure-profile.ts",
33
37
  "src/structure-script.ts",
34
- "src/structure-workspace.ts"
38
+ "src/structure-workspace.ts",
39
+ "src/yaml-emit.ts",
40
+ "src/yaml-parse.ts"
35
41
  ],
36
42
  "scripts": {
37
43
  "lint": "biome check --error-on-warnings .",
@@ -49,5 +55,8 @@
49
55
  },
50
56
  "publishConfig": {
51
57
  "access": "public"
58
+ },
59
+ "dependencies": {
60
+ "yaml": "^2.9.0"
52
61
  }
53
62
  }
package/src/cli.ts CHANGED
@@ -4,10 +4,11 @@
4
4
  // davidvornholt/standards template into a consumer repo and detects local
5
5
  // tampering with them. See the standards repository README for the design.
6
6
  //
7
- // This script is intentionally zero-dependency (Bun + Node built-ins only) and
8
- // does NOT use Effect: `bunx` must be able to execute the published package
9
- // before a consumer has dependencies. That is the documented exception to the
10
- // repo's Effect standard for standalone bootstrap tooling.
7
+ // This bootstrap executable keeps its runtime surface minimal and does not use
8
+ // Effect: `bunx` must be able to execute the published package before a
9
+ // consumer has dependencies. Its strict YAML parser is a declared runtime
10
+ // dependency. This is the documented exception to the repo's Effect standard
11
+ // for standalone bootstrap tooling.
11
12
 
12
13
  import { execFileSync } from 'node:child_process';
13
14
  import { createHash } from 'node:crypto';
@@ -32,15 +33,21 @@ import {
32
33
  sep,
33
34
  } from 'node:path';
34
35
  import process from 'node:process';
36
+ import {
37
+ composeDependabot,
38
+ DEPENDABOT_BASE_FILE,
39
+ DEPENDABOT_FILE,
40
+ DEPENDABOT_LOCAL_FILE,
41
+ } from './dependabot-compose';
42
+ import { inspectDependabot } from './dependabot-inspect';
35
43
  import { CANONICAL_SETTINGS_FILE, LOCAL_SETTINGS_FILE } from './github-api';
36
44
  import { runGithubApply, runGithubCheck } from './github-commands';
37
45
  import { loadGithubSettings } from './github-settings';
46
+ import { isNonEmptyString, isRecord } from './github-settings-parse';
38
47
  import { collectStructureProblems } from './structure-check';
39
48
  import type { StructureProfile } from './structure-profile';
40
49
  import { hasSafeCommand } from './structure-script';
41
50
 
42
- const { YAML: BunYaml } = await import('bun');
43
-
44
51
  const DEFAULT_UPSTREAM = 'github:davidvornholt/standards';
45
52
 
46
53
  // Characters of a sha256 hex digest shown in drift reports; enough to identify.
@@ -79,6 +86,7 @@ type Source = {
79
86
 
80
87
  type Command =
81
88
  | 'check'
89
+ | 'dependabot'
82
90
  | 'doctor'
83
91
  | 'github'
84
92
  | 'help'
@@ -93,6 +101,7 @@ type CliOptions = {
93
101
  readonly from: string | undefined;
94
102
  readonly ref: string | undefined;
95
103
  readonly apply: boolean;
104
+ readonly write: boolean;
96
105
  readonly profile: StructureProfile;
97
106
  };
98
107
 
@@ -403,6 +412,12 @@ const runInit = async (
403
412
  ): Promise<void> => {
404
413
  const seeds = await seedTargets(src.dir, manifest.seedDir);
405
414
  assertDisjoint(manifest.paths, [...seeds.keys()]);
415
+ const prospectiveDependabot = await prepareProspectiveDependabot(
416
+ manifest,
417
+ src.dir,
418
+ consumer,
419
+ seeds.get(DEPENDABOT_LOCAL_FILE) ?? null,
420
+ );
406
421
  await Promise.all(
407
422
  [...seeds].map(async ([rel, abs]) => {
408
423
  const dest = join(consumer, rel);
@@ -423,6 +438,7 @@ const runInit = async (
423
438
  dryRun: false,
424
439
  });
425
440
  reportMirror(result, false);
441
+ await applyProspectiveDependabot(consumer, prospectiveDependabot, false);
426
442
  await writeLock(consumer, {
427
443
  upstream: manifest.upstream,
428
444
  sha: src.sha,
@@ -441,6 +457,12 @@ const runSync = async (
441
457
  ): Promise<void> => {
442
458
  const seeds = await seedTargets(src.dir, manifest.seedDir);
443
459
  assertDisjoint(manifest.paths, [...seeds.keys()]);
460
+ const prospectiveDependabot = await prepareProspectiveDependabot(
461
+ manifest,
462
+ src.dir,
463
+ consumer,
464
+ null,
465
+ );
444
466
  const lock = await readLock(consumer);
445
467
  const result = await mirror({
446
468
  manifest,
@@ -450,6 +472,7 @@ const runSync = async (
450
472
  dryRun,
451
473
  });
452
474
  reportMirror(result, dryRun);
475
+ await applyProspectiveDependabot(consumer, prospectiveDependabot, dryRun);
453
476
  if (dryRun) {
454
477
  return;
455
478
  }
@@ -510,177 +533,159 @@ const runCheck = async (consumer: string): Promise<boolean> => {
510
533
  const readTextIfPresent = async (path: string): Promise<string | null> =>
511
534
  existsSync(path) ? readFile(path, 'utf8') : null;
512
535
 
513
- const DEPENDABOT_BASELINE_ECOSYSTEMS = ['bun', 'github-actions'] as const;
514
- const DEPENDABOT_SCHEDULE_INTERVALS = new Set([
515
- 'daily',
516
- 'weekly',
517
- 'monthly',
518
- 'quarterly',
519
- 'semiannually',
520
- 'yearly',
521
- 'cron',
522
- ]);
523
-
524
- const isRecord = (value: unknown): value is Record<string, unknown> =>
525
- typeof value === 'object' && value !== null && !Array.isArray(value);
526
-
527
- const isNonEmptyString = (value: unknown): value is string =>
528
- typeof value === 'string' && value.length > 0;
529
-
530
- type DependabotUpdateInspection = {
531
- readonly problems: ReadonlyArray<string>;
532
- readonly rootEcosystem: string | null;
533
- };
534
-
535
- const inspectDependabotSchedule = (
536
- schedule: unknown,
537
- label: string,
538
- ): ReadonlyArray<string> => {
539
- if (!(isRecord(schedule) && isNonEmptyString(schedule.interval))) {
540
- return [`${label} must define schedule.interval`];
541
- }
542
- if (!DEPENDABOT_SCHEDULE_INTERVALS.has(schedule.interval)) {
543
- return [`${label} has an unsupported schedule.interval`];
544
- }
545
- if (schedule.interval === 'cron' && !isNonEmptyString(schedule.cronjob)) {
546
- return [`${label} must define schedule.cronjob for a cron interval`];
547
- }
548
- return [];
536
+ type DependabotSources = {
537
+ readonly base: string | null;
538
+ readonly local: string | null;
539
+ readonly current: string | null;
549
540
  };
550
541
 
551
- type DependabotGroupInspection = {
542
+ const readDependabotSources = async (
543
+ consumer: string,
544
+ ): Promise<DependabotSources> => ({
545
+ base: await readTextIfPresent(join(consumer, DEPENDABOT_BASE_FILE)),
546
+ local: await readTextIfPresent(join(consumer, DEPENDABOT_LOCAL_FILE)),
547
+ current: await readTextIfPresent(join(consumer, DEPENDABOT_FILE)),
548
+ });
549
+
550
+ // Compose the generated Dependabot config from its on-disk sources, folding
551
+ // Dependabot semantic validation into the same problem list.
552
+ const composedDependabot = (
553
+ sources: DependabotSources,
554
+ ): {
555
+ readonly composed: string | null;
552
556
  readonly problems: ReadonlyArray<string>;
553
- readonly scheduledGroups: ReadonlySet<string>;
554
- };
555
-
556
- const inspectDependabotGroups = (
557
- groups: unknown,
558
- ): DependabotGroupInspection => {
559
- if (groups === undefined) {
560
- return { problems: [], scheduledGroups: new Set() };
561
- }
562
- if (!isRecord(groups)) {
557
+ } => {
558
+ if (sources.base === null) {
563
559
  return {
560
+ composed: null,
564
561
  problems: [
565
- '.github/dependabot.yml multi-ecosystem-groups must be a mapping',
562
+ `${DEPENDABOT_BASE_FILE} must exist; run \`bun standards sync\` to mirror it in`,
566
563
  ],
567
- scheduledGroups: new Set(),
568
564
  };
569
565
  }
570
-
571
- const problems: Array<string> = [];
572
- const scheduledGroups = new Set<string>();
573
- for (const [name, group] of Object.entries(groups)) {
574
- const label = `.github/dependabot.yml multi-ecosystem-groups.${name}`;
575
- const groupProblems = isRecord(group)
576
- ? inspectDependabotSchedule(group.schedule, label)
577
- : [`${label} must be a mapping`];
578
- problems.push(...groupProblems);
579
- if (groupProblems.length === 0) {
580
- scheduledGroups.add(name);
581
- }
566
+ const result = composeDependabot(sources.base, sources.local);
567
+ if (result.composed === null) {
568
+ return result;
582
569
  }
583
- return { problems, scheduledGroups };
570
+ const problems = inspectDependabot(result.composed);
571
+ return problems.length > 0 ? { composed: null, problems } : result;
584
572
  };
585
573
 
586
- const inspectDependabotUpdate = (
587
- update: unknown,
588
- index: number,
589
- scheduledGroups: ReadonlySet<string>,
590
- ): DependabotUpdateInspection => {
591
- const label = `.github/dependabot.yml updates[${index}]`;
592
- if (!isRecord(update)) {
593
- return { problems: [`${label} must be a mapping`], rootEcosystem: null };
594
- }
595
-
596
- const {
597
- directory,
598
- directories,
599
- schedule,
600
- 'multi-ecosystem-group': multiEcosystemGroup,
601
- 'package-ecosystem': ecosystem,
602
- } = update;
603
- const problems: Array<string> = [];
604
- if (!isNonEmptyString(ecosystem)) {
605
- problems.push(`${label} must define package-ecosystem`);
574
+ const dependabotProblems = async (
575
+ consumer: string,
576
+ ): Promise<ReadonlyArray<string>> => {
577
+ const sources = await readDependabotSources(consumer);
578
+ const { composed, problems } = composedDependabot(sources);
579
+ if (composed === null) {
580
+ return problems;
606
581
  }
607
-
608
- const hasDirectory = isNonEmptyString(directory);
609
- const hasDirectories =
610
- Array.isArray(directories) &&
611
- directories.length > 0 &&
612
- directories.every(isNonEmptyString);
613
- if (hasDirectory === hasDirectories) {
614
- problems.push(
615
- `${label} must define exactly one of directory or directories`,
616
- );
582
+ if (sources.current !== composed) {
583
+ return [
584
+ `${DEPENDABOT_FILE} does not match its composed sources; regenerate it with \`bun standards dependabot --write\``,
585
+ ];
617
586
  }
587
+ return [];
588
+ };
618
589
 
619
- if (schedule === undefined && isNonEmptyString(multiEcosystemGroup)) {
620
- if (!scheduledGroups.has(multiEcosystemGroup)) {
621
- problems.push(
622
- `${label} must reference a scheduled multi-ecosystem group`,
623
- );
624
- }
625
- } else {
626
- problems.push(...inspectDependabotSchedule(schedule, label));
627
- }
590
+ const writeComposedDependabot = async (
591
+ consumer: string,
592
+ composed: string,
593
+ ): Promise<void> => {
594
+ const dest = join(consumer, DEPENDABOT_FILE);
595
+ await mkdir(dirname(dest), { recursive: true });
596
+ await writeFile(dest, composed);
597
+ };
628
598
 
629
- const targetsRoot =
630
- directory === '/' ||
631
- (Array.isArray(directories) && directories.includes('/'));
632
- return {
633
- problems,
634
- rootEcosystem:
635
- isNonEmptyString(ecosystem) && targetsRoot ? ecosystem : null,
636
- };
599
+ const composeProblemsError = (problems: ReadonlyArray<string>): Error =>
600
+ new Error(
601
+ [
602
+ `cannot compose ${DEPENDABOT_FILE}:`,
603
+ ...problems.map((problem) => ` - ${problem}`),
604
+ ].join('\n'),
605
+ );
606
+
607
+ type ProspectiveDependabot = {
608
+ readonly composed: string;
609
+ readonly current: string | null;
637
610
  };
638
611
 
639
- const inspectDependabot = (raw: string): ReadonlyArray<string> => {
640
- const problems: Array<string> = [];
641
- let config: unknown;
642
- try {
643
- config = BunYaml.parse(raw);
644
- } catch {
645
- return ['.github/dependabot.yml must contain valid YAML'];
612
+ // Validate the incoming canonical base against the effective local overlay
613
+ // before init/sync mutates any consumer-owned, canonical, generated, or lock
614
+ // file. For init, an existing overlay wins; otherwise the overlay seed is what
615
+ // the command is about to install.
616
+ const prepareProspectiveDependabot = async (
617
+ manifest: Manifest,
618
+ srcDir: string,
619
+ consumer: string,
620
+ localSeed: string | null,
621
+ ): Promise<ProspectiveDependabot> => {
622
+ const incoming = await listManaged(srcDir, manifest.paths);
623
+ const basePath = incoming.get(DEPENDABOT_BASE_FILE);
624
+ if (basePath === undefined) {
625
+ throw new Error(
626
+ `source content must manage ${DEPENDABOT_BASE_FILE}; @davidvornholt/standards 0.10.1 requires a 0.10.1-compatible content ref`,
627
+ );
628
+ }
629
+ const existingLocal = await readTextIfPresent(
630
+ join(consumer, DEPENDABOT_LOCAL_FILE),
631
+ );
632
+ const local =
633
+ existingLocal ??
634
+ (localSeed === null ? null : await readFile(localSeed, 'utf8'));
635
+ const current = await readTextIfPresent(join(consumer, DEPENDABOT_FILE));
636
+ const sources = {
637
+ base: await readFile(basePath, 'utf8'),
638
+ local,
639
+ current,
640
+ };
641
+ const { composed, problems } = composedDependabot(sources);
642
+ if (composed === null) {
643
+ throw composeProblemsError(problems);
646
644
  }
645
+ return { composed, current };
646
+ };
647
647
 
648
- if (!isRecord(config)) {
649
- return ['.github/dependabot.yml must contain a YAML mapping'];
648
+ const applyProspectiveDependabot = async (
649
+ consumer: string,
650
+ prospective: ProspectiveDependabot,
651
+ dryRun: boolean,
652
+ ): Promise<void> => {
653
+ if (prospective.current === prospective.composed) {
654
+ return;
650
655
  }
651
- if (config.version !== 2) {
652
- problems.push('.github/dependabot.yml must use version: 2');
656
+ if (dryRun) {
657
+ console.log(` would generate ${DEPENDABOT_FILE}`);
658
+ return;
653
659
  }
660
+ await writeComposedDependabot(consumer, prospective.composed);
661
+ console.log(` generated ${DEPENDABOT_FILE}`);
662
+ };
654
663
 
655
- const { updates, 'multi-ecosystem-groups': multiEcosystemGroups } = config;
656
- if (!Array.isArray(updates)) {
657
- problems.push('.github/dependabot.yml must define an updates list');
658
- return problems;
664
+ const runDependabotCheck = async (consumer: string): Promise<boolean> => {
665
+ const problems = await dependabotProblems(consumer);
666
+ if (problems.length > 0) {
667
+ console.error(`standards dependabot: ${problems.length} problem(s):`);
668
+ console.error(problems.map((problem) => ` - ${problem}`).join('\n'));
669
+ return false;
659
670
  }
671
+ console.log(
672
+ `standards dependabot: ${DEPENDABOT_FILE} matches its composed sources`,
673
+ );
674
+ return true;
675
+ };
660
676
 
661
- const groupInspection = inspectDependabotGroups(multiEcosystemGroups);
662
- problems.push(...groupInspection.problems);
663
- const rootEcosystems = new Set<string>();
664
- for (const [index, update] of updates.entries()) {
665
- const inspection = inspectDependabotUpdate(
666
- update,
667
- index,
668
- groupInspection.scheduledGroups,
669
- );
670
- problems.push(...inspection.problems);
671
- if (inspection.rootEcosystem !== null) {
672
- rootEcosystems.add(inspection.rootEcosystem);
673
- }
677
+ const runDependabotWrite = async (consumer: string): Promise<void> => {
678
+ const sources = await readDependabotSources(consumer);
679
+ const { composed, problems } = composedDependabot(sources);
680
+ if (composed === null) {
681
+ throw composeProblemsError(problems);
674
682
  }
675
-
676
- for (const ecosystem of DEPENDABOT_BASELINE_ECOSYSTEMS) {
677
- if (!rootEcosystems.has(ecosystem)) {
678
- problems.push(
679
- `.github/dependabot.yml must include a root-directory ${ecosystem} ecosystem`,
680
- );
681
- }
683
+ if (sources.current === composed) {
684
+ console.log(`standards dependabot: ${DEPENDABOT_FILE} is up to date`);
685
+ return;
682
686
  }
683
- return problems;
687
+ await writeComposedDependabot(consumer, composed);
688
+ console.log(`standards dependabot: generated ${DEPENDABOT_FILE}`);
684
689
  };
685
690
 
686
691
  const inspectPackageJson = (packageRaw: string): ReadonlyArray<string> => {
@@ -726,14 +731,7 @@ const runDoctor = async (consumer: string): Promise<boolean> => {
726
731
  problems.push('AGENTS.local.md must exist for project-specific guidance');
727
732
  }
728
733
 
729
- const dependabot = await readTextIfPresent(
730
- join(consumer, '.github/dependabot.yml'),
731
- );
732
- if (dependabot === null) {
733
- problems.push('.github/dependabot.yml must exist');
734
- } else {
735
- problems.push(...inspectDependabot(dependabot));
736
- }
734
+ problems.push(...(await dependabotProblems(consumer)));
737
735
 
738
736
  const packagePath = join(consumer, 'package.json');
739
737
  const packageRaw = await readTextIfPresent(packagePath);
@@ -773,13 +771,14 @@ const runDoctor = async (consumer: string): Promise<boolean> => {
773
771
  const USAGE = `Usage: standards <command> [options]
774
772
 
775
773
  Commands:
776
- init Bootstrap a consumer repo: seed repo-owned files, mirror canonical files, write the lock
777
- sync Mirror canonical files from upstream and rewrite the lock
778
- check Verify canonical files, extension seams, monorepo structure, and GitHub settings
779
- doctor Validate extension seams only
780
- structure Validate monorepo structure rules only
781
- github Compare (--check) or converge (--apply) live GitHub settings
782
- help Show this help
774
+ init Bootstrap a consumer repo: seed repo-owned files, mirror canonical files, write the lock
775
+ sync Mirror canonical files from upstream, regenerate the composed Dependabot config, and rewrite the lock
776
+ check Verify canonical files, extension seams, monorepo structure, and GitHub settings
777
+ doctor Validate extension seams only
778
+ structure Validate monorepo structure rules only
779
+ dependabot Verify (--check) or regenerate (--write) the composed .github/dependabot.yml
780
+ github Compare (--check) or converge (--apply) live GitHub settings
781
+ help Show this help
783
782
 
784
783
  Options:
785
784
  --dir <path> Consumer directory to operate on (default: current directory)
@@ -787,12 +786,14 @@ Options:
787
786
  --from <src> Upstream override for init/sync (GitHub repo or local path)
788
787
  --ref <ref> Upstream tag, branch, or full commit sha for init/sync (remote Git/GitHub sources only; default: main)
789
788
  --dry-run Preview a sync without writing anything
790
- --check With github: compare live settings to the declaration (default)
791
- --apply With github: converge the live repository (needs admin auth)`;
789
+ --check With github/dependabot: compare against the declared sources (default)
790
+ --apply With github: converge the live repository (needs admin auth)
791
+ --write With dependabot: regenerate the composed .github/dependabot.yml`;
792
792
 
793
793
  const commandFromArg = (arg: string): Command => {
794
794
  if (
795
795
  arg === 'check' ||
796
+ arg === 'dependabot' ||
796
797
  arg === 'doctor' ||
797
798
  arg === 'github' ||
798
799
  arg === 'help' ||
@@ -832,6 +833,44 @@ const nextOptionValue = (
832
833
  return value;
833
834
  };
834
835
 
836
+ type ParsedFlags = {
837
+ readonly command: Command | undefined;
838
+ readonly checkFlag: boolean;
839
+ readonly apply: boolean;
840
+ readonly write: boolean;
841
+ readonly ref: string | undefined;
842
+ readonly profile: StructureProfile | undefined;
843
+ };
844
+
845
+ // Every option is only meaningful with specific commands; reject the rest so a
846
+ // typo fails loudly instead of silently doing the default thing.
847
+ const assertOptionUsage = (flags: ParsedFlags): void => {
848
+ const { command, checkFlag, apply, write, ref, profile } = flags;
849
+ if (checkFlag && command !== 'github' && command !== 'dependabot') {
850
+ throw new Error(
851
+ '--check is only valid with the github and dependabot commands',
852
+ );
853
+ }
854
+ if (apply && command !== 'github') {
855
+ throw new Error('--apply is only valid with the github command');
856
+ }
857
+ if (apply && checkFlag) {
858
+ throw new Error('github accepts exactly one of --check or --apply');
859
+ }
860
+ if (write && command !== 'dependabot') {
861
+ throw new Error('--write is only valid with the dependabot command');
862
+ }
863
+ if (write && checkFlag) {
864
+ throw new Error('dependabot accepts exactly one of --check or --write');
865
+ }
866
+ if (ref !== undefined && command !== 'init' && command !== 'sync') {
867
+ throw new Error('--ref is only valid with the init and sync commands');
868
+ }
869
+ if (profile !== undefined && command !== 'structure') {
870
+ throw new Error('--profile is only valid with the structure command');
871
+ }
872
+ };
873
+
835
874
  const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
836
875
  let command: Command | undefined;
837
876
  let consumer = process.cwd();
@@ -840,6 +879,7 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
840
879
  let ref: string | undefined;
841
880
  let checkFlag = false;
842
881
  let apply = false;
882
+ let write = false;
843
883
  let profile: StructureProfile | undefined;
844
884
 
845
885
  for (let index = 0; index < argv.length; index += 1) {
@@ -851,6 +891,9 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
851
891
  case '--check':
852
892
  checkFlag = true;
853
893
  break;
894
+ case '--write':
895
+ write = true;
896
+ break;
854
897
  case '--dir':
855
898
  consumer = nextOptionValue(argv, index);
856
899
  index += 1;
@@ -879,21 +922,7 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
879
922
  }
880
923
  }
881
924
 
882
- if (checkFlag && command !== 'github') {
883
- throw new Error('--check is only valid with the github command');
884
- }
885
- if (apply && command !== 'github') {
886
- throw new Error('--apply is only valid with the github command');
887
- }
888
- if (apply && checkFlag) {
889
- throw new Error('github accepts exactly one of --check or --apply');
890
- }
891
- if (ref !== undefined && command !== 'init' && command !== 'sync') {
892
- throw new Error('--ref is only valid with the init and sync commands');
893
- }
894
- if (profile !== undefined && command !== 'structure') {
895
- throw new Error('--profile is only valid with the structure command');
896
- }
925
+ assertOptionUsage({ command, checkFlag, apply, write, ref, profile });
897
926
 
898
927
  return {
899
928
  command,
@@ -902,6 +931,7 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
902
931
  from,
903
932
  ref,
904
933
  apply,
934
+ write,
905
935
  profile: profile ?? 'consumer',
906
936
  };
907
937
  };
@@ -1069,7 +1099,7 @@ const runSyncCommand = async (
1069
1099
 
1070
1100
  // Commands whose success is reported through the exit code.
1071
1101
  const runGateCommand = (
1072
- command: 'check' | 'doctor' | 'github' | 'structure',
1102
+ command: 'check' | 'dependabot' | 'doctor' | 'github' | 'structure',
1073
1103
  consumer: string,
1074
1104
  apply: boolean,
1075
1105
  profile: StructureProfile,
@@ -1077,6 +1107,9 @@ const runGateCommand = (
1077
1107
  if (command === 'check') {
1078
1108
  return runCheckCommand(consumer);
1079
1109
  }
1110
+ if (command === 'dependabot') {
1111
+ return runDependabotCheck(consumer);
1112
+ }
1080
1113
  if (command === 'doctor') {
1081
1114
  return runDoctor(consumer);
1082
1115
  }
@@ -1087,9 +1120,8 @@ const runGateCommand = (
1087
1120
  };
1088
1121
 
1089
1122
  const main = async (): Promise<void> => {
1090
- const { command, consumer, dryRun, from, ref, apply, profile } = parseArgs(
1091
- process.argv.slice(2),
1092
- );
1123
+ const { command, consumer, dryRun, from, ref, apply, write, profile } =
1124
+ parseArgs(process.argv.slice(2));
1093
1125
 
1094
1126
  if (command === undefined) {
1095
1127
  console.error('standards: a command is required\n');
@@ -1113,6 +1145,11 @@ const main = async (): Promise<void> => {
1113
1145
  return;
1114
1146
  }
1115
1147
 
1148
+ if (command === 'dependabot' && write) {
1149
+ await runDependabotWrite(consumer);
1150
+ return;
1151
+ }
1152
+
1116
1153
  if (!(await runGateCommand(command, consumer, apply, profile))) {
1117
1154
  process.exitCode = 1;
1118
1155
  }