@zgeoff/chex 0.0.0 → 0.0.2

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.
Files changed (53) hide show
  1. package/README.md +96 -3
  2. package/lefthook.yml +123 -0
  3. package/package.json +31 -3
  4. package/src/build-init-package.ts +57 -0
  5. package/src/build-init-plan.ts +96 -0
  6. package/src/build-lefthook-extends.ts +117 -0
  7. package/src/check-names.ts +18 -0
  8. package/src/cli.ts +49 -0
  9. package/src/collect-command-names.ts +297 -0
  10. package/src/collect-expression-references.ts +17 -0
  11. package/src/dependency-gates/collect-stale-age-gate-excludes.ts +83 -0
  12. package/src/dependency-gates/collect-unneeded-audit-ignores.ts +41 -0
  13. package/src/dependency-gates/collect-unneeded-overrides.ts +70 -0
  14. package/src/dependency-gates/first-party-scopes.ts +1 -0
  15. package/src/dependency-gates/is-record.ts +4 -0
  16. package/src/dependency-gates/parse-audit-advisories.ts +33 -0
  17. package/src/dependency-gates/parse-audit-ignore-ids.ts +15 -0
  18. package/src/dependency-gates/parse-fixable-packages.ts +29 -0
  19. package/src/dependency-gates/parse-lockfile-dependents.ts +77 -0
  20. package/src/dependency-gates/parse-root-manifest.ts +68 -0
  21. package/src/dependency-gates/read-dependency-gate-findings.ts +91 -0
  22. package/src/dependency-gates/run-dependency-gates-check.ts +54 -0
  23. package/src/dependency-gates/types.ts +19 -0
  24. package/src/docs/check-length.sh +117 -0
  25. package/src/docs/check-links.sh +245 -0
  26. package/src/docs/check-prose.sh +657 -0
  27. package/src/docs/check-readmes.sh +102 -0
  28. package/src/docs/check-skill.sh +413 -0
  29. package/src/docs/check-vision.sh +34 -0
  30. package/src/find-install-cache-path.ts +16 -0
  31. package/src/format-commit-lines.ts +3 -0
  32. package/src/make-check-runner.ts +40 -0
  33. package/src/parse-added-lines.ts +112 -0
  34. package/src/parse-chex-args.ts +76 -0
  35. package/src/parse-lane-limits.ts +59 -0
  36. package/src/parse-pushed-refs.ts +29 -0
  37. package/src/parse-shell-words.ts +520 -0
  38. package/src/pick-merge-blobs-command.ts +152 -0
  39. package/src/read-branch-base.ts +31 -0
  40. package/src/read-branch-files.ts +59 -0
  41. package/src/read-pushed-ranges.ts +47 -0
  42. package/src/read-pushed-refs-input.ts +18 -0
  43. package/src/read-range-commits.ts +30 -0
  44. package/src/run-chex.ts +62 -0
  45. package/src/run-commits-check.ts +178 -0
  46. package/src/run-init.ts +129 -0
  47. package/src/run-local-paths-check.ts +185 -0
  48. package/src/run-program.ts +38 -0
  49. package/src/run-test-timeouts-check.ts +498 -0
  50. package/src/run-uncommitted-check.ts +82 -0
  51. package/src/run-workflows-check.ts +610 -0
  52. package/src/shell-escape-parsers.ts +197 -0
  53. package/src/write-text-file.ts +7 -0
