@davidvornholt/standards 0.17.3 → 0.19.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.
@@ -0,0 +1,100 @@
1
+ // Turns unverifiable ruleset fields into gate failures. This module owns both
2
+ // the wording of an invisible field and the test for which field it is, so the
3
+ // two cannot drift apart: an earlier version formatted the message in one
4
+ // module and recovered the signal by parsing the suffix in another, where a
5
+ // reformat would have silently stopped the bypass-actor fallback from firing.
6
+
7
+ import {
8
+ ADMIN_VISIBILITY_ADVICE,
9
+ unverifiableProblem,
10
+ } from './github-command-shared';
11
+ import {
12
+ BYPASS_ACTORS_KEY,
13
+ type UnverifiableRulesetField,
14
+ } from './github-ruleset-diff';
15
+
16
+ const SCOPE = 'ruleset field(s)';
17
+
18
+ export const isHiddenBypassActors = (
19
+ field: UnverifiableRulesetField,
20
+ ): boolean => field.key === BYPASS_ACTORS_KEY;
21
+
22
+ const fieldLabel = (field: UnverifiableRulesetField): string =>
23
+ `ruleset "${field.name}": ${field.key}`;
24
+
25
+ // What the GraphQL bypass-actor fallback established, so the advice can tell a
26
+ // failed request from a permission gap. `countedNames` holds the live rulesets
27
+ // GraphQL did answer a count for.
28
+ export type BypassCountOutcome = {
29
+ readonly countedNames: ReadonlySet<string>;
30
+ readonly failure: string | null;
31
+ };
32
+
33
+ // Rulesets can be unverifiable for reasons other than a hidden bypass list, in
34
+ // which case the fallback is never called.
35
+ export const GRAPHQL_NOT_CONSULTED: BypassCountOutcome = {
36
+ countedNames: new Set(),
37
+ failure: null,
38
+ };
39
+
40
+ // The generic "read access to repository administration" remedy is
41
+ // unactionable for brokered CI here: REST withholds this field from a GitHub
42
+ // App installation token at every permission level, so widening the App only
43
+ // broadens the blast radius while the gate stays red.
44
+ const INSTALLATION_DEAD_END =
45
+ "A GitHub App installation token cannot read bypass_actors over REST at any permission level, so widening the App's permissions will not help";
46
+
47
+ const COUNT_MATCHED_ADVICE = `GitHub reports the declared number of bypass actors but withholds their identities, so the repository is probably fine and only a local run with admin auth can confirm it. ${INSTALLATION_DEAD_END}`;
48
+
49
+ const NOTHING_ANSWERED_ADVICE = `Neither REST nor the GraphQL bypass-actor count answered for the ruleset(s) listed, so nothing at all is known about who may bypass them; verify locally with admin auth. ${INSTALLATION_DEAD_END}`;
50
+
51
+ // The two no-failure remedies differ per ruleset, so the fields are partitioned
52
+ // rather than reported under whichever advice one of them happened to match: a
53
+ // ruleset GraphQL never answered for must never be described as probably fine
54
+ // because a sibling ruleset was counted.
55
+ const hiddenBypassActorsProblems = (
56
+ fields: ReadonlyArray<UnverifiableRulesetField>,
57
+ outcome: BypassCountOutcome,
58
+ ): ReadonlyArray<string> => {
59
+ if (outcome.failure !== null) {
60
+ return unverifiableProblem(
61
+ SCOPE,
62
+ fields.map(fieldLabel),
63
+ `The GraphQL bypass-actor count did not answer (${outcome.failure}), so this is a failed request rather than a permission gap: re-run the check, and if it keeps failing verify locally with admin auth`,
64
+ );
65
+ }
66
+ const counted = fields.filter((field) =>
67
+ outcome.countedNames.has(field.name),
68
+ );
69
+ const unanswered = fields.filter(
70
+ (field) => !outcome.countedNames.has(field.name),
71
+ );
72
+ return [
73
+ ...unverifiableProblem(
74
+ SCOPE,
75
+ counted.map(fieldLabel),
76
+ COUNT_MATCHED_ADVICE,
77
+ ),
78
+ ...unverifiableProblem(
79
+ SCOPE,
80
+ unanswered.map(fieldLabel),
81
+ NOTHING_ANSWERED_ADVICE,
82
+ ),
83
+ ];
84
+ };
85
+
86
+ export const rulesetVisibilityProblems = (
87
+ unverifiable: ReadonlyArray<UnverifiableRulesetField>,
88
+ outcome: BypassCountOutcome,
89
+ ): ReadonlyArray<string> => {
90
+ const hidden = unverifiable.filter(isHiddenBypassActors);
91
+ const others = unverifiable.filter((field) => !isHiddenBypassActors(field));
92
+ return [
93
+ ...unverifiableProblem(
94
+ SCOPE,
95
+ others.map(fieldLabel),
96
+ ADMIN_VISIBILITY_ADVICE,
97
+ ),
98
+ ...hiddenBypassActorsProblems(hidden, outcome),
99
+ ];
100
+ };
@@ -1,8 +1,7 @@
1
- // Shape parsing for a single declarative GitHub settings file: the canonical
2
- // `.github/settings.json` or the repo-owned `.github/settings.local.json`
3
- // extension. Seam merging lives in github-settings.ts, drift comparison in
4
- // github-diff.ts, and the API interaction in github-api.ts. Like cli.ts, this
5
- // module is zero-dependency so `bunx` can execute the published package.
1
+ // Parse one GitHub settings file: canonical `.github/settings.json` or the
2
+ // repo-owned `.github/settings.local.json` extension. Seam merging, drift
3
+ // comparison, and API interaction live in dedicated modules. This module stays
4
+ // zero-dependency so `bunx` can execute the published package.
6
5
 
