@zgeoff/chex 0.0.3 → 0.0.5

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
@@ -52,14 +52,14 @@ The docs checks read the repo's READMEs, its `docs/` tree and its skills:
52
52
 
53
53
  The repo checks read the branch, the pushed commits and the repo's config:
54
54
 
55
- | Check | Fails on | Arguments |
56
- | ------------------ | --------------------------------------------------------------------------- | --------------- |
57
- | `commits` | a pushed commit whose message fails `@zgeoff/commitlint-config` | `--pushed-refs` |
58
- | `local-paths` | a line a pushed commit adds that holds the home folder or the host name | `--pushed-refs` |
59
- | `test-timeouts` | a `bun test` timeout above its lane's limit, in a script or a `bunfig.toml` | none |
60
- | `workflows` | nothing: it warns on each fault of the shared workflow lint, and passes | the paths |
61
- | `dependency-gates` | a stale age-gate exclude, an override or an audit ignore that nothing needs | none |
62
- | `uncommitted` | a file the branch changes that holds a change the push does not send | none |
55
+ | Check | Fails on | Arguments |
56
+ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------ | --------------- |
57
+ | `commits` | a pushed commit whose message fails `@zgeoff/commitlint-config` | `--pushed-refs` |
58
+ | `local-paths` | a line a pushed commit adds that holds the home folder or the host name | `--pushed-refs` |
59
+ | `test-timeouts` | a `bun test` timeout above its lane's limit, in a script or a `bunfig.toml` | none |
60
+ | `workflows` | nothing: it warns on each fault of the shared workflow lint, and passes | the paths |
61
+ | `dependency-gates` | a stale age-gate exclude, an override or an audit ignore that nothing needs, or a first-party package with no age-gate exclude | none |
62
+ | `uncommitted` | a file the branch changes that holds a change the push does not send | none |
63
63
 
64
64
  With no path, `prose` and `links` read every README outside `.claude/` and `.agents/`, every file
65
65
  under `docs/`, each spike's `results.md`, and the Markdown under `.agents/skills/`,
@@ -79,8 +79,10 @@ skipped file back:
79
79
  - `prose`, `links`, `readmes` and `skill` skip each file that `.gitattributes` marks
80
80
  `linguist-vendored`.
81
81
 