@@ -0,0 +1,297 @@
1
+ import type { ShellWord } from './parse-shell-words.ts';
2
+ import { parseShellWords } from './parse-shell-words.ts';
3
+
4
+ // Each command contributes the word the shell runs, after any NAME=value assignments, plus the
5
+ // script that bash, sh or bun runs. A command outside the forms read here returns undefined, so the
6
+ // caller skips the step rather than guess what it runs.
7
+ export function collectCommandNames(words: readonly ShellWord[]): string[] | undefined {
8
+ const names = splitCommands(words).map((command) => collectRunTargets(command));
9
+
10
+ return names.every((targets) => targets !== undefined) ? names.flat() : undefined;
11
+ }
12
+
13
+ function splitCommands(words: readonly ShellWord[]): ShellWord[][] {
14
+ const commands: ShellWord[][] = [[]];
15
+
16
+ for (const word of words) {
17
+ if (word.isOperator) {
18
+ commands.push([]);
19
+ } else {
20
+ commands.at(-1)?.push(word);
21
+ }
22
+ }
23
+
24
+ return commands;
25
+ }
26
+
27
+ // A command substitution runs a command that the parser does not split out, a heredoc body holds
28
+ // lines that are data, and a name held in a variable is known only when the step runs.
29
+ const unreadablePattern = /\$\((?!\()|`|(?<!<)<<(?!<)/u;
30
+
31
+ function collectRunTargets(command: readonly ShellWord[]): string[] | undefined {
32
+ const start = command.findIndex((word) => !word.isAssignment);
33
+ const words = start === -1 ? [] : command.slice(start).map((word) => word.text);
34
+ const [name, ...rest] = words;
35
+
36
+ if (name === undefined) {
37
+ return [];
38
+ }
39
+
40
+ if (words.some((word) => unreadablePattern.test(word)) || name.includes('$')) {
41
+ return undefined;
42
+ }
43
+
44
+ if (name === 'bash' || name === 'sh') {
45
+ return collectShellTargets(name, rest);
46
+ }
47
+
48
+ return name === 'bun' ? collectBunTargets(rest) : [name];
49
+ }
50
+
51
+ const shellLongOptions = new Set(['--login', '--noediting', '--noprofile', '--norc', '--posix']);
52
+
53
+ // With -s a shell reads its commands from standard input, so its first operand is an argument, and
54
+ // with -c that operand is a command string. -o and +o take an option name.
55
+ function collectShellTargets(name: string, args: readonly string[]): string[] | undefined {
56
+ let flags = '';
57
+ let index = 0;
58
+
59
+ while (index < args.length) {
60
+ const argument = args[index] ?? '';
61
+
62
+ if (argument === '--') {
63
+ index += 1;
64
+ break;
65
+ }
66
+
67
+ if (argument === '-o' || argument === '+o') {
68
+ index += 2;
69
+ } else if (/^[-+][A-Za-z]+$/u.test(argument)) {
70
+ flags += argument.slice(1);
71
+ index += 1;
72
+ } else if (argument.startsWith('-')) {
73
+ if (!shellLongOptions.has(argument)) {
74
+ return undefined;
75
+ }
76
+
77
+ index += 1;
78
+ } else {
79
+ break;
80
+ }
81
+ }
82
+
83
+ return collectShellOperandTargets(name, flags, args[index]);
84
+ }
85
+
86
+ function collectShellOperandTargets(
87
+ name: string,
88
+ flags: string,
89
+ operand: string | undefined,
90
+ ): string[] | undefined {
91
+ if (flags.includes('s')) {
92
+ return [name];
93
+ }
94
+
95
+ if (!flags.includes('c')) {
96
+ return operand === undefined ? [name] : [name, operand];
97
+ }
98
+
99
+ const words = operand === undefined ? undefined : parseShellWords(operand);
100
+ const names = words === undefined ? undefined : collectCommandNames(words);
101
+
102
+ return names === undefined ? undefined : [name, ...names];
103
+ }
104
+
105
+ // The subcommands `bun --help` lists, with their short aliases. Bun runs any other first operand as
106
+ // a file or a package script.
107
+ const bunCommands = new Set([
108
+ 'a',
109
+ 'add',
110
+ 'audit',
111
+ 'build',
112
+ 'c',
113
+ 'check',
114
+ 'create',
115
+ 'dedupe',
116
+ 'exec',
117
+ 'i',
118
+ 'info',
119
+ 'init',
120
+ 'install',
121
+ 'link',
122
+ 'lint',
123
+ 'outdated',
124
+ 'patch',
125
+ 'pm',
126
+ 'prune',
127
+ 'publish',
128
+ 'remove',
129
+ 'repl',
130
+ 'rm',
131
+ 'test',
132
+ 'unlink',
133
+ 'update',
134
+ 'upgrade',
135
+ 'why',
136
+ 'x',
137
+ ]);
138
+
139
+ function collectBunTargets(args: readonly string[]): string[] | undefined {
140
+ const firstIndex = findBunOperand(args, 0);
141
+ const first = firstIndex === undefined ? undefined : args[firstIndex];
142
+
143
+ if (firstIndex === undefined) {
144
+ return undefined;
145
+ }
146
+
147
+ if (first !== 'run') {
148
+ return first === undefined || bunCommands.has(first) ? ['bun'] : ['bun', first];
149
+ }
150
+
151
+ const scriptIndex = findBunOperand(args, firstIndex + 1);
152
+ const script = scriptIndex === undefined ? undefined : args[scriptIndex];
153
+
154
+ if (scriptIndex === undefined) {
155
+ return undefined;
156
+ }
157
+
158
+ return script === undefined ? ['bun'] : ['bun', script];
159
+ }
160
+
161
+ // The options `bun run --help` lists that take a value, as `--cwd dir` or `--cwd=dir`.
162
+ const bunValueOptions = new Set([
163
+ '-F',
164
+ '--filter',
165
+ '-r',
166
+ '--preload',
167
+ '--require',
168
+ '--import',
169
+ '-d',
170
+ '--define',
171
+ '-l',
172
+ '--loader',
173
+ '-c',
174
+ '--config',
175
+ '--conditions',
176
+ '--console-depth',
177
+ '--cpu-prof-dir',
178
+ '--cpu-prof-interval',
179
+ '--cpu-prof-name',
180
+ '--cron-period',
181
+ '--cron-title',
182
+ '--cwd',
183
+ '--disable-warning',
184
+ '--dns-result-order',
185
+ '--drop',
186
+ '--elide-lines',
187
+ '--env-file',
188
+ '--extension-order',
189
+ '--feature',
190
+ '--fetch-preconnect',
191
+ '--heap-prof-dir',
192
+ '--heap-prof-interval',
193
+ '--heap-prof-name',
194
+ '--install',
195
+ '--jsx-factory',
196
+ '--jsx-fragment',
197
+ '--jsx-import-source',
198
+ '--jsx-runtime',
199
+ '--main-fields',
200
+ '--max-http-header-size',
201
+ '--port',
202
+ '--redirect-warnings',
203
+ '--shell',
204
+ '--title',
205
+ '--tsconfig-override',
206
+ '--unhandled-rejections',
207
+ '--user-agent',
208
+ '--watch-kill-signal',
209
+ ]);
210
+
211
+ // The options `bun run --help` lists that take no value. The inspect options take one only after
212
+ // an `=`.
213
+ const bunFlagOptions = new Set([
214
+ '-b',
215
+ '--bun',
216
+ '-h',
217
+ '--help',
218
+ '--check',
219
+ '--cpu-prof',
220
+ '--cpu-prof-md',
221
+ '--experimental-http2-fetch',
222
+ '--experimental-http3-fetch',
223
+ '--experimental-stream-iter',
224
+ '--expose-gc',
225
+ '--heap-prof',
226
+ '--heap-prof-md',
227
+ '--hot',
228
+ '--if-present',
229
+ '--ignore-dce-annotations',
230
+ '--insecure-http-parser',
231
+ '--inspect',
232
+ '--inspect-brk',
233
+ '--inspect-wait',
234
+ '--interactive',
235
+ '--jsx-side-effects',
236
+ '--no-addons',
237
+ '--no-clear-screen',
238
+ '--no-deprecation',
239
+ '--no-env-file',
240
+ '--no-exit-on-error',
241
+ '--no-ffi-cc',
242
+ '--no-install',
243
+ '--no-macros',
244
+ '--no-orphans',
245
+ '--no-warnings',
246
+ '--parallel',
247
+ '--pending-deprecation',
248
+ '--prefer-latest',
249
+ '--prefer-offline',
250
+ '--preserve-symlinks',
251
+ '--preserve-symlinks-main',
252
+ '--redis-preconnect',
253
+ '--sequential',
254
+ '--silent',
255
+ '--smol',
256
+ '--sql-preconnect',
257
+ '--throw-deprecation',
258
+ '--tls-max-v1.2',
259
+ '--tls-max-v1.3',
260
+ '--tls-min-v1.0',
261
+ '--tls-min-v1.1',
262
+ '--tls-min-v1.2',
263
+ '--tls-min-v1.3',
264
+ '--trace-deprecation',
265
+ '--trace-warnings',
266
+ '--use-bundled-ca',
267
+ '--use-openssl-ca',
268
+ '--use-system-ca',
269
+ '--watch',
270
+ '--workspaces',
271
+ '--zero-fill-buffers',
272
+ ]);
273
+
274
+ // The index of the first operand at or after start, past each option and the value it takes; the
275
+ // length of args when no operand follows, and undefined at an option outside the two tables.
276
+ function findBunOperand(args: readonly string[], start: number): number | undefined {
277
+ let index = start;
278
+
279
+ while (index < args.length) {
280
+ const argument = args[index] ?? '';
281
+ const option = argument.split('=')[0] ?? '';
282
+
283
+ if (!argument.startsWith('-')) {
284
+ return index;
285
+ }
286
+
287
+ if (bunValueOptions.has(option)) {
288
+ index += argument.includes('=') ? 1 : 2;
289
+ } else if (bunFlagOptions.has(option)) {
290
+ index += 1;
291
+ } else {
292
+ return undefined;
293
+ }
294
+ }
295
+
296
+ return index;
297
+ }
@@ -0,0 +1,17 @@
1
+ // A string literal in an expression may hold `}}`, so the end of an expression is found past them.
2
+ const expressionPattern = /\$\{\{(?<body>(?:'(?:[^']|'')*'|[^'])*?)\}\}/gsu;
3
+ const indexPattern = /\[\s*'(?<name>[A-Za-z_][\w-]*)'\s*\]/gu;
4
+ const stringPattern = /'(?:[^']|'')*'/gu;
5
+ const referencePattern = /[A-Za-z_][\w-]*(?:\.[A-Za-z_][\w-]*)+/gu;
6
+
7
+ // Only a context reference inside an expression changes between runs: the same words outside one,
8
+ // or in one of its string literals, are text. `github['sha']` reads the same value as `github.sha`.
9
+ export function collectExpressionReferences(text: string): string[] {
10
+ return [...text.matchAll(expressionPattern)].flatMap((expression) => {
11
+ const body = (expression.groups?.['body'] ?? '')
12
+ .replaceAll(indexPattern, '.$<name>')
13
+ .replaceAll(stringPattern, "''");
14
+
15
+ return [...body.matchAll(referencePattern)].map((reference) => reference[0]);
16
+ });
17
+ }
@@ -0,0 +1,83 @@
1
+ import assert from 'node:assert/strict';
2
+ import { firstPartyScopes } from './first-party-scopes.ts';
3
+ import { isRecord } from './is-record.ts';
4
+ import type { DependencyGateFinding } from './types.ts';
5
+
6
+ export function collectStaleAgeGateExcludes(
7
+ bunfig: string,
8
+ today: string,
9
+ ): DependencyGateFinding[] {
10
+ const removalDates = collectRemovalDates(bunfig);
11
+
12
+ return parseAgeGateExcludes(bunfig)
13
+ .filter((name) => !firstPartyScopes.some((scope) => name.startsWith(scope)))
14
+ .flatMap((name) => {
15
+ const removalDate = removalDates.get(name);
16
+
17
+ if (removalDate === undefined) {
18
+ return [
19
+ {
20
+ path: 'bunfig.toml',
21
+ entry: name,
22
+ problem: 'the age-gate exclude has no dated removal comment',
23
+ },
24
+ ];
25
+ }
26
+
27
+ if (removalDate < today) {
28
+ return [
29
+ {
30
+ path: 'bunfig.toml',
31
+ entry: name,
32
+ problem: `the age-gate exclude was due for removal on ${removalDate}`,
33
+ },
34
+ ];
35
+ }
36
+
37
+ return [];
38
+ });
39
+ }
40
+
41
+ // The shared audit-fix workflow writes this comment for each exclude it adds, and removes the
42
+ // exclude once the date passes.
43
+ const removalCommentPattern =
44
+ /^# (?<name>\S+) \S+ \(published [^)]*\) ages past the gate ~(?<date>\d{4}-\d{2}-\d{2}) — remove then/gmu;
45
+
46
+ function collectRemovalDates(bunfig: string): Map<string, string> {
47
+ const removalDates = new Map<string, string>();
48
+
49
+ for (const match of bunfig.matchAll(removalCommentPattern)) {
50
+ const name = match.groups?.['name'];
51
+ const date = match.groups?.['date'];
52
+
53
+ assert.ok(name !== undefined && date !== undefined, 'the removal comment pattern names both');
54
+
55
+ const latest = removalDates.get(name);
56
+
57
+ if (latest === undefined || date > latest) {
58
+ removalDates.set(name, date);
59
+ }
60
+ }
61
+
62
+ return removalDates;
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
+ }
@@ -0,0 +1,41 @@
1
+ import type { AuditAdvisory, DependencyGateFinding } from './types.ts';
2
+
3
+ interface UnneededAuditIgnoresInput {
4
+ readonly ignoreIDs: readonly string[];
5
+ readonly advisories: readonly AuditAdvisory[];
6
+ readonly fixablePackages: readonly string[];
7
+ }
8
+
9
+ export function collectUnneededAuditIgnores(
10
+ input: UnneededAuditIgnoresInput,
11
+ ): DependencyGateFinding[] {
12
+ return input.ignoreIDs.flatMap((ignoreID) => {
13
+ const matches = input.advisories.filter((advisory) => advisory.advisoryIDs.includes(ignoreID));
14
+
15
+ if (matches.length === 0) {
16
+ return [
17
+ {
18
+ path: 'package.json',
19
+ entry: ignoreID,
20
+ problem: 'no locked package has the ignored advisory',
21
+ },
22
+ ];
23
+ }
24
+
25
+ const fixable = matches.find((advisory) =>
26
+ input.fixablePackages.includes(advisory.packageName),
27
+ );
28
+
29
+ if (fixable !== undefined) {
30
+ return [
31
+ {
32
+ path: 'package.json',
33
+ entry: ignoreID,
34
+ problem: `bun audit fix can move ${fixable.packageName} to a patched release in range`,
35
+ },
36
+ ];
37
+ }
38
+
39
+ return [];
40
+ });
41
+ }
@@ -0,0 +1,70 @@
1
+ import type { DependencyGateFinding, LockfileDependent } from './types.ts';
2
+
3
+ interface UnneededOverridesInput {
4
+ readonly overrides: Readonly<Record<string, string>>;
5
+ readonly catalog: Readonly<Record<string, string>>;
6
+ readonly catalogs: Readonly<Record<string, Readonly<Record<string, string>>>>;
7
+ readonly dependents: readonly LockfileDependent[];
8
+ }
9
+
10
+ export function collectUnneededOverrides(input: UnneededOverridesInput): DependencyGateFinding[] {
11
+ return Object.entries(input.overrides).flatMap(([name, pin]) => {
12
+ const ranges = input.dependents
13
+ .filter((dependent) => dependent.name === name)
14
+ .map((dependent) => resolveCatalogRange(input, name, dependent.range));
15
+
16
+ if (ranges.length === 0) {
17
+ return [
18
+ {
19
+ path: 'package.json',
20
+ entry: name,
21
+ problem: 'no package in bun.lock depends on the overridden package',
22
+ },
23
+ ];
24
+ }
25
+
26
+ const isPinOutsideARange = ranges.some(
27
+ (range) => !isSemverRange(range) || !Bun.semver.satisfies(pin, range),
28
+ );
29
+
30
+ const isPinTheCatalogVersion = input.catalog[name] === pin;
31
+
32
+ if (isPinOutsideARange || isPinTheCatalogVersion) {
33
+ return [];
34
+ }
35
+
36
+ return [
37
+ {
38
+ path: 'package.json',
39
+ entry: name,
40
+ problem: `every range in bun.lock already admits ${pin}, so the override changes nothing`,
41
+ },
42
+ ];
43
+ });
44
+ }
45
+
46
+ // bun.lock keeps a workspace's `catalog:` reference as it is written in its package.json.
47
+ function resolveCatalogRange(input: UnneededOverridesInput, name: string, range: string): string {
48
+ if (!range.startsWith('catalog:')) {
49
+ return range;
50
+ }
51
+
52
+ const catalogName = range.slice('catalog:'.length);
53
+ const catalog = catalogName === '' ? input.catalog : input.catalogs[catalogName];
54
+
55
+ return catalog?.[name] ?? range;
56
+ }
57
+
58
+ const comparatorPattern =
59
+ /^(?:[<>]=?|=|\^|~)?v?(?:\d+|[*Xx])(?:\.(?:\d+|[*Xx])){0,2}(?:-[\w.-]+)?(?:\+[\w.-]+)?$/u;
60
+
61
+ // Bun.semver.satisfies reads a range it cannot parse, such as a dist-tag, a `workspace:` or `npm:`
62
+ // protocol or a git URL, as any version, so only a range of plain comparators can admit the pin.
63
+ function isSemverRange(range: string): boolean {
64
+ return range.split('||').every((part) =>
65
+ part
66
+ .trim()
67
+ .split(/\s+/u)
68
+ .every((token) => token === '' || token === '-' || comparatorPattern.test(token)),
69
+ );
70
+ }
@@ -0,0 +1 @@
1
+ export const firstPartyScopes = ['@zgeoff/', '@impz/'] as const;
@@ -0,0 +1,4 @@
1
+ // repo-sync delivers this folder without the @zgeoff/std package, so the folder keeps its own guard.
2
+ export function isRecord(value: unknown): value is Record<string, unknown> {
3
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
4
+ }
@@ -0,0 +1,33 @@
1
+ import { isRecord } from './is-record.ts';
2
+ import type { AuditAdvisory } from './types.ts';
3
+
4
+ export function parseAuditAdvisories(output: string): AuditAdvisory[] {
5
+ const report: unknown = JSON.parse(output);
6
+
7
+ if (!isRecord(report)) {
8
+ throw new TypeError('bun audit --json printed no object');
9
+ }
10
+
11
+ return Object.entries(report).flatMap(([packageName, advisories]) => {
12
+ if (!Array.isArray(advisories)) {
13
+ throw new TypeError(`bun audit --json lists no advisories for ${packageName}`);
14
+ }
15
+
16
+ return advisories.map((advisory: unknown) => {
17
+ if (
18
+ !isRecord(advisory) ||
19
+ typeof advisory['id'] !== 'number' ||
20
+ typeof advisory['url'] !== 'string'
21
+ ) {
22
+ throw new TypeError(
23
+ `bun audit --json has an advisory with no id or url for ${packageName}`,
24
+ );
25
+ }
26
+
27
+ return {
28
+ advisoryIDs: [String(advisory['id']), ...advisory['url'].split('/').slice(-1)],
29
+ packageName,
30
+ };
31
+ });
32
+ });
33
+ }
@@ -0,0 +1,15 @@
1
+ import { parseShellWords } from '../parse-shell-words.ts';
2
+
3
+ export function parseAuditIgnoreIDs(auditScript: string): string[] {
4
+ const words = parseShellWords(auditScript).map((word) => word.text);
5
+
6
+ return words.flatMap((word, index) => {
7
+ if (word.startsWith('--ignore=')) {
8
+ return [word.slice('--ignore='.length)];
9
+ }
10
+
11
+ const next = words[index + 1];
12
+
13
+ return word === '--ignore' && next !== undefined ? [next] : [];
14
+ });
15
+ }
@@ -0,0 +1,29 @@
1
+ import { isRecord } from './is-record.ts';
2
+
3
+ export function parseFixablePackages(output: string): string[] {
4
+ const plan: unknown = JSON.parse(output);
5
+
6
+ if (!isRecord(plan)) {
7
+ throw new TypeError('bun audit fix --json printed no object');
8
+ }
9
+
10
+ const stuck = new Set([...collectNames(plan, 'blocked'), ...collectNames(plan, 'unfixable')]);
11
+
12
+ return [...new Set(collectNames(plan, 'fixes'))].filter((name) => !stuck.has(name));
13
+ }
14
+
15
+ function collectNames(plan: Record<string, unknown>, key: string): string[] {
16
+ const list = plan[key];
17
+
18
+ if (!Array.isArray(list)) {
19
+ throw new TypeError(`bun audit fix --json has no ${key} list`);
20
+ }
21
+
22
+ return list.map((entry: unknown) => {
23
+ if (!isRecord(entry) || typeof entry['name'] !== 'string') {
24
+ throw new TypeError(`bun audit fix --json has an entry with no name in its ${key} list`);
25
+ }
26
+
27
+ return entry['name'];
28
+ });
29
+ }
@@ -0,0 +1,77 @@
1
+ import { isRecord } from './is-record.ts';
2
+ import type { LockfileDependent } from './types.ts';
3
+
4
+ // A root override reaches a workspace's own dependencies too, so the workspaces' ranges count
5
+ // beside the ranges of the registry packages.
6
+ export function parseLockfileDependents(lockfile: string): LockfileDependent[] {
7
+ const parsed: unknown = Bun.JSONC.parse(lockfile);
8
+ const workspaces = isRecord(parsed) ? parsed['workspaces'] : undefined;
9
+ const packages = isRecord(parsed) ? parsed['packages'] : undefined;
10
+
11
+ if (!isRecord(workspaces)) {
12
+ throw new TypeError('bun.lock has no workspaces object');
13
+ }
14
+
15
+ if (!isRecord(packages)) {
16
+ throw new TypeError('bun.lock has no packages object');
17
+ }
18
+
19
+ return [
20
+ ...Object.entries(workspaces).flatMap(([path, workspace]) =>
21
+ collectWorkspaceDependents(path, workspace),
22
+ ),
23
+ ...Object.values(packages).flatMap((entry) => collectPackageDependents(entry)),
24
+ ];
25
+ }
26
+
27
+ const workspaceFields = [
28
+ 'dependencies',
29
+ 'devDependencies',
30
+ 'optionalDependencies',
31
+ 'peerDependencies',
32
+ ] as const;
33
+
34
+ function collectWorkspaceDependents(path: string, workspace: unknown): LockfileDependent[] {
35
+ const dependent = path === '' ? 'the root workspace' : path;
36
+
37
+ return collectDeclaredRanges(dependent, workspace, workspaceFields);
38
+ }
39
+
40
+ function collectDeclaredRanges(
41
+ dependent: string,
42
+ metadata: unknown,
43
+ fields: readonly string[],
44
+ ): LockfileDependent[] {
45
+ if (!isRecord(metadata)) {
46
+ return [];
47
+ }
48
+
49
+ return fields.flatMap((field) => {
50
+ const ranges = metadata[field];
51
+
52
+ if (!isRecord(ranges)) {
53
+ return [];
54
+ }
55
+
56
+ return Object.entries(ranges).flatMap(([name, range]) =>
57
+ typeof range === 'string' ? [{ dependent, name, range }] : [],
58
+ );
59
+ });
60
+ }
61
+
62
+ const packageFields = ['dependencies', 'optionalDependencies', 'peerDependencies'] as const;
63
+
64
+ // A registry entry is [id, registry, metadata, integrity]; a workspace or link entry is shorter.
65
+ function collectPackageDependents(entry: unknown): LockfileDependent[] {
66
+ const [dependent, , metadata] = isList(entry) ? entry : [];
67
+
68
+ if (typeof dependent !== 'string') {
69
+ throw new TypeError('bun.lock has a package entry that does not start with its id');
70
+ }
71
+
72
+ return collectDeclaredRanges(dependent, metadata, packageFields);
73
+ }
74
+
75
+ function isList(value: unknown): value is readonly unknown[] {
76
+ return Array.isArray(value);
77
+ }