7
6
  import { labelIdentity } from './github-label-identity';
8
7
 
@@ -139,13 +138,15 @@ const parseDeclarationList = (
139
138
  options: ListField,
140
139
  problems: Array<string>,
141
140
  ): ReadonlyArray<unknown> | null => {
142
- const list = raw ?? [];
143
- if (!Array.isArray(list)) {
141
+ if (raw === undefined) {
142
+ return [];
143
+ }
144
+ if (!Array.isArray(raw)) {
144
145
  problems.push(`${options.label} "${options.field}" must be an array`);
145
146
  return null;
146
147
  }
147
- problems.push(...options.listProblems(list, options.label));
148
- return list;
148
+ problems.push(...options.listProblems(raw, options.label));
149
+ return raw;
149
150
  };
150
151
 
151
152
  export const parseSettings = (
@@ -162,7 +163,7 @@ export const parseSettings = (
162
163
  problems.push(`${label} has unknown key "${key}"`);
163
164
  }
164
165
  }
165
- const repository = raw.repository ?? {};
166
+ const repository = 'repository' in raw ? raw.repository : {};
166
167
  if (!isRecord(repository)) {
167
168
  problems.push(`${label} "repository" must be an object`);
168
169
  }
@@ -179,6 +180,7 @@ export const parseSettings = (
179
180
  // Only the opt-out value is accepted: enforcement is the default, and an
180
181
  // explicit "enforced" would be a second way to spell the same state.
181
182
  if (
183
+ allowedKeys.has('rulesetEnforcement') &&
182
184
  raw.rulesetEnforcement !== undefined &&
183
185
  raw.rulesetEnforcement !== ENFORCEMENT_OPT_OUT
184
186
  ) {
@@ -1,7 +1,8 @@
1
1
  // Load and merge canonical GitHub settings with the repo-owned extension.
2
2
  // Parsing lives in github-settings-parse.ts, merge policy in
3
- // github-settings-merge.ts, drift in github-diff.ts, and API work in
4
- // github-api.ts; this stays dependency-free.
3
+ // github-settings-merge.ts, drift in github-diff.ts and
4
+ // github-ruleset-diff.ts, and API work in github-api.ts; this stays
5
+ // dependency-free.
5
6
 
6
7
  import { mergeSettings } from './github-settings-merge';
7
8
  import {
@@ -1,4 +1,12 @@
1
- import { readdir, readFile, stat } from 'node:fs/promises';
1
+ import {
2
+ lstat,
3
+ readdir,
4
+ readFile,
5
+ readlink,
6
+ rm,
7
+ symlink,
8
+ writeFile,
9
+ } from 'node:fs/promises';
2
10
  import { join, relative, sep } from 'node:path';
3
11
 
4
12
  // Never mirrored, even under a managed directory path: build output, VCS
@@ -16,34 +24,76 @@ const directiveToken = Buffer.from(['biome', 'ignore'].join('-'));
16
24
 
17
25
  const toPosix = (path: string): string => path.split(sep).join('/');
18
26
 
27
+ // A managed path is a file or a symlink. A symlink's identity is its target,
28
+ // never the content it resolves to: mirroring must reproduce the link itself,
29
+ // and following it would copy the whole target tree into every consumer.
30
+ export type ManagedEntry =
31
+ | { readonly kind: 'file'; readonly absolutePath: string }
32
+ | {
33
+ readonly kind: 'symlink';
34
+ readonly absolutePath: string;
35
+ readonly target: string;
36
+ }
37
+ // Only ever observed when inspecting a destination a consumer replaced; the
38
+ // upstream walk descends into directories instead of recording them.
39
+ | { readonly kind: 'directory'; readonly absolutePath: string };
40
+
41
+ // Built rather than written as an escape so this source file stays plain ASCII
42
+ // and greppable. File digests remain plain content hashes, so existing consumer
43
+ // locks keep validating; the markers only need to be unreachable content for a
44
+ // canonical file, and no text file begins with a NUL byte.
45
+ const nul = String.fromCharCode(0);
46
+ const SYMLINK_DIGEST_PREFIX = `${nul}standards-symlink${nul}`;
47
+ const DIRECTORY_DIGEST = `${nul}standards-directory${nul}`;
48
+
49
+ export const inspectManagedPath = async (
50
+ path: string,
51
+ ): Promise<ManagedEntry | null> => {
52
+ const info = await lstat(path).catch(() => null);
53
+ if (info === null) {
54
+ return null;
55
+ }
56
+ if (info.isSymbolicLink()) {
57
+ return {
58
+ kind: 'symlink',
59
+ absolutePath: path,
60
+ target: toPosix(await readlink(path)),
61
+ };
62
+ }
63
+ if (info.isDirectory()) {
64
+ return { kind: 'directory', absolutePath: path };
65
+ }
66
+ return { kind: 'file', absolutePath: path };
67
+ };
68
+
19
69
  const walkManagedFiles = async (
20
70
  path: string,
21
71
  repositoryRoot: string,
22
- files: Map<string, string>,
72
+ files: Map<string, ManagedEntry>,
23
73
  ): Promise<void> => {
24
- const info = await stat(path).catch(() => null);
25
- if (info === null) {
74
+ const entry = await inspectManagedPath(path);
75
+ if (entry === null) {
26
76
  return;
27
77
  }
28
- if (info.isDirectory()) {
78
+ if (entry.kind === 'directory') {
29
79
  const entries = await readdir(path);
30
80
  await Promise.all(
31
81
  entries
32
- .filter((entry) => !IGNORED_DIRECTORIES.has(entry))
33
- .map((entry) =>
34
- walkManagedFiles(join(path, entry), repositoryRoot, files),
82
+ .filter((name) => !IGNORED_DIRECTORIES.has(name))
83
+ .map((name) =>
84
+ walkManagedFiles(join(path, name), repositoryRoot, files),
35
85
  ),
36
86
  );
37
87
  return;
38
88
  }
39
- files.set(toPosix(relative(repositoryRoot, path)), path);
89
+ files.set(toPosix(relative(repositoryRoot, path)), entry);
40
90
  };
41
91
 
42
92
  export const listManagedFiles = async (
43
93
  repositoryRoot: string,
44
94
  paths: ReadonlyArray<string>,
45
- ): Promise<ReadonlyMap<string, string>> => {
46
- const files = new Map<string, string>();
95
+ ): Promise<ReadonlyMap<string, ManagedEntry>> => {
96
+ const files = new Map<string, ManagedEntry>();
47
97
  await Promise.all(
48
98
  paths.map((path) =>
49
99
  walkManagedFiles(join(repositoryRoot, path), repositoryRoot, files),
@@ -52,12 +102,64 @@ export const listManagedFiles = async (
52
102
  return files;
53
103
  };
54
104
 
105
+ // What the lock hashes for an entry: file content, or the marker-prefixed link
106
+ // target. Retargeting a link therefore reads as drift the same way an edited
107
+ // file does.
108
+ export const managedEntryDigestInput = (
109
+ entry: ManagedEntry,
110
+ ): Promise<Buffer> => {
111
+ if (entry.kind === 'symlink') {
112
+ return Promise.resolve(
113
+ Buffer.from(`${SYMLINK_DIGEST_PREFIX}${entry.target}`),
114
+ );
115
+ }
116
+ if (entry.kind === 'directory') {
117
+ return Promise.resolve(Buffer.from(DIRECTORY_DIGEST));
118
+ }
119
+ return readFile(entry.absolutePath);
120
+ };
121
+
122
+ // Plant one managed entry, replacing a file or a link that occupies the
123
+ // destination. Never recursive: a directory at the destination is unbounded
124
+ // consumer content, so removing it is the caller's decision to make explicitly
125
+ // once it has proven the path was engine-owned.
126
+ export const writeManagedEntry = async (
127
+ dest: string,
128
+ entry: ManagedEntry,
129
+ ): Promise<void> => {
130
+ if (entry.kind === 'directory') {
131
+ throw new Error(`cannot mirror a directory as a managed path: ${dest}`);
132
+ }
133
+ const existing = await inspectManagedPath(dest);
134
+ if (existing?.kind === 'directory') {
135
+ throw new Error(
136
+ `refusing to replace the directory ${dest} with a managed ${entry.kind}; move or delete it first`,
137
+ );
138
+ }
139
+ // `symlink` refuses an existing path, and writing a file through a stale link
140
+ // would clobber that link's target instead of replacing the managed path.
141
+ if (
142
+ existing !== null &&
143
+ (entry.kind === 'symlink' || existing.kind === 'symlink')
144
+ ) {
145
+ await rm(dest, { force: true });
146
+ }
147
+ if (entry.kind === 'symlink') {
148
+ await symlink(entry.target, dest);
149
+ return;
150
+ }
151
+ await writeFile(dest, await readFile(entry.absolutePath));
152
+ };
153
+
55
154
  export const findManagedFilesContainingBiomeDirectiveToken = async (
56
- files: ReadonlyMap<string, string>,
155
+ files: ReadonlyMap<string, ManagedEntry>,
57
156
  ): Promise<ReadonlyArray<string>> => {
58
157
  const matches = await Promise.all(
59
- [...files].map(async ([path, absolutePath]) =>
60
- (await readFile(absolutePath)).includes(directiveToken) ? path : null,
158
+ [...files].map(async ([path, entry]) =>
159
+ entry.kind === 'file' &&
160
+ (await readFile(entry.absolutePath)).includes(directiveToken)
161
+ ? path
162
+ : null,
61
163
  ),
62
164
  );
63
165
  return matches.filter((path): path is string => path !== null).sort();
@@ -0,0 +1,85 @@
1
+ // Ownership questions about a destination path, asked before init/sync writes
2
+ // to it or deletes it. Both answers come from the lock: it is the only record of
3
+ // what this engine put in a consumer, so anything absent from it is consumer
4
+ // work that a mirror must never destroy.
5
+
6
+ import { readdir } from 'node:fs/promises';
7
+ import { join } from 'node:path';
8
+ import { inspectManagedPath, type ManagedEntry } from './managed-files';
9
+
10
+ const collectPaths = async (
11
+ root: string,
12
+ rel: string,
13
+ found: Array<string>,
14
+ ): Promise<void> => {
15
+ const entry = await inspectManagedPath(join(root, rel));
16
+ if (entry === null) {
17
+ return;
18
+ }
19
+ if (entry.kind === 'directory') {
20
+ const names = await readdir(join(root, rel));
21
+ await Promise.all(
22
+ names.map((name) => collectPaths(root, `${rel}/${name}`, found)),
23
+ );
24
+ return;
25
+ }
26
+ found.push(rel);
27
+ };
28
+
29
+ // Paths inside `rel` that the lock never recorded, so a caller can tell an
30
+ // engine-owned tree from consumer work. An older CLI that followed a canonical
31
+ // link materialized its whole target tree and locked every path in it; a
32
+ // consumer's own file under the same directory is absent from the lock. Nothing
33
+ // here follows a symlink: every entry is judged where it sits.
34
+ export const unlockedPathsUnder = async (
35
+ root: string,
36
+ rel: string,
37
+ locked: ReadonlySet<string>,
38
+ ): Promise<ReadonlyArray<string>> => {
39
+ const found: Array<string> = [];
40
+ await collectPaths(root, rel, found);
41
+ return found.filter((path) => !locked.has(path)).sort();
42
+ };
43
+
44
+ // An ancestor that is not a directory, so nothing can sit below it under the
45
+ // name the lock recorded. The kind is part of the answer: below a symlink the
46
+ // name still resolves, to somebody else's content, while below a file it
47
+ // resolves to nothing at all.
48
+ export type InterposedAncestor = {
49
+ readonly rel: string;
50
+ readonly kind: 'file' | 'symlink';
51
+ };
52
+
53
+ // The shallowest ancestor of `rel` inside `root` that is not a directory, or
54
+ // null when the whole parent chain is directories.
55
+ //
56
+ // `planned` is the payload this run mirrors, and it wins over the disk: every
57
+ // path in it has the kind upstream gives it once the mirror has finished, and
58
+ // the caller runs after the mirror. A dry run, which writes nothing, therefore
59
+ // reaches the same answer the matching real run does.
60
+ //
61
+ // A path whose parent chain crosses a link does not name what the caller thinks
62
+ // it names: reading or removing it reaches into the link's target tree, which
63
+ // is somebody else's content.
64
+ export const interposedAncestor = async (
65
+ root: string,
66
+ rel: string,
67
+ planned: ReadonlyMap<string, ManagedEntry>,
68
+ ): Promise<InterposedAncestor | null> => {
69
+ const segments = rel.split('/').slice(0, -1);
70
+ const prefixes = segments.map((_, depth) =>
71
+ segments.slice(0, depth + 1).join('/'),
72
+ );
73
+ const entries = await Promise.all(
74
+ prefixes.map(
75
+ (prefix) => planned.get(prefix) ?? inspectManagedPath(join(root, prefix)),
76
+ ),
77
+ );
78
+ for (const [index, prefix] of prefixes.entries()) {
79
+ const entry = entries[index] ?? null;
80
+ if (entry !== null && entry.kind !== 'directory') {
81
+ return { rel: prefix, kind: entry.kind };
82
+ }
83
+ }
84
+ return null;
85
+ };