@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 +14 -2
- package/package.json +1 -3
- package/src/cli.ts +28 -4
- package/src/github-bypass-actors.ts +1 -1
- package/src/github-ruleset-diff.ts +12 -19
- package/src/github-ruleset-visibility.ts +2 -4
- package/src/github-settings-parse.ts +86 -0
- package/src/poller-config.ts +0 -2
- package/src/poller-github-pulls.ts +0 -1
- package/src/poller-outcome.ts +6 -2
- package/src/poller-review-plan-parse.ts +4 -2
- package/src/poller-review-thread-write.ts +0 -1
- package/src/structure-check.ts +1 -1
- package/src/structure-profile.ts +11 -0
- package/src/structure-workspace.ts +15 -4
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.
|
|
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.
|
|
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
|
|
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
|
-
:
|
|
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-
|
|
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 {
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
103
|
-
if (
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
};
|
package/src/poller-config.ts
CHANGED
|
@@ -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
|
});
|
package/src/poller-outcome.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
package/src/structure-check.ts
CHANGED
|
@@ -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 =
|
package/src/structure-profile.ts
CHANGED
|
@@ -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
|
-
|
|
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];
|