@davidvornholt/standards 0.27.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
 
@@ -182,6 +190,10 @@ bun standards github --apply
182
190
 
183
191
  The command merges `.github/settings.json` with `.github/settings.local.json`, then compares or converges repository settings, declared rulesets, and canonical labels. Undeclared live labels are left alone. Undeclared repository-owned rulesets are removed by `--apply`.
184
192
 
193
+ Starting with 0.28.0, every declared ruleset must explicitly state `target`, `enforcement`, and `bypass_actors`; branch and tag rulesets must also state `conditions.ref_name.include` and `conditions.ref_name.exclude`. Use `"bypass_actors": []` when nobody may bypass. Incomplete declarations fail before GitHub is contacted, with an error naming each missing field. Add the intended values to existing local rulesets before upgrading; no defaults or warning period apply.
194
+
195
+ Push rulesets apply to every push and must omit `conditions`, as described in [GitHub's push-ruleset documentation](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets#push-rulesets).
196
+
185
197
  `--apply` needs admin authentication and rechecks the API response instead of treating an accepted request as success. A repository whose plan cannot enforce rulesets may declare `"rulesetEnforcement": "unavailable-on-plan"` in the local settings file.
186
198
 
187
199
  ## creds
@@ -236,7 +248,7 @@ Bootstrap credentials and singular tokens still owned by an active repository ar
236
248
 
237
249
  ### Prepare existing consumers for GitHub App client IDs
238
250
 
239
- `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`.
240
252
 
241
253
  ## screenshots
242
254
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davidvornholt/standards",
3
- "version": "0.27.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(
@@ -2,7 +2,7 @@
2
2
  // answers for tokens that cannot read the actors themselves. Pure logic; the
3
3
  // GraphQL call lives in github-graphql.ts.
4
4
 
5
- import { BYPASS_ACTORS_KEY } from './github-ruleset-diff';
5
+ import { BYPASS_ACTORS_KEY } from './github-settings-parse';
6
6
 
7
7
  // Stand-ins for bypass actors GraphQL counted but would not name. They exist
8
8
  // only to carry the count into the ordinary length comparison, and are attached
@@ -3,9 +3,11 @@
3
3
  // general value comparison lives in github-diff.ts.
4
4
 
5
5
  import { subsetMatches } from './github-diff';
6
- import { isRecord } from './github-settings-parse';
7
-
8
- export const BYPASS_ACTORS_KEY = 'bypass_actors';
6
+ import {
7
+ BYPASS_ACTORS_KEY,
8
+ isRecord,
9
+ rulesetComparedKeys,
10
+ } from './github-settings-parse';
9
11
 
10
12
  // A declared ruleset field the live side did not carry. It stays structured so
11
13
  // the caller owns both the wording and the test for *which* field it is;
@@ -21,13 +23,6 @@ export type RulesetDiff = {
21
23
  readonly unverifiable: ReadonlyArray<UnverifiableRulesetField>;
22
24
  };
23
25
 
24
- const RULESET_COMPARED_KEYS = [
25
- 'target',
26
- 'enforcement',
27
- 'conditions',
28
- BYPASS_ACTORS_KEY,
29
- ] as const;
30
-
31
26
  const diffRules = (
32
27
  name: string,
33
28
  declared: Readonly<Record<string, unknown>>,
@@ -99,15 +94,13 @@ export const diffRuleset = (
99
94
  const name = String(declared.name);
100
95
  const drifted: Array<string> = [];
101
96
  const unverifiable: Array<UnverifiableRulesetField> = [];
102
- for (const key of RULESET_COMPARED_KEYS) {
103
- if (declared[key] !== undefined) {
104
- if (live[key] === undefined) {
105
- unverifiable.push({ key, name });
106
- } else if (!subsetMatches(declared[key], live[key])) {
107
- drifted.push(
108
- `ruleset "${name}": ${key} differs from the declared configuration${bypassActorCountDetail(key, declared[key], live[key])}`,
109
- );
110
- }
97
+ for (const key of rulesetComparedKeys(declared.target)) {
98
+ if (live[key] === undefined) {
99
+ unverifiable.push({ key, name });
100
+ } else if (!subsetMatches(declared[key], live[key])) {
101
+ drifted.push(
102
+ `ruleset "${name}": ${key} differs from the declared configuration${bypassActorCountDetail(key, declared[key], live[key])}`,
103
+ );
111
104
  }
112
105
  }
113
106
  drifted.push(...diffRules(name, declared, live));
@@ -8,10 +8,8 @@ import {
8
8
  ADMIN_VISIBILITY_ADVICE,
9
9
  unverifiableProblem,
10
10
  } from './github-command-shared';
11
- import {
12
- BYPASS_ACTORS_KEY,
13
- type UnverifiableRulesetField,
14
- } from './github-ruleset-diff';
11
+ import type { UnverifiableRulesetField } from './github-ruleset-diff';
12
+ import { BYPASS_ACTORS_KEY } from './github-settings-parse';
15
13
 
16
14
  const SCOPE = 'ruleset field(s)';
17
15
 
@@ -45,6 +45,89 @@ export const isNamedRuleset = (
45
45
  ): value is Readonly<Record<string, unknown>> & { readonly name: string } =>
46
46
  isRecord(value) && typeof value.name === 'string' && value.name.length > 0;
47
47
 
48
+ export const BYPASS_ACTORS_KEY = 'bypass_actors';
49
+ const RULESET_COMPARED_KEYS = [
50
+ 'target',
51
+ 'enforcement',
52
+ 'conditions',
53
+ BYPASS_ACTORS_KEY,
54
+ ] as const;
55
+
56
+ // Push rules cover every push to the repository and have no ref-name scope.
57
+ // The parser and comparison share the same field contract.
58
+ export const rulesetComparedKeys = (target: unknown) =>
59
+ target === 'push'
60
+ ? RULESET_COMPARED_KEYS.filter((key) => key !== 'conditions')
61
+ : RULESET_COMPARED_KEYS;
62
+
63
+ const isStringList = (value: unknown): value is ReadonlyArray<string> =>
64
+ Array.isArray(value) && value.every((item) => typeof item === 'string');
65
+
66
+ const isOneOf = (value: unknown, choices: ReadonlyArray<string>): boolean =>
67
+ typeof value === 'string' && choices.includes(value);
68
+
69
+ const rulesetConditionsProblem = (
70
+ ruleset: Readonly<Record<string, unknown>>,
71
+ prefix: string,
72
+ ): string | null => {
73
+ if (ruleset.conditions === undefined) {
74
+ return null;
75
+ }
76
+ if (ruleset.target === 'push') {
77
+ return `${prefix} must omit "conditions" for target "push", which applies to every push`;
78
+ }
79
+ const refName = isRecord(ruleset.conditions)
80
+ ? ruleset.conditions.ref_name
81
+ : undefined;
82
+ return isRecord(refName) &&
83
+ isStringList(refName.include) &&
84
+ isStringList(refName.exclude)
85
+ ? null
86
+ : `${prefix} "conditions" must declare "ref_name.include" and "ref_name.exclude" as arrays of strings`;
87
+ };
88
+
89
+ const rulesetFieldProblems = (
90
+ ruleset: Readonly<Record<string, unknown>>,
91
+ label: string,
92
+ ): ReadonlyArray<string> => {
93
+ const problems: Array<string> = [];
94
+ const prefix = `${label} ruleset "${String(ruleset.name)}"`;
95
+ for (const key of rulesetComparedKeys(ruleset.target)) {
96
+ if (ruleset[key] === undefined) {
97
+ const advice =
98
+ key === BYPASS_ACTORS_KEY ? '; use [] if nobody may bypass' : '';
99
+ problems.push(`${prefix} must declare "${key}"${advice}`);
100
+ }
101
+ }
102
+ if (
103
+ ruleset.target !== undefined &&
104
+ !isOneOf(ruleset.target, ['branch', 'tag', 'push'])
105
+ ) {
106
+ problems.push(`${prefix} "target" must be "branch", "tag", or "push"`);
107
+ }
108
+ if (
109
+ ruleset.enforcement !== undefined &&
110
+ !isOneOf(ruleset.enforcement, ['active', 'disabled', 'evaluate'])
111
+ ) {
112
+ problems.push(
113
+ `${prefix} "enforcement" must be "active", "disabled", or "evaluate"`,
114
+ );
115
+ }
116
+ if (
117
+ ruleset[BYPASS_ACTORS_KEY] !== undefined &&
118
+ !Array.isArray(ruleset[BYPASS_ACTORS_KEY])
119
+ ) {
120
+ problems.push(
121
+ `${prefix} "${BYPASS_ACTORS_KEY}" must be an array; use [] if nobody may bypass`,
122
+ );
123
+ }
124
+ const conditionsProblem = rulesetConditionsProblem(ruleset, prefix);
125
+ if (conditionsProblem !== null) {
126
+ problems.push(conditionsProblem);
127
+ }
128
+ return problems;
129
+ };
130
+
48
131
  const LABEL_COLOR = /^[0-9a-f]{6}$/u;
49
132
  const LABEL_DECLARATION_KEY_COUNT = 3;
50
133
 
@@ -98,6 +181,9 @@ const rulesetListProblems = (
98
181
  } else {
99
182
  names.add(ruleset.name);
100
183
  }
184
+ if (isNamedRuleset(ruleset)) {
185
+ problems.push(...rulesetFieldProblems(ruleset, label));
186
+ }
101
187
  }
102
188
  return problems;
103
189
  };
@@ -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];