@davidvornholt/standards 0.28.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -97,7 +97,15 @@ bun standards structure --profile source
97
97
 
98
98
  `doctor` checks project-owned integration points such as `biome.jsonc`, `AGENTS.local.md`, package scripts, sync policy, settings overlays, and Dependabot inputs.
99
99
 
100
- `structure` checks the exact Bun declaration, workspace scripts, internal dependency versions, package exports, TypeScript inheritance, accessibility wiring, and CI secret structure. The `source` profile records the few intentional differences in this repository itself.
100
+ `structure` checks the exact Bun declaration, root and workspace scripts, internal dependency versions, package exports, TypeScript inheritance, accessibility wiring, and CI secret structure. The `source` profile records the few intentional differences in this repository itself.
101
+
102
+ Starting with 0.29.0, Biome runs once from the repository root instead of once per workspace. The root `package.json` must declare `lint` running `biome check --error-on-warnings .` and `lint:fix` running `biome check --write --error-on-warnings .`, and any workspace that declares `lint` or `lint:fix` fails. To upgrade an existing repository:
103
+
104
+ 1. Install the new CLI with `bun add --dev --exact @davidvornholt/standards@0.29.0`.
105
+ 2. Run `bun standards sync` so the canonical `packages/typescript-config` and `packages/a11y-testing` drop their lint scripts. A repository that pins `ref` in `sync-standards.local.json` first moves it to `v0.29.0` or later.
106
+ 3. Add the root `lint` and `lint:fix` scripts and remove both scripts from every workspace `package.json`.
107
+ 4. In `turbo.json`, replace the `lint` and `lint:fix` tasks with `//#lint` (`"outputs": []`) and `//#lint:fix` (`"cache": false`, `"outputs": []`), and delete any `<package>#lint` overrides. Keep the default inputs for `//#lint`.
108
+ 5. Run `bun run lint:fix` and fix anything it still reports. The root run also checks root files that per-workspace runs never covered.
101
109
 
102
110
  ## dependabot
103
111
 
@@ -240,7 +248,7 @@ Bootstrap credentials and singular tokens still owned by an active repository ar
240
248
 
241
249
  ### Prepare existing consumers for GitHub App client IDs
242
250
 
243
- `standards creds add github --dest ci:ci.broker_app` writes and verifies `app_id`, `client_id`, and `private_key` together. Run it in each existing consumer, mirror the new `client_id` key with a placeholder in its `secrets/ci.example.yaml`, and commit both files before adopting a workflow that resolves `broker_app.client_id` and passes `client-id` to `actions/create-github-app-token@v3`. The numeric `app_id` remains available while existing workflows are migrated. Do not remove it until every local and canonical caller has switched. Canonical workflows continue using `app-id` until consumer provisioning is complete.
251
+ `standards creds add github --dest ci:ci.broker_app` writes and verifies `app_id`, `client_id`, and `private_key` together. The canonical Standards sync workflow passes `client-id` to `actions/create-github-app-token@v3` when `ci.broker_app.client_id` exists and falls back to the deprecated `app-id` when it does not. Provision `client_id` in each existing consumer before a future release drops that fallback: re-run `bun standards creds add github --dest ci:ci.broker_app`, or add the App's public client ID at `ci.broker_app.client_id` with `sops`. Mirror the new key with a placeholder in `secrets/ci.example.yaml` and commit both files. Keep the numeric `app_id` until every local and canonical caller has switched to `client-id`.
244
252
 
245
253
  ## screenshots
246
254
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davidvornholt/standards",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "Bootstrap, synchronize, and validate davidvornholt/standards consumers",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -180,8 +180,6 @@
180
180
  "src/sops-action-callers.ts"
181
181
  ],
182
182
  "scripts": {
183
- "lint": "biome check --error-on-warnings .",
184
- "lint:fix": "biome check --write --error-on-warnings .",
185
183
  "check-types": "tsc --noEmit",
186
184
  "test": "bun test"
187
185
  },
package/src/cli.ts CHANGED
@@ -389,11 +389,31 @@ type MirrorResult = {
389
389
  readonly stale: ReadonlyArray<string>;
390
390
  };
391
391
 