82
- `chex init` writes the `extends` line into `lefthook.yml` and adds a `check:docs` script of
83
- `chex prose length links skill` when the repo has none. A second run changes nothing.
82
+ `chex init` writes the `extends` line and a
83
+ [`chex-baseline` job](../../docs/architecture/pre-push-baseline.md#a-checkout-without-the-baseline)
84
+ into `lefthook.yml`, and adds a `check:docs` script of `chex prose length links skill` when the repo
85
+ has none. A second run changes nothing.
84
86
 
85
87
  ## The pre-push baseline
86
88
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/chex",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
4
4
  "description": "Run each shared docs and repo check as one subcommand, with the lefthook pre-push baseline that calls them",
5
5
  "keywords": [
6
6
  "checks",
@@ -34,7 +34,7 @@
34
34
  "ts-pattern": "5.9.2"
35
35
  },
36
36
  "devDependencies": {
37
- "@types/bun": "1.4.3",
37
+ "@types/bun": "1.4.4",
38
38
  "tiny-invariant": "1.3.3",
39
39
  "tsrs": "0.18.0-ts7.1.0-dev.20260929",
40
40
  "typescript": "7.0.2"
@@ -3,6 +3,7 @@ import type { PackageOutcome } from './build-init-package.ts';
3
3
  import { buildInitPackage, docsScript } from './build-init-package.ts';
4
4
  import type { LefthookOutcome } from './build-lefthook-extends.ts';
5
5
  import { baselinePath, buildLefthookExtends } from './build-lefthook-extends.ts';
6
+ import { buildLefthookGuard, guardName, guardedHooks } from './build-lefthook-guard.ts';
6
7
 
7
8
  export interface InitWrite {
8
9
  readonly file: 'lefthook.yml' | 'package.json';
@@ -81,15 +82,32 @@ function buildPlanFromSteps(steps: readonly InitStep[]): InitPlan {
81
82
  };
82
83
  }
83
84
 
84
- const keptStep: InitStep = { reasons: [], writes: [], notes: [] };
85
-
86
85
  function buildLefthookStep(lefthookText: string | undefined): InitStep {
87
86
  return match<LefthookOutcome, InitStep>(buildLefthookExtends(lefthookText))
88
- .with({ kind: 'unchanged' }, () => keptStep)
87
+ .with({ kind: 'unchanged' }, (outcome) => buildGuardStep(outcome.text, []))
88
+ .with({ kind: 'changed' }, (outcome) =>
89
+ buildGuardStep(outcome.text, [`lefthook.yml extends ${baselinePath}`]),
90
+ )
91
+ .with({ kind: 'refused' }, (outcome) => ({ reasons: [outcome.reason], writes: [], notes: [] }))
92
+ .exhaustive();
93
+ }
94
+
95
+ const keptStep: InitStep = { reasons: [], writes: [], notes: [] };
96
+
97
+ function buildGuardStep(text: string, notes: readonly string[]): InitStep {
98
+ return match<LefthookOutcome, InitStep>(buildLefthookGuard(text))
99
+ .with({ kind: 'unchanged' }, () =>
100
+ notes.length === 0
101
+ ? keptStep
102
+ : { reasons: [], writes: [{ file: 'lefthook.yml', text }], notes },
103
+ )
89
104
  .with({ kind: 'changed' }, (outcome) => ({
90
105
  reasons: [],
91
106
  writes: [{ file: 'lefthook.yml', text: outcome.text }],
92
- notes: [`lefthook.yml extends ${baselinePath}`],
107
+ notes: [
108
+ ...notes,
109
+ `lefthook.yml runs ${guardName} first in ${guardedHooks.join(' and ')}, which fails when ${baselinePath} is missing`,
110
+ ],
93
111
  }))
94
112
  .with({ kind: 'refused' }, (outcome) => ({ reasons: [outcome.reason], writes: [], notes: [] }))
95
113
  .exhaustive();
@@ -1,9 +1,9 @@
1
- import { isRecord } from '@zgeoff/std';
1
+ import { parseLefthookConfig } from './parse-lefthook-config.ts';
2
2
 
3
3
  export const baselinePath = 'node_modules/@zgeoff/chex/lefthook.yml';
4
4
 
5
5
  export type LefthookOutcome =
6
- | { readonly kind: 'unchanged' }
6
+ | { readonly kind: 'unchanged'; readonly text: string }
7
7
  | { readonly kind: 'changed'; readonly text: string }
8
8
  | { readonly kind: 'refused'; readonly reason: string };
9
9
 
@@ -18,7 +18,7 @@ export function buildLefthookExtends(text: string | undefined): LefthookOutcome
18
18
  return { kind: 'changed', text: `extends:\n - ${baselinePath}\n` };
19
19
  }
20
20
 
21
- const config = parseConfig(text);
21
+ const config = parseLefthookConfig(text);
22
22
 
23
23
  if (config === undefined) {
24
24
  return { kind: 'refused', reason: 'lefthook.yml is not a YAML mapping' };
@@ -31,7 +31,7 @@ export function buildLefthookExtends(text: string | undefined): LefthookOutcome
31
31
  }
32
32
 
33
33
  if (Array.isArray(extended) && extended.includes(baselinePath)) {
34
- return { kind: 'unchanged' };
34
+ return { kind: 'unchanged', text };
35
35
  }
36
36
 
37
37
  const inline = inlineExtends.exec(text);
@@ -54,25 +54,9 @@ export function buildLefthookExtends(text: string | undefined): LefthookOutcome
54
54
  return checkEdit(config, `${text.slice(0, end)}\n - ${baselinePath}${text.slice(end)}`);
55
55
  }
56
56
 
57
- function parseConfig(text: string): Record<string, unknown> | undefined {
58
- let parsed: unknown;
59
-
60
- try {
61
- parsed = Bun.YAML.parse(text);
62
- } catch {
63
- return undefined;
64
- }
65
-
66
- if (parsed === null) {
67
- return {};
68
- }
69
-
70
- return isRecord(parsed) ? parsed : undefined;
71
- }
72
-
73
57
  // A flow mapping or a second YAML document reads differently once a block key joins the text.
74
58
  function checkEdit(config: Record<string, unknown>, edited: string): LefthookOutcome {
75
- const editedConfig = parseConfig(edited);
59
+ const editedConfig = parseLefthookConfig(edited);
76
60
 
77
61
  if (editedConfig !== undefined) {
78
62
  const { extends: editedExtends, ...editedRest } = editedConfig;
@@ -0,0 +1,170 @@
1
+ import { isRecord } from '@zgeoff/std';
2
+ import type { LefthookOutcome } from './build-lefthook-extends.ts';
3
+ import { baselinePath } from './build-lefthook-extends.ts';
4
+ import { parseLefthookConfig } from './parse-lefthook-config.ts';
5
+
6
+ export const guardedHooks = ['pre-commit', 'pre-push'] as const;
7
+
8
+ // lefthook runs the repo's own jobs before an extended file's, and a piped hook stops at the first
9
+ // failure, so the guard goes first.
10
+ export function buildLefthookGuard(text: string): LefthookOutcome {
11
+ let edited = text;
12
+
13
+ for (const hook of guardedHooks) {
14
+ const edit = buildHookEdit(edited, hook);
15
+
16
+ if (edit.kind === 'refused') {
17
+ return edit;
18
+ }
19
+
20
+ edited = edit.text;
21
+ }
22
+
23
+ return edited === text ? { kind: 'unchanged', text } : { kind: 'changed', text: edited };
24
+ }
25
+
26
+ type GuardedHook = (typeof guardedHooks)[number];
27
+
28
+ type HookEdit =
29
+ | { readonly kind: 'guarded'; readonly text: string }
30
+ | { readonly kind: 'refused'; readonly reason: string };
31
+
32
+ export const guardName = 'chex-baseline';
33
+
34
+ // lefthook drops an extends entry whose file is missing and prints nothing, so a checkout with no
35
+ // node_modules runs none of the baseline's jobs unless a job in the repo's own config fails.
36
+ const guardScript = [
37
+ `if [ ! -f ${baselinePath} ]; then`,
38
+ ` echo "${guardName}: ${baselinePath} is missing, so lefthook runs none of the chex baseline's jobs; run bun install, then try again"`,
39
+ ' exit 1',
40
+ 'fi',
41
+ '# {files}',
42
+ ];
43
+
44
+ // lefthook 2.2.2 skips a job whose run substitutes no file when no file matches: a commit that
45
+ // stages none, or a push of another branch while the checked-out one has nothing to push. Every
46
+ // repo that takes the baseline has package.json, and the shell comment hides it from the check.
47
+ const guardFiles = 'echo package.json';
48
+ const guardJob = { name: guardName, files: guardFiles, run: `${guardScript.join('\n')}\n` };
49
+
50
+ function buildHookEdit(text: string, hook: GuardedHook): HookEdit {
51
+ const config = parseLefthookConfig(text);
52
+
53
+ if (config === undefined) {
54
+ return { kind: 'refused', reason: 'lefthook.yml is not a YAML mapping' };
55
+ }
56
+
57
+ const hookConfig = config[hook];
58
+
59
+ if (hookConfig === undefined) {
60
+ const separator = text.endsWith('\n') ? '' : '\n';
61
+
62
+ return checkHookEdit(
63
+ config,
64
+ hook,
65
+ { jobs: [guardJob] },
66
+ `${text}${separator}\n${hook}:\n jobs:\n${formatGuardJob(' ')}`,
67
+ );
68
+ }
69
+
70
+ const listedJobs = isRecord(hookConfig) ? hookConfig['jobs'] : undefined;
71
+
72
+ if (!isRecord(hookConfig) || !Array.isArray(listedJobs)) {
73
+ return refuseHook(hook);
74
+ }
75
+
76
+ const jobs: readonly unknown[] = listedJobs;
77
+ const listed = jobs.find((job: unknown) => isRecord(job) && job['name'] === guardName);
78
+
79
+ if (listed !== undefined) {
80
+ return Bun.deepEquals(listed, guardJob, true)
81
+ ? { kind: 'guarded', text }
82
+ : {
83
+ kind: 'refused',
84
+ reason: `lefthook.yml has a ${guardName} job under ${hook} that differs from the one chex init writes; remove it, then run chex init again`,
85
+ };
86
+ }
87
+
88
+ const insert = findGuardInsert(text, hook);
89
+
90
+ if (insert === undefined) {
91
+ return refuseHook(hook);
92
+ }
93
+
94
+ return checkHookEdit(
95
+ config,
96
+ hook,
97
+ { ...hookConfig, jobs: [guardJob, ...jobs] },
98
+ `${text.slice(0, insert.offset)}${formatGuardJob(insert.indent)}${text.slice(insert.offset)}`,
99
+ );
100
+ }
101
+
102
+ // A job list that the text lays out in a form the line search misreads, such as a flow list or a
103
+ // key in quotes, parses to something else once the guard lines go in.
104
+ function checkHookEdit(
105
+ config: Record<string, unknown>,
106
+ hook: GuardedHook,
107
+ hookConfig: Record<string, unknown>,
108
+ edited: string,
109
+ ): HookEdit {
110
+ const editedConfig = parseLefthookConfig(edited);
111
+
112
+ if (
113
+ editedConfig !== undefined &&
114
+ Bun.deepEquals(editedConfig, { ...config, [hook]: hookConfig }, true)
115
+ ) {
116
+ return { kind: 'guarded', text: edited };
117
+ }
118
+
119
+ return refuseHook(hook);
120
+ }
121
+
122
+ function refuseHook(hook: GuardedHook): HookEdit {
123
+ return {
124
+ kind: 'refused',
125
+ reason: `lefthook.yml writes ${hook} in a form chex init does not edit; write its ${hook} jobs as a block list, then run chex init again`,
126
+ };
127
+ }
128
+
129
+ function formatGuardJob(indent: string): string {
130
+ const lines = [
131
+ `- name: ${guardName}`,
132
+ ` files: ${guardFiles}`,
133
+ ' run: |',
134
+ ...guardScript.map((line) => ` ${line}`),
135
+ ];
136
+
137
+ return lines.map((line) => `${indent}${line}\n`).join('');
138
+ }
139
+
140
+ interface GuardInsert {
141
+ readonly offset: number;
142
+ readonly indent: string;
143
+ }
144
+
145
+ function findGuardInsert(text: string, hook: GuardedHook): GuardInsert | undefined {
146
+ const hookLine = new RegExp(`^${hook}:[ \\t]*(?:#.*)?$`, 'mu').exec(text);
147
+
148
+ if (hookLine === null) {
149
+ return undefined;
150
+ }
151
+
152
+ const blockStart = hookLine.index + hookLine[0].length;
153
+ const blockEnd = /^[^\s#]/mu.exec(text.slice(blockStart + 1));
154
+ const blockStop = blockEnd === null ? text.length : blockStart + 1 + blockEnd.index;
155
+ const block = text.slice(blockStart, blockStop);
156
+ const jobsLine = /^[ \t]+jobs:[ \t]*(?:#.*)?$/mu.exec(block);
157
+
158
+ if (jobsLine === null) {
159
+ return undefined;
160
+ }
161
+
162
+ const afterJobs = jobsLine.index + jobsLine[0].length;
163
+ const item = /^(?<indent>[ \t]*)- /mu.exec(block.slice(afterJobs));
164
+
165
+ if (item === null) {
166
+ return undefined;
167
+ }
168
+
169
+ return { offset: blockStart + afterJobs + 1, indent: item.groups?.['indent'] ?? '' };
170
+ }
@@ -0,0 +1,24 @@
1
+ import { firstPartyScopes } from './first-party-scopes.ts';
2
+ import type { AgeGate } from './parse-age-gate.ts';
3
+ import type { DependencyGateFinding } from './types.ts';
4
+
5
+ // An exclude with a version, such as `@zgeoff/std@1.0.0`, lets only that release through, so it
6
+ // does not count.
7
+ export function collectMissingFirstPartyExcludes(
8
+ ageGate: AgeGate,
9
+ registryNames: readonly string[],
10
+ ): DependencyGateFinding[] {
11
+ if (!ageGate.isOn) {
12
+ return [];
13
+ }
14
+
15
+ return registryNames
16
+ .filter((name) => firstPartyScopes.some((scope) => name.startsWith(scope)))
17
+ .filter((name) => !ageGate.excludes.includes(name))
18
+ .map((name) => ({
19
+ path: 'bunfig.toml',
20
+ entry: name,
21
+ problem:
22
+ 'the first-party package has no age-gate exclude, so bun install refuses each release younger than the gate',
23
+ }));
24
+ }
@@ -1,6 +1,6 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import { firstPartyScopes } from './first-party-scopes.ts';
3
- import { isRecord } from './is-record.ts';
3
+ import { parseAgeGate } from './parse-age-gate.ts';
4
4
  import type { DependencyGateFinding } from './types.ts';
5
5
 
6
6
  export function collectStaleAgeGateExcludes(
@@ -9,8 +9,8 @@ export function collectStaleAgeGateExcludes(
9
9
  ): DependencyGateFinding[] {
10
10
  const removalDates = collectRemovalDates(bunfig);
11
11
 
12
- return parseAgeGateExcludes(bunfig)
13
- .filter((name) => !firstPartyScopes.some((scope) => name.startsWith(scope)))
12
+ return parseAgeGate(bunfig)
13
+ .excludes.filter((name) => !firstPartyScopes.some((scope) => name.startsWith(scope)))
14
14
  .flatMap((name) => {
15
15
  const removalDate = removalDates.get(name);
16
16
 
@@ -61,23 +61,3 @@ function collectRemovalDates(bunfig: string): Map<string, string> {
61
61
 
62
62
  return removalDates;
63
63
  }
64
-
65
- function parseAgeGateExcludes(bunfig: string): string[] {
66
- const parsed: unknown = Bun.TOML.parse(bunfig);
67
- const install = isRecord(parsed) ? parsed['install'] : undefined;
68
- const excludes = isRecord(install) ? install['minimumReleaseAgeExcludes'] : undefined;
69
-
70
- if (excludes === undefined) {
71
- return [];
72
- }
73
-
74
- if (!isStringList(excludes)) {
75
- throw new TypeError('bunfig.toml sets a minimumReleaseAgeExcludes that is not a list of names');
76
- }
77
-
78
- return excludes;
79
- }
80
-
81
- function isStringList(value: unknown): value is string[] {
82
- return Array.isArray(value) && value.every((item: unknown) => typeof item === 'string');
83
- }
@@ -1 +1 @@
1
- export const firstPartyScopes = ['@zgeoff/', '@impz/'] as const;
1
+ export const firstPartyScopes = ['@zgeoff/', '@impz/', '@wringer/'] as const;
@@ -0,0 +1,26 @@
1
+ import { isRecord } from './is-record.ts';
2
+
3
+ export interface AgeGate {
4
+ readonly isOn: boolean;
5
+ readonly excludes: readonly string[];
6
+ }
7
+
8
+ export function parseAgeGate(bunfig: string): AgeGate {
9
+ const parsed: unknown = Bun.TOML.parse(bunfig);
10
+ const install = isRecord(parsed) ? parsed['install'] : undefined;
11
+ const minimumReleaseAge = isRecord(install) ? install['minimumReleaseAge'] : undefined;
12
+ const excludes = isRecord(install) ? install['minimumReleaseAgeExcludes'] : undefined;
13
+
14
+ if (excludes !== undefined && !isStringList(excludes)) {
15
+ throw new TypeError('bunfig.toml sets a minimumReleaseAgeExcludes that is not a list of names');
16
+ }
17
+
18
+ return {
19
+ isOn: minimumReleaseAge !== undefined && minimumReleaseAge !== 0 && minimumReleaseAge !== '0',
20
+ excludes: excludes ?? [],
21
+ };
22
+ }
23
+
24
+ function isStringList(value: unknown): value is string[] {
25
+ return Array.isArray(value) && value.every((item: unknown) => typeof item === 'string');
26
+ }
@@ -0,0 +1,27 @@
1
+ import { isRecord } from './is-record.ts';
2
+
3
+ // An entry's id is `<name>@<version>`. A workspace, link, file or git entry carries a protocol in
4
+ // its version, such as `workspace:packages/std`, and bun never puts it through the age gate.
5
+ export function parseLockfileRegistryNames(lockfile: string): string[] {
6
+ const parsed: unknown = Bun.JSONC.parse(lockfile);
7
+ const packages = isRecord(parsed) ? parsed['packages'] : undefined;
8
+
9
+ if (!isRecord(packages)) {
10
+ throw new TypeError('bun.lock has no packages object');
11
+ }
12
+
13
+ const names = Object.values(packages).flatMap((entry) => {
14
+ const id: unknown = Array.isArray(entry) ? entry[0] : undefined;
15
+
16
+ if (typeof id !== 'string') {
17
+ throw new TypeError('bun.lock has a package entry that does not start with its id');
18
+ }
19
+
20
+ const versionStart = id.indexOf('@', 1);
21
+ const version = id.slice(versionStart + 1);
22
+
23
+ return versionStart > 0 && !version.includes(':') ? [id.slice(0, versionStart)] : [];
24
+ });
25
+
26
+ return [...new Set(names)].toSorted();
27
+ }
@@ -1,11 +1,14 @@
1
1
  import type { RunProgram } from '../run-program.ts';
2
+ import { collectMissingFirstPartyExcludes } from './collect-missing-first-party-excludes.ts';
2
3
  import { collectStaleAgeGateExcludes } from './collect-stale-age-gate-excludes.ts';
3
4
  import { collectUnneededAuditIgnores } from './collect-unneeded-audit-ignores.ts';
4
5
  import { collectUnneededOverrides } from './collect-unneeded-overrides.ts';
6
+ import { parseAgeGate } from './parse-age-gate.ts';
5
7
  import { parseAuditAdvisories } from './parse-audit-advisories.ts';
6
8
  import { parseAuditIgnoreIDs } from './parse-audit-ignore-ids.ts';
7
9
  import { parseFixablePackages } from './parse-fixable-packages.ts';
8
10
  import { parseLockfileDependents } from './parse-lockfile-dependents.ts';
11
+ import { parseLockfileRegistryNames } from './parse-lockfile-registry-names.ts';
9
12
  import type { RootManifest } from './parse-root-manifest.ts';
10
13
  import { parseRootManifest } from './parse-root-manifest.ts';
11
14
  import type { DependencyGateFinding } from './types.ts';
@@ -36,24 +39,30 @@ export function readDependencyGateFindings(
36
39
 
37
40
  const manifest = parseRootManifest(manifestText);
38
41
  const ignoreIDs = parseAuditIgnoreIDs(manifest.auditScript);
42
+ const bunfig = sources.readRepoFile('bunfig.toml') ?? '';
43
+ const lockfile = sources.readRepoFile('bun.lock');
39
44
 
40
45
  return [
41
- ...collectStaleAgeGateExcludes(sources.readRepoFile('bunfig.toml') ?? '', sources.today),
42
- ...readOverrideFindings(sources, manifest),
46
+ ...collectStaleAgeGateExcludes(bunfig, sources.today),
47
+ ...(lockfile === null
48
+ ? []
49
+ : collectMissingFirstPartyExcludes(
50
+ parseAgeGate(bunfig),
51
+ parseLockfileRegistryNames(lockfile),
52
+ )),
53
+ ...collectOverrideFindings(manifest, lockfile),
43
54
  ...(ignoreIDs.length === 0 ? [] : readIgnoreFindings(sources, ignoreIDs)),
44
55
  ];
45
56
  }
46
57
 
47
- function readOverrideFindings(
48
- sources: Readonly<DependencyGateSources>,
58
+ function collectOverrideFindings(
49
59
  manifest: RootManifest,
60
+ lockfile: string | null,
50
61
  ): DependencyGateFinding[] {
51
62
  if (Object.keys(manifest.overrides).length === 0) {
52
63
  return [];
53
64
  }
54
65
 
55
- const lockfile = sources.readRepoFile('bun.lock');
56
-
57
66
  if (lockfile === null) {
58
67
  throw new Error('package.json sets overrides, and the repo has no bun.lock');
59
68
  }
@@ -0,0 +1,17 @@
1
+ import { isRecord } from '@zgeoff/std';
2
+
3
+ export function parseLefthookConfig(text: string): Record<string, unknown> | undefined {
4
+ let parsed: unknown;
5
+
6
+ try {
7
+ parsed = Bun.YAML.parse(text);
8
+ } catch {
9
+ return undefined;
10
+ }
11
+
12
+ if (parsed === null) {
13
+ return {};
14
+ }
15
+
16
+ return isRecord(parsed) ? parsed : undefined;
17
+ }