392
+ type MirrorCommand = 'init' | 'sync';
393
+
392
394
  type MirrorOptions = {
393
395
  readonly upstream: ReadonlyMap<string, ManagedEntry>;
394
396
  readonly consumer: string;
395
397
  readonly previous: Record<string, string>;
396
398
  readonly dryRun: boolean;
399
+ readonly command: MirrorCommand;
400
+ };
401
+
402
+ // A copy or zip tool that follows links turns a managed link into a directory of
403
+ // copies. Those copies are not in the lock, so the run refuses; for a link the
404
+ // message names what the path should be and the exact way back to it.
405
+ const directoryCollision = (
406
+ rel: string,
407
+ entry: ManagedEntry,
408
+ unlocked: ReadonlyArray<string>,
409
+ command: MirrorCommand,
410
+ ): string => {
411
+ const holding = `${unlocked.length} path(s) this repository does not manage (${unlocked.slice(0, COLLISION_SAMPLE).join(', ')})`;
412
+ if (entry.kind !== 'symlink') {
413
+ return `${rel} is a directory holding ${holding}; move or delete them, then re-run`;
414
+ }
415
+ const canonical = posix.join(posix.dirname(rel), entry.target);
416
+ return `${rel} should be a symlink to the canonical ${canonical}, but it is a directory holding ${holding}; copy and zip tools that follow links can cause this. First move any of your own files out of ${rel}, then run \`rm -rf ${rel} && bun standards ${command}\``;
397
417
  };
398
418
 
399
419
  // Two things init/sync must settle before their first write: an escaping
@@ -408,10 +428,11 @@ const mirrorPreconditionProblems = async (
408
428
  consumer: string,
409
429
  upstream: ReadonlyMap<string, ManagedEntry>,
410
430
  previous: Record<string, string>,
431
+ command: MirrorCommand,
411
432
  ): Promise<ReadonlyArray<string>> => {
412
433
  const locked = new Set(Object.keys(previous));
413
434
  const collisions = await Promise.all(
414
- [...upstream.keys()].map(async (rel) => {
435
+ [...upstream].map(async ([rel, entry]) => {
415
436
  const ancestor = await interposedAncestor(consumer, rel, upstream);
416
437
  if (ancestor !== null) {
417
438
  return `${rel} is below the ${ancestor.kind} ${ancestor.rel}; refusing to write through an existing file or symlink ancestor; move it aside before syncing this payload`;
@@ -423,7 +444,7 @@ const mirrorPreconditionProblems = async (
423
444
  const unlocked = await unlockedPathsUnder(consumer, rel, locked);
424
445
  return unlocked.length === 0
425
446
  ? null
426
- : `${rel} is a directory holding ${unlocked.length} path(s) this repository does not manage (${unlocked.slice(0, COLLISION_SAMPLE).join(', ')}); move or delete them, then re-run`;
447
+ : directoryCollision(rel, entry, unlocked, command);
427
448
  }),
428
449
  );
429
450
  return [
@@ -545,12 +566,13 @@ const mirror = async ({
545
566
  consumer,
546
567
  previous,
547
568
  dryRun,
569
+ command,
548
570
  }: MirrorOptions): Promise<MirrorResult> => {
549
571
  for (const rel of Object.keys(previous)) {
550
572
  assertSafeRelativePath(rel, 'sync-standards.lock file');
551
573
  }
552
574
  assertNoProblems(
553
- await mirrorPreconditionProblems(consumer, upstream, previous),
575
+ await mirrorPreconditionProblems(consumer, upstream, previous, command),
554
576
  'cannot mirror canonical files',
555
577
  );
556
578
  const next: Record<string, string> = {};
@@ -682,7 +704,7 @@ const runInit = async (
682
704
  assertNoProblems(
683
705
  [
684
706
  ...symlinkTargetProblems(seeds),
685
- ...(await mirrorPreconditionProblems(consumer, upstream, {})),
707
+ ...(await mirrorPreconditionProblems(consumer, upstream, {}, 'init')),
686
708
  ],
687
709
  'cannot initialize from this source',
688
710
  );
@@ -712,6 +734,7 @@ const runInit = async (
712
734
  consumer,
713
735
  previous: {},
714
736
  dryRun: false,
737
+ command: 'init',
715
738
  });
716
739
  reportMirror(result, false);
717
740
  await applyProspectiveDependabot(consumer, prospectiveDependabot, false);
@@ -745,6 +768,7 @@ const runSync = async (
745
768
  consumer,
746
769
  previous: lock?.files ?? {},
747
770
  dryRun,
771
+ command: 'sync',
748
772
  });
749
773
  reportMirror(result, dryRun);
750
774
  const generated = await applyProspectiveDependabot(
@@ -14,7 +14,6 @@ const CONFIG_KEYS: ReadonlySet<string> = new Set([
14
14
  'repos',
15
15
  'model',
16
16
  'reasoningEffort',
17
- // biome-ignore lint/security/noSecrets: a config key name, not a credential.
18
17
  'maxJobsPerTick',
19
18
  'staleClaimHours',
20
19
  'runTimeoutMinutes',
@@ -167,7 +166,6 @@ export const parsePollerConfig = (
167
166
  ),
168
167
  maxJobsPerTick: parsePositiveInteger(
169
168
  raw.maxJobsPerTick,
170
- // biome-ignore lint/security/noSecrets: a config key name, not a credential.
171
169
  'maxJobsPerTick',
172
170
  DEFAULT_MAX_JOBS_PER_TICK,
173
171
  problems,
@@ -175,7 +175,6 @@ export const markPullRequestReady = async (
175
175
  ): Promise<void> => {
176
176
  const response = await request(token, 'POST', '/graphql', {
177
177
  query:
178
- // biome-ignore lint/security/noSecrets: a GraphQL mutation string, not a credential.
179
178
  'mutation($id: ID!) { markPullRequestReadyForReview(input: {pullRequestId: $id}) { pullRequest { isDraft } } }',
180
179
  variables: { id: pullRequestNodeId },
181
180
  });
@@ -19,8 +19,12 @@ const hasExactKeys = (
19
19
  value: Readonly<Record<string, unknown>>,
20
20
  expected: ReadonlyArray<string>,
21
21
  ): boolean => {
22
- const actual = Object.keys(value).sort();
23
- const sortedExpected = [...expected].sort();
22
+ const actual = Object.keys(value).sort((left, right) =>
23
+ left.localeCompare(right),
24
+ );
25
+ const sortedExpected = [...expected].sort((left, right) =>
26
+ left.localeCompare(right),
27
+ );
24
28
  return (
25
29
  actual.length === sortedExpected.length &&
26
30
  actual.every((key, index) => key === sortedExpected[index])
@@ -22,8 +22,10 @@ const hasExactKeys = (
22
22
  value: Readonly<Record<string, unknown>>,
23
23
  keys: ReadonlyArray<string>,
24
24
  ): boolean => {
25
- const actual = Object.keys(value).sort();
26
- const expected = [...keys].sort();
25
+ const actual = Object.keys(value).sort((left, right) =>
26
+ left.localeCompare(right),
27
+ );
28
+ const expected = [...keys].sort((left, right) => left.localeCompare(right));
27
29
  return (
28
30
  actual.length === expected.length &&
29
31
  actual.every((key, index) => key === expected[index])
@@ -2,7 +2,6 @@ import { apiError, HTTP_OK, request } from './github-api';
2
2
  import { isRecord } from './github-settings-parse';
3
3
 
4
4
  const REPLY_MUTATION =
5
- // biome-ignore lint/security/noSecrets: a GraphQL mutation string, not a credential.
6
5
  'mutation($id: ID!, $body: String!) { addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: $id, body: $body }) { comment { id } } }';
7
6
 
8
7
  const RESOLVE_MUTATION =
@@ -16,7 +16,7 @@ import { collectCiSecretsProblems } from './structure-secrets';
16
16
  import { inspectWorkspace, type Workspace } from './structure-workspace';
17
17
 
18
18
  const ROOT_FIXED_SCRIPTS = new Set(
19
- 'standards,check,check:fix,test:a11y'.split(','),
19
+ 'standards,check,check:fix,lint,lint:fix,test:a11y'.split(','),
20
20
  );
21
21
  const GLOB_SUFFIX = '/*';
22
22
  const WORKSPACES_REQUIREMENT =
@@ -13,6 +13,8 @@ const CONSUMER_CHECK =
13
13
  const CONSUMER_CHECK_FIX =
14
14
  'turbo run lint:fix check-types test build test:a11y --output-logs=errors-only';
15
15
  const ROOT_A11Y = 'turbo run test:a11y';
16
+ const ROOT_LINT = 'biome check --error-on-warnings .';
17
+ const ROOT_LINT_FIX = 'biome check --write --error-on-warnings .';
16
18
 
17
19
  export const PUBLISHED_CLI_WORKSPACE = 'packages/standards-cli';
18
20
  const PUBLISHED_CLI_PACKAGE = '@davidvornholt/standards';
@@ -51,6 +53,14 @@ const SOURCE_ROOT_EXPECTATIONS: ReadonlyArray<RootScriptExpectation> = [
51
53
  },
52
54
  ];
53
55
 
56
+ // Biome runs once over the whole repository from the root. With the project
57
+ // domain on, every invocation loads the entire repository, so one root run
58
+ // replaces a full project load per workspace and also covers root files.
59
+ const ROOT_LINT_EXPECTATIONS: ReadonlyArray<RootScriptExpectation> = [
60
+ { name: 'lint', commands: [ROOT_LINT], exact: true },
61
+ { name: 'lint:fix', commands: [ROOT_LINT_FIX], exact: true },
62
+ ];
63
+
54
64
  export const rootScriptExpectations = (
55
65
  profile: StructureProfile,
56
66
  requireA11y: boolean,
@@ -61,6 +71,7 @@ export const rootScriptExpectations = (
61
71
  { name: 'check', commands: [CONSUMER_CHECK], exact: false },
62
72
  { name: 'check:fix', commands: [CONSUMER_CHECK_FIX], exact: false },
63
73
  ]),
74
+ ...ROOT_LINT_EXPECTATIONS,
64
75
  ...(requireA11y
65
76
  ? [{ name: 'test:a11y', commands: [ROOT_A11Y], exact: false }]
66
77
  : []),
@@ -13,10 +13,12 @@ export type Workspace = {
13
13
  };
14
14
  const WORKSPACE_SCRIPTS: ReadonlyArray<readonly [string, string]> = [
15
15
  ['check-types', 'tsc --noEmit'],
16
- ['lint', 'biome check --error-on-warnings .'],
17
- ['lint:fix', 'biome check --write --error-on-warnings .'],
18
16
  ['test', 'bun test'],
19
17
  ];
18
+ // Biome lints the whole repository once from the root "lint" scripts, and
19
+ // Turbo runs only that root task. A workspace copy would be a stale second
20
+ // entry point, so declaring one fails instead of being silently ignored.
21
+ const ROOT_ONLY_SCRIPTS = ['lint', 'lint:fix'] as const;
20
22
  const A11Y_DEPENDENCIES = ['@axe-core/playwright', '@playwright/test'] as const;
21
23
  const DEPENDENCY_FIELDS =
22
24
  'dependencies devDependencies peerDependencies optionalDependencies'.split(
@@ -57,8 +59,9 @@ const containsA11ySuite = async (dir: string): Promise<boolean> => {
57
59
  );
58
60
  return nested.includes(true);
59
61
  };
60
- const inspectScripts = (ws: Workspace): ReadonlyArray<string> =>
61
- WORKSPACE_SCRIPTS.flatMap(([name, command]) => {
62
+ const inspectScripts = (ws: Workspace): ReadonlyArray<string> => {
63
+ const scripts = isRecord(ws.manifest.scripts) ? ws.manifest.scripts : {};
64
+ const required = WORKSPACE_SCRIPTS.flatMap(([name, command]) => {
62
65
  const script = scriptOf(ws.manifest, name);
63
66
  const accepted =
64
67
  hasSafeCommand(script, command) ||
@@ -67,6 +70,14 @@ const inspectScripts = (ws: Workspace): ReadonlyArray<string> =>
67
70
  name === 'test' ? 'bun test or bun test --isolate' : command;
68
71
  return accepted ? [] : [`${ws.rel}: script "${name}" must run ${expected}`];
69
72
  });
73
+ const rootOnly = ROOT_ONLY_SCRIPTS.filter((name) =>
74
+ Object.hasOwn(scripts, name),
75
+ ).map(
76
+ (name) =>
77
+ `${ws.rel}: remove script "${name}"; linting runs once at the repository root through the root "lint" and "lint:fix" scripts`,
78
+ );
79
+ return [...required, ...rootOnly];
80
+ };
70
81
  const declaredDependencies = (manifest: Record<string, unknown>) =>
71
82
  DEPENDENCY_FIELDS.flatMap((field) => {
72
83
  const deps = manifest[field];