@rexezuge/tooling 0.0.0-stage → 1.0.1

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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +297 -2
  3. package/dist/eslint-boundaries.d.ts +75 -0
  4. package/dist/eslint-boundaries.d.ts.map +1 -0
  5. package/dist/eslint-boundaries.js +138 -0
  6. package/dist/eslint-boundaries.js.map +1 -0
  7. package/dist/eslint-rules.d.ts +41 -0
  8. package/dist/eslint-rules.d.ts.map +1 -0
  9. package/dist/eslint-rules.js +273 -0
  10. package/dist/eslint-rules.js.map +1 -0
  11. package/dist/eslint.d.ts +96 -0
  12. package/dist/eslint.d.ts.map +1 -0
  13. package/dist/eslint.js +189 -0
  14. package/dist/eslint.js.map +1 -0
  15. package/dist/functions/pages-proxy.d.ts +113 -0
  16. package/dist/functions/pages-proxy.d.ts.map +1 -0
  17. package/dist/functions/pages-proxy.js +131 -0
  18. package/dist/functions/pages-proxy.js.map +1 -0
  19. package/dist/index.d.ts +31 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +28 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/scripts/backup/d1-target.d.ts +35 -0
  24. package/dist/scripts/backup/d1-target.d.ts.map +1 -0
  25. package/dist/scripts/backup/d1-target.js +31 -0
  26. package/dist/scripts/backup/d1-target.js.map +1 -0
  27. package/dist/scripts/backup/destination-config.d.ts +61 -0
  28. package/dist/scripts/backup/destination-config.d.ts.map +1 -0
  29. package/dist/scripts/backup/destination-config.js +57 -0
  30. package/dist/scripts/backup/destination-config.js.map +1 -0
  31. package/dist/scripts/backup/encrypt-backup.d.ts +35 -0
  32. package/dist/scripts/backup/encrypt-backup.d.ts.map +1 -0
  33. package/dist/scripts/backup/encrypt-backup.js +98 -0
  34. package/dist/scripts/backup/encrypt-backup.js.map +1 -0
  35. package/dist/scripts/backup/naming.d.ts +44 -0
  36. package/dist/scripts/backup/naming.d.ts.map +1 -0
  37. package/dist/scripts/backup/naming.js +58 -0
  38. package/dist/scripts/backup/naming.js.map +1 -0
  39. package/dist/scripts/check-god-files.d.ts +164 -0
  40. package/dist/scripts/check-god-files.d.ts.map +1 -0
  41. package/dist/scripts/check-god-files.js +271 -0
  42. package/dist/scripts/check-god-files.js.map +1 -0
  43. package/dist/scripts/ensure-spa-shell-stub.d.ts +3 -0
  44. package/dist/scripts/ensure-spa-shell-stub.d.ts.map +1 -0
  45. package/dist/scripts/ensure-spa-shell-stub.js +30 -0
  46. package/dist/scripts/ensure-spa-shell-stub.js.map +1 -0
  47. package/dist/scripts/init-secrets.d.ts +63 -0
  48. package/dist/scripts/init-secrets.d.ts.map +1 -0
  49. package/dist/scripts/init-secrets.js +240 -0
  50. package/dist/scripts/init-secrets.js.map +1 -0
  51. package/dist/scripts/lib/cli-args.d.ts +78 -0
  52. package/dist/scripts/lib/cli-args.d.ts.map +1 -0
  53. package/dist/scripts/lib/cli-args.js +116 -0
  54. package/dist/scripts/lib/cli-args.js.map +1 -0
  55. package/dist/scripts/lib/github-actions.d.ts +26 -0
  56. package/dist/scripts/lib/github-actions.d.ts.map +1 -0
  57. package/dist/scripts/lib/github-actions.js +38 -0
  58. package/dist/scripts/lib/github-actions.js.map +1 -0
  59. package/dist/scripts/lib/wrangler-table.d.ts +46 -0
  60. package/dist/scripts/lib/wrangler-table.d.ts.map +1 -0
  61. package/dist/scripts/lib/wrangler-table.js +99 -0
  62. package/dist/scripts/lib/wrangler-table.js.map +1 -0
  63. package/dist/scripts/migrations-lock.d.ts +3 -0
  64. package/dist/scripts/migrations-lock.d.ts.map +1 -0
  65. package/dist/scripts/migrations-lock.js +46 -0
  66. package/dist/scripts/migrations-lock.js.map +1 -0
  67. package/dist/scripts/prepare-wrangler-config.d.ts +3 -0
  68. package/dist/scripts/prepare-wrangler-config.d.ts.map +1 -0
  69. package/dist/scripts/prepare-wrangler-config.js +50 -0
  70. package/dist/scripts/prepare-wrangler-config.js.map +1 -0
  71. package/dist/scripts/spa-shell.d.ts +41 -0
  72. package/dist/scripts/spa-shell.d.ts.map +1 -0
  73. package/dist/scripts/spa-shell.js +155 -0
  74. package/dist/scripts/spa-shell.js.map +1 -0
  75. package/dist/scripts/validate-locales.d.ts +26 -0
  76. package/dist/scripts/validate-locales.d.ts.map +1 -0
  77. package/dist/scripts/validate-locales.js +350 -0
  78. package/dist/scripts/validate-locales.js.map +1 -0
  79. package/dist/scripts/verify-migrations.d.ts +62 -0
  80. package/dist/scripts/verify-migrations.d.ts.map +1 -0
  81. package/dist/scripts/verify-migrations.js +302 -0
  82. package/dist/scripts/verify-migrations.js.map +1 -0
  83. package/dist/scripts/verify-spa-shell.d.ts +3 -0
  84. package/dist/scripts/verify-spa-shell.d.ts.map +1 -0
  85. package/dist/scripts/verify-spa-shell.js +53 -0
  86. package/dist/scripts/verify-spa-shell.js.map +1 -0
  87. package/dist/scripts/wrangler-config/cli.d.ts +22 -0
  88. package/dist/scripts/wrangler-config/cli.d.ts.map +1 -0
  89. package/dist/scripts/wrangler-config/cli.js +51 -0
  90. package/dist/scripts/wrangler-config/cli.js.map +1 -0
  91. package/dist/scripts/wrangler-config/patches.d.ts +51 -0
  92. package/dist/scripts/wrangler-config/patches.d.ts.map +1 -0
  93. package/dist/scripts/wrangler-config/patches.js +140 -0
  94. package/dist/scripts/wrangler-config/patches.js.map +1 -0
  95. package/dist/scripts/wrangler-config/resources.d.ts +70 -0
  96. package/dist/scripts/wrangler-config/resources.d.ts.map +1 -0
  97. package/dist/scripts/wrangler-config/resources.js +290 -0
  98. package/dist/scripts/wrangler-config/resources.js.map +1 -0
  99. package/dist/scripts/wrangler-config/types.d.ts +103 -0
  100. package/dist/scripts/wrangler-config/types.d.ts.map +1 -0
  101. package/dist/scripts/wrangler-config/types.js +49 -0
  102. package/dist/scripts/wrangler-config/types.js.map +1 -0
  103. package/dist/test/integration-migrations.d.ts +167 -0
  104. package/dist/test/integration-migrations.d.ts.map +1 -0
  105. package/dist/test/integration-migrations.js +171 -0
  106. package/dist/test/integration-migrations.js.map +1 -0
  107. package/dist/test/mocks/cloudflare-workers.d.ts +106 -0
  108. package/dist/test/mocks/cloudflare-workers.d.ts.map +1 -0
  109. package/dist/test/mocks/cloudflare-workers.js +90 -0
  110. package/dist/test/mocks/cloudflare-workers.js.map +1 -0
  111. package/dist/vite.d.ts +117 -0
  112. package/dist/vite.d.ts.map +1 -0
  113. package/dist/vite.js +125 -0
  114. package/dist/vite.js.map +1 -0
  115. package/dist/vitest-web.d.ts +73 -0
  116. package/dist/vitest-web.d.ts.map +1 -0
  117. package/dist/vitest-web.js +72 -0
  118. package/dist/vitest-web.js.map +1 -0
  119. package/dist/vitest.d.ts +92 -0
  120. package/dist/vitest.d.ts.map +1 -0
  121. package/dist/vitest.js +128 -0
  122. package/dist/vitest.js.map +1 -0
  123. package/package.json +58 -3
  124. package/src/eslint-boundaries.ts +169 -0
  125. package/src/eslint-rules.ts +276 -0
  126. package/src/eslint.test.ts +175 -0
  127. package/src/eslint.ts +257 -0
  128. package/src/functions/pages-proxy.test.ts +72 -0
  129. package/src/functions/pages-proxy.ts +187 -0
  130. package/src/github/actions/retry-step/action.yml +39 -0
  131. package/src/github/actions/setup-env/action.yml +20 -0
  132. package/src/github/dependabot.yml +30 -0
  133. package/src/github/workflows/backup-main.yml +46 -0
  134. package/src/github/workflows/continuous-deployment.yml +188 -0
  135. package/src/github/workflows/continuous-integration.yml +259 -0
  136. package/src/github/workflows/scheduled-version-update.yml +38 -0
  137. package/src/github/workflows/upstream-sync.yml +56 -0
  138. package/src/index.ts +42 -0
  139. package/src/scripts/backup/backup-rules.test.ts +105 -0
  140. package/src/scripts/backup/d1-target.ts +54 -0
  141. package/src/scripts/backup/destination-config.ts +92 -0
  142. package/src/scripts/backup/encrypt-backup.ts +107 -0
  143. package/src/scripts/backup/naming.ts +62 -0
  144. package/src/scripts/check-god-files.test.ts +131 -0
  145. package/src/scripts/check-god-files.ts +327 -0
  146. package/src/scripts/ensure-spa-shell-stub.ts +34 -0
  147. package/src/scripts/init-secrets.ts +265 -0
  148. package/src/scripts/lib/cli-args.ts +154 -0
  149. package/src/scripts/lib/github-actions.ts +41 -0
  150. package/src/scripts/lib/wrangler-table.ts +105 -0
  151. package/src/scripts/migrations-lock.ts +52 -0
  152. package/src/scripts/prepare-wrangler-config.ts +51 -0
  153. package/src/scripts/spa-shell.test.ts +91 -0
  154. package/src/scripts/spa-shell.ts +179 -0
  155. package/src/scripts/validate-locales.test.ts +89 -0
  156. package/src/scripts/validate-locales.ts +380 -0
  157. package/src/scripts/verify-migrations.test.ts +71 -0
  158. package/src/scripts/verify-migrations.ts +364 -0
  159. package/src/scripts/verify-spa-shell.ts +56 -0
  160. package/src/scripts/wrangler-config/cli.ts +51 -0
  161. package/src/scripts/wrangler-config/patches.ts +157 -0
  162. package/src/scripts/wrangler-config/resources.ts +330 -0
  163. package/src/scripts/wrangler-config/types.ts +113 -0
  164. package/src/test/integration-migrations.test.ts +169 -0
  165. package/src/test/integration-migrations.ts +267 -0
  166. package/src/test/mocks/cloudflare-workers.ts +115 -0
  167. package/src/vite.test.ts +83 -0
  168. package/src/vite.ts +202 -0
  169. package/src/vitest-web.ts +109 -0
  170. package/src/vitest.ts +185 -0
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Compress and encrypt the exported D1 dump.
4
+ *
5
+ * Provenance: Edge-Sonic's `scripts/backup/encrypt-backup.ts`, converged with the
6
+ * AES-GCM envelope every repo in the family already uses for its secrets. This is
7
+ * the one backup script that ported cleanly and changed *for the better* on the way
8
+ * in: the source shelled out to `openssl enc -aes-256-cbc -pbkdf2`, which is a
9
+ * password-derived key with a KDF whose cost is a constant somebody chose once,
10
+ * and which is not authenticated — a flipped byte in the ciphertext is undetectable.
11
+ *
12
+ * The kit's version uses `@rexezuge/d1`'s `encryptSecret`, the same WebCrypto
13
+ * AES-256-GCM primitive the workers use, over a base64 32-byte key — the same key
14
+ * format `resolveReplicationKey` reads. One definition of "what an encryption key
15
+ * looks like" across the deploy path and the backup path is the point: a second
16
+ * implementation is how a deploy ends up generating something the worker cannot
17
+ * read, and here it would be how a backup ends up unreadable by the restore path.
18
+ *
19
+ * Fail-closed: without `BACKUP_ENCRYPTION_KEY` the script refuses to run and
20
+ * deletes nothing, so an unencrypted dump can never reach the artifact store the
21
+ * upload jobs read from.
22
+ *
23
+ * The passphrase is read from the environment rather than passed as an argument,
24
+ * because command-line arguments are visible to any process on the runner via `ps`.
25
+ *
26
+ * Emits the resulting file name as the `file` step output.
27
+ *
28
+ * Usage, from the repo root (after `wrangler d1 export … --output=backup.sql`):
29
+ *
30
+ * ```bash
31
+ * BACKUP_ENCRYPTION_KEY=… pnpm exec tsx scripts/backup/encrypt-backup.ts
32
+ * ```
33
+ */
34
+ import { encryptSecret } from '@rexezuge/d1';
35
+ import { execFileSync } from 'node:child_process';
36
+ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
37
+ import { basename } from 'node:path';
38
+ import { fail, setOutput } from '../lib/github-actions';
39
+ import { backupFileName } from './naming';
40
+ /** The dump the export step leaves in the working directory. */
41
+ const SOURCE_FILE = 'backup.sql';
42
+ /**
43
+ * xz preset 6, chosen for ratio over speed: this runs once a day against a dump
44
+ * that is mostly SQL text and JSON blobs, and LZMA typically beats gzip by 20-30%
45
+ * there. `-T0` uses every available thread, which matters because xz is markedly
46
+ * slower than gzip and the job is wall-clock bound.
47
+ */
48
+ const XZ_ARGS = ['-T0', '-6'];
49
+ function run(command, args) {
50
+ execFileSync(command, [...args], { stdio: ['ignore', 'inherit', 'inherit'] });
51
+ }
52
+ if (!process.env['BACKUP_ENCRYPTION_KEY']) {
53
+ fail('BACKUP_ENCRYPTION_KEY is not set. Refusing to export an unencrypted database.');
54
+ }
55
+ if (!existsSync(SOURCE_FILE)) {
56
+ fail(`Expected ${SOURCE_FILE} from the export step, but it does not exist.`);
57
+ }
58
+ const key = process.env['BACKUP_ENCRYPTION_KEY'];
59
+ const file = backupFileName(new Date());
60
+ let plaintext;
61
+ try {
62
+ plaintext = readFileSync(SOURCE_FILE, 'utf8');
63
+ }
64
+ catch (error) {
65
+ fail(`Could not read ${SOURCE_FILE}: ${error instanceof Error ? error.message : String(error)}`);
66
+ }
67
+ if (plaintext === '') {
68
+ fail(`${SOURCE_FILE} is empty. An empty dump is not a backup, and uploading one is a false sense of safety.`);
69
+ }
70
+ /**
71
+ * Compress first, then encrypt.
72
+ *
73
+ * The order matters: compression over ciphertext buys nothing, and encrypting the
74
+ * compressed bytes means the plaintext is never re-materialised on disk at full
75
+ * size. Unlike gzip, xz leaves its input in place, so the plaintext SQL has to be
76
+ * removed explicitly here or it survives into the artifact store.
77
+ */
78
+ let compressed;
79
+ try {
80
+ compressed = execFileSync('xz', [...XZ_ARGS, '--keep', '--stdout', SOURCE_FILE], { maxBuffer: 512 * 1024 * 1024 });
81
+ }
82
+ catch (error) {
83
+ fail(`xz failed on ${SOURCE_FILE}: ${error instanceof Error ? error.message : String(error)}`);
84
+ }
85
+ let envelope;
86
+ try {
87
+ envelope = await encryptSecret(compressed.toString('base64'), key);
88
+ }
89
+ catch (error) {
90
+ // A key that is not 32 base64 bytes, or not valid base64 at all, fails here
91
+ // rather than producing a file nothing can open.
92
+ fail(`Could not encrypt the dump: ${error instanceof Error ? error.message : String(error)}`);
93
+ }
94
+ writeFileSync(file, `${JSON.stringify({ ...envelope, source: basename(SOURCE_FILE) }, null, 2)}\n`, 'utf8');
95
+ rmSync(SOURCE_FILE, { force: true });
96
+ console.log(`Backup encrypted: ${file} (${compressed.byteLength} compressed bytes)`);
97
+ setOutput('file', file);
98
+ //# sourceMappingURL=encrypt-backup.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"encrypt-backup.js","sourceRoot":"","sources":["../../../src/scripts/backup/encrypt-backup.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Backup artifact naming and retention-window handling.
3
+ *
4
+ * Provenance: Edge-Sonic's `scripts/backup/naming.ts` and `retention.ts`,
5
+ * converged into one module because the two answer the same question from either
6
+ * side — what a file is called, and which of them are still worth keeping — and a
7
+ * consumer only ever imports one.
8
+ */
9
+ /**
10
+ * The filename prefix.
11
+ *
12
+ * Exported rather than inlined, because the workflow uploads with
13
+ * `<prefix>_*.sql.xz.enc` and `if-no-files-found: error`. That glob and this prefix
14
+ * are one fact written in two places — the failure mode being a rename here that
15
+ * silently stops every upload while each upload job still reports success, since
16
+ * the upload jobs skip on `needs` rather than failing.
17
+ */
18
+ export declare const BACKUP_FILE_PREFIX = "backup_prod_";
19
+ /**
20
+ * `2026-10-01_04-15-00` — sortable, filename-safe, and UTC.
21
+ *
22
+ * UTC rather than local: a retention window computed across a DST boundary is one
23
+ * day wide in one direction, and a backup pruned a day early is a backup that was
24
+ * never needed.
25
+ */
26
+ export declare function backupStamp(date: Date): string;
27
+ /** The full artifact name: `<prefix><stamp>.sql.xz.enc`. */
28
+ export declare function backupFileName(date: Date): string;
29
+ /**
30
+ * `YYYY-MM-DD`, `days` before `now`.
31
+ */
32
+ export declare function retentionCutoff(now: Date, days: number): string;
33
+ /**
34
+ * Read `BACKUP_RETENTION_DAYS` from the environment, rejecting bad values.
35
+ *
36
+ * Rejected rather than defaulted: silently falling back would either prune
37
+ * everything or keep backups forever, and both are worse than a failed run that
38
+ * names the problem.
39
+ *
40
+ * `Number()` rather than `parseInt()`: `parseInt('30d')` yields 30, which would
41
+ * silently accept a mistyped value and prune against the wrong window.
42
+ */
43
+ export declare function requireRetentionDays(): number;
44
+ //# sourceMappingURL=naming.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"naming.d.ts","sourceRoot":"","sources":["../../../src/scripts/backup/naming.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,iBAAiB,CAAC;AAEjD;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,CAG9C;AAED,4DAA4D;AAC5D,wBAAgB,cAAc,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,CAEjD;AAED;;GAEG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAE/D;AAED;;;;;;;;;GASG;AACH,wBAAgB,oBAAoB,IAAI,MAAM,CAO7C"}
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Backup artifact naming and retention-window handling.
3
+ *
4
+ * Provenance: Edge-Sonic's `scripts/backup/naming.ts` and `retention.ts`,
5
+ * converged into one module because the two answer the same question from either
6
+ * side — what a file is called, and which of them are still worth keeping — and a
7
+ * consumer only ever imports one.
8
+ */
9
+ /**
10
+ * The filename prefix.
11
+ *
12
+ * Exported rather than inlined, because the workflow uploads with
13
+ * `<prefix>_*.sql.xz.enc` and `if-no-files-found: error`. That glob and this prefix
14
+ * are one fact written in two places — the failure mode being a rename here that
15
+ * silently stops every upload while each upload job still reports success, since
16
+ * the upload jobs skip on `needs` rather than failing.
17
+ */
18
+ export const BACKUP_FILE_PREFIX = 'backup_prod_';
19
+ /**
20
+ * `2026-10-01_04-15-00` — sortable, filename-safe, and UTC.
21
+ *
22
+ * UTC rather than local: a retention window computed across a DST boundary is one
23
+ * day wide in one direction, and a backup pruned a day early is a backup that was
24
+ * never needed.
25
+ */
26
+ export function backupStamp(date) {
27
+ const iso = date.toISOString();
28
+ return `${iso.slice(0, 10)}_${iso.slice(11, 19).replaceAll(':', '-')}`;
29
+ }
30
+ /** The full artifact name: `<prefix><stamp>.sql.xz.enc`. */
31
+ export function backupFileName(date) {
32
+ return `${BACKUP_FILE_PREFIX}${backupStamp(date)}.sql.xz.enc`;
33
+ }
34
+ /**
35
+ * `YYYY-MM-DD`, `days` before `now`.
36
+ */
37
+ export function retentionCutoff(now, days) {
38
+ return new Date(now.getTime() - days * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
39
+ }
40
+ /**
41
+ * Read `BACKUP_RETENTION_DAYS` from the environment, rejecting bad values.
42
+ *
43
+ * Rejected rather than defaulted: silently falling back would either prune
44
+ * everything or keep backups forever, and both are worse than a failed run that
45
+ * names the problem.
46
+ *
47
+ * `Number()` rather than `parseInt()`: `parseInt('30d')` yields 30, which would
48
+ * silently accept a mistyped value and prune against the wrong window.
49
+ */
50
+ export function requireRetentionDays() {
51
+ const raw = process.env['BACKUP_RETENTION_DAYS'];
52
+ const days = Number(raw);
53
+ if (!Number.isFinite(days) || days <= 0) {
54
+ throw new Error(`BACKUP_RETENTION_DAYS must be a positive number, got ${JSON.stringify(raw)}.`);
55
+ }
56
+ return days;
57
+ }
58
+ //# sourceMappingURL=naming.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"naming.js","sourceRoot":"","sources":["../../../src/scripts/backup/naming.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,cAAc,CAAC;AAEjD;;;;;;GAMG;AACH,MAAM,UAAU,WAAW,CAAC,IAAU;IACpC,MAAM,GAAG,GAAG,IAAI,CAAC,WAAW,EAAE,CAAC;IAC/B,OAAO,GAAG,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,GAAG,CAAC,KAAK,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,UAAU,CAAC,GAAG,EAAE,GAAG,CAAC,EAAE,CAAC;AACzE,CAAC;AAED,4DAA4D;AAC5D,MAAM,UAAU,cAAc,CAAC,IAAU;IACvC,OAAO,GAAG,kBAAkB,GAAG,WAAW,CAAC,IAAI,CAAC,aAAa,CAAC;AAChE,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,eAAe,CAAC,GAAS,EAAE,IAAY;IACrD,OAAO,IAAI,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,IAAI,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC,CAAC,WAAW,EAAE,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;AACzF,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,oBAAoB;IAClC,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,uBAAuB,CAAC,CAAC;IACjD,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC;IACzB,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,EAAE,CAAC;QACxC,MAAM,IAAI,KAAK,CAAC,wDAAwD,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IAClG,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC"}
@@ -0,0 +1,164 @@
1
+ /**
2
+ * The rules behind `scripts/check-god-files.ts`.
3
+ *
4
+ * Provenance: converged from the seven `.mjs` copies (AWS-AccessBridge,
5
+ * CalDAV-Bridge, ChordDHT-Tracker, Durable-DAV, Durable-DAV-Router, Edge-Git,
6
+ * Mail-Meow, Mail-Otter) and the typed ratchet in Edge-Sonic.
7
+ *
8
+ * All seven `.mjs` copies are one script with two constants: soft 300 (warn),
9
+ * hard 400 (error). The typed variant is not a different rule so much as a
10
+ * different answer to "what does the gate enforce", and that is the decision this
11
+ * module has to take once for the whole family.
12
+ *
13
+ * ### The convergence: warn-only, with an allowlist
14
+ *
15
+ * The two source designs:
16
+ *
17
+ * - **Ceiling** (six repos) — 300 warn, 400 error, `exit 1` on 400. Durable-DAV's
18
+ * own `AGENTS.md` records what that measures: **ten** files sit just over 300
19
+ * and none is a god file, so `WARN` reports every run and `HARD` has never
20
+ * fired. A ceiling set above the largest file in the tree cannot tell a
21
+ * repository getting worse from one that never got better, and raising it is
22
+ * always available.
23
+ * - **Ratchet** (Edge-Sonic) — a committed baseline records every file's size; a
24
+ * file may shrink freely and grow only to its recorded size. It cannot be
25
+ * satisfied by raising a number, which is its whole strength.
26
+ *
27
+ * The ratchet is the better *rule*, and the ceiling is the better *gate*: the
28
+ * ratchet needs a baseline maintained per file and fails the first time somebody
29
+ * writes a legitimate 320-line module, which is the failure mode that trains
30
+ * everyone to ignore a report. So the converged script keeps the 300/400
31
+ * thresholds — every repo's CI already prints them — and makes the verdict
32
+ * **warn-only by default**, with two escape valves that are both visible in a
33
+ * diff:
34
+ *
35
+ * 1. `--fail-on-hard` turns the 400 line back into a failing gate, for a repo
36
+ * that has paid its debt and wants the ceiling enforced.
37
+ * 2. An **allowlist** records the files that are over the line and known, so the
38
+ * report names only the ones that are *new*. That is the ratchet's
39
+ * "recorded size" idea without its per-file bookkeeping: the allowlist is
40
+ * reviewed by the same commit that grows the file, because the diff shows what
41
+ * was added to it.
42
+ *
43
+ * Tests are excluded, as every source does. A double modelling the platform grows
44
+ * with the platform's surface, not with the complexity of the code it stands in
45
+ * for — Edge-Sonic's ratchet holds them too, and its own notes record a 4,343-line
46
+ * fixture, so this is a documented disagreement rather than an oversight.
47
+ */
48
+ /**
49
+ * A file past a size the guard reports on.
50
+ */
51
+ export interface GodFileFinding {
52
+ /** Path relative to the root the check ran against. */
53
+ readonly file: string;
54
+ /** Lines it has. */
55
+ readonly lines: number;
56
+ /** `warn` past the soft limit, `critical` past the hard one. */
57
+ readonly level: 'warn' | 'critical';
58
+ }
59
+ /**
60
+ * The outcome of one run.
61
+ */
62
+ export interface GodFileReport {
63
+ /** Files over a limit, worst first, minus the allowlisted. */
64
+ readonly findings: readonly GodFileFinding[];
65
+ /** How many files the walk measured. */
66
+ readonly scanned: number;
67
+ /** Allowlisted files that are over a limit — reported, never counted. */
68
+ readonly allowlisted: readonly GodFileFinding[];
69
+ /**
70
+ * Whether the tree may be committed as it stands.
71
+ *
72
+ * `true` in warn-only mode, which is the default; a `critical` finding fails
73
+ * only when the caller asked for the gate.
74
+ */
75
+ readonly passed: boolean;
76
+ /** The message to print, either way. */
77
+ readonly reported: string;
78
+ }
79
+ /**
80
+ * What `checkGodFiles` accepts.
81
+ */
82
+ export interface GodFileOptions {
83
+ /** Directory to walk. */
84
+ readonly root: string;
85
+ /**
86
+ * Repo-relative paths (or directory prefixes ending in `/`) exempt from the
87
+ * report. Defaults to `god-files.allowlist.json` beside the caller's script
88
+ * when that file exists — see `readAllowlist`.
89
+ */
90
+ readonly allowlist?: readonly string[];
91
+ /** Soft limit, past which a file is reported as `warn`. */
92
+ readonly soft?: number;
93
+ /** Hard limit, past which a file is reported as `critical`. */
94
+ readonly hard?: number;
95
+ /** Make a `critical` finding fail the run. */
96
+ readonly failOnHard?: boolean;
97
+ }
98
+ /** Default soft limit, as every source repo uses. */
99
+ export declare const SOFT_LIMIT = 300;
100
+ /** Default hard limit, as every source repo uses. */
101
+ export declare const HARD_LIMIT = 400;
102
+ /**
103
+ * Whether a repo-relative path is exempt from the guard.
104
+ *
105
+ * Provenance: AWS-AccessBridge's version is the canonical one, because it
106
+ * documents and fixes two live bugs in the others.
107
+ *
108
+ * Every directory pattern is anchored with `(?:^|/)` rather than a bare leading
109
+ * `/`. `relative()` yields `test/helpers/x.ts` for a top-level test directory and
110
+ * `scripts/lib/x.ts` for the scripts directory — **no leading separator** — so a
111
+ * pattern written as `/\/(?:test|tests|__tests__)\//` never matches either one.
112
+ * It looks right, it does match when handed an absolute path, and against these
113
+ * layouts it is silently a no-op. Durable-DAV and ChordDHT-Tracker ship exactly
114
+ * that dead pattern.
115
+ */
116
+ export declare function shouldSkip(rel: string): boolean;
117
+ /**
118
+ * Whether a repo-relative path is on the allowlist.
119
+ *
120
+ * An entry ending in `/` exempts a whole directory; any other entry is matched
121
+ * exactly, so `apps/api/src/index.ts` does not exempt `apps/api/src/index.util.ts`.
122
+ */
123
+ export declare function isAllowlisted(rel: string, allowlist: readonly string[]): boolean;
124
+ /**
125
+ * Every source file under `root`, as repo-relative paths.
126
+ *
127
+ * Symlink-safe: a dangling symlink or a file removed mid-walk is skipped rather
128
+ * than thrown, because neither is a god-file problem.
129
+ */
130
+ export declare function walk(root: string): string[];
131
+ /**
132
+ * Run the guard over a tree.
133
+ *
134
+ * Pure with respect to the verdict — the only I/O is the walk and a line count —
135
+ * so a test drives it against a temporary directory.
136
+ */
137
+ export declare function checkGodFiles(options: GodFileOptions): GodFileReport;
138
+ /**
139
+ * Read an allowlist file, or `[]` when it is absent.
140
+ *
141
+ * The file is `scripts/god-files.allowlist.json`: a JSON array of repo-relative
142
+ * paths. Absence is the normal case for a repo with no known offenders, so it is
143
+ * not an error — a guard that fails on a first run nobody has filled in yet is a
144
+ * guard that gets disabled.
145
+ *
146
+ * A malformed file is an error rather than an empty list, because "the allowlist
147
+ * is empty" silently un-reports every known offender.
148
+ */
149
+ export declare function readAllowlist(path: string): string[];
150
+ /**
151
+ * CLI entry: `checkGodFilesCli(['.', '--fail-on-hard'])`.
152
+ *
153
+ * The directory is a positional argument **or** `--root <dir>`, because every other
154
+ * script in this tree takes its target positionally and a gate that has to be
155
+ * invoked one way in one script and another way in the next is a gate somebody
156
+ * invokes wrong. A second positional is an error.
157
+ *
158
+ * Flags (see FlagSpec): `--root <dir>` (default `.`), `--allowlist <file>`
159
+ * (default `scripts/god-files.allowlist.json` beside the root, absent = none),
160
+ * `--fail-on-hard` (default off, matching every source repo's warn-only gate).
161
+ * Returns 0 unless `--fail-on-hard` is set and a critical file is found.
162
+ */
163
+ export declare function checkGodFilesCli(argv?: readonly string[]): number;
164
+ //# sourceMappingURL=check-god-files.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"check-god-files.d.ts","sourceRoot":"","sources":["../../src/scripts/check-god-files.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8CG;AAQH;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B,uDAAuD;IACvD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,oBAAoB;IACpB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,gEAAgE;IAChE,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,UAAU,CAAC;CACrC;AAED;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,8DAA8D;IAC9D,QAAQ,CAAC,QAAQ,EAAE,SAAS,cAAc,EAAE,CAAC;IAC7C,wCAAwC;IACxC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,yEAAyE;IACzE,QAAQ,CAAC,WAAW,EAAE,SAAS,cAAc,EAAE,CAAC;IAChD;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB,wCAAwC;IACxC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAED;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B,yBAAyB;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;;OAIG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACvC,2DAA2D;IAC3D,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,+DAA+D;IAC/D,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,8CAA8C;IAC9C,QAAQ,CAAC,UAAU,CAAC,EAAE,OAAO,CAAC;CAC/B;AAED,qDAAqD;AACrD,eAAO,MAAM,UAAU,MAAM,CAAC;AAC9B,qDAAqD;AACrD,eAAO,MAAM,UAAU,MAAM,CAAC;AA+B9B;;;;;;;;;;;;;GAaG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAW/C;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAEhF;AAED;;;;;GAKG;AACH,wBAAgB,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAqB3C;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,cAAc,GAAG,aAAa,CA+BpE;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAiBpD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,GAAE,SAAS,MAAM,EAA0B,GAAG,MAAM,CA0BxF"}
@@ -0,0 +1,271 @@
1
+ /**
2
+ * The rules behind `scripts/check-god-files.ts`.
3
+ *
4
+ * Provenance: converged from the seven `.mjs` copies (AWS-AccessBridge,
5
+ * CalDAV-Bridge, ChordDHT-Tracker, Durable-DAV, Durable-DAV-Router, Edge-Git,
6
+ * Mail-Meow, Mail-Otter) and the typed ratchet in Edge-Sonic.
7
+ *
8
+ * All seven `.mjs` copies are one script with two constants: soft 300 (warn),
9
+ * hard 400 (error). The typed variant is not a different rule so much as a
10
+ * different answer to "what does the gate enforce", and that is the decision this
11
+ * module has to take once for the whole family.
12
+ *
13
+ * ### The convergence: warn-only, with an allowlist
14
+ *
15
+ * The two source designs:
16
+ *
17
+ * - **Ceiling** (six repos) — 300 warn, 400 error, `exit 1` on 400. Durable-DAV's
18
+ * own `AGENTS.md` records what that measures: **ten** files sit just over 300
19
+ * and none is a god file, so `WARN` reports every run and `HARD` has never
20
+ * fired. A ceiling set above the largest file in the tree cannot tell a
21
+ * repository getting worse from one that never got better, and raising it is
22
+ * always available.
23
+ * - **Ratchet** (Edge-Sonic) — a committed baseline records every file's size; a
24
+ * file may shrink freely and grow only to its recorded size. It cannot be
25
+ * satisfied by raising a number, which is its whole strength.
26
+ *
27
+ * The ratchet is the better *rule*, and the ceiling is the better *gate*: the
28
+ * ratchet needs a baseline maintained per file and fails the first time somebody
29
+ * writes a legitimate 320-line module, which is the failure mode that trains
30
+ * everyone to ignore a report. So the converged script keeps the 300/400
31
+ * thresholds — every repo's CI already prints them — and makes the verdict
32
+ * **warn-only by default**, with two escape valves that are both visible in a
33
+ * diff:
34
+ *
35
+ * 1. `--fail-on-hard` turns the 400 line back into a failing gate, for a repo
36
+ * that has paid its debt and wants the ceiling enforced.
37
+ * 2. An **allowlist** records the files that are over the line and known, so the
38
+ * report names only the ones that are *new*. That is the ratchet's
39
+ * "recorded size" idea without its per-file bookkeeping: the allowlist is
40
+ * reviewed by the same commit that grows the file, because the diff shows what
41
+ * was added to it.
42
+ *
43
+ * Tests are excluded, as every source does. A double modelling the platform grows
44
+ * with the platform's surface, not with the complexity of the code it stands in
45
+ * for — Edge-Sonic's ratchet holds them too, and its own notes record a 4,343-line
46
+ * fixture, so this is a documented disagreement rather than an oversight.
47
+ */
48
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
49
+ import { pathToFileURL } from 'node:url';
50
+ import { join, relative } from 'node:path';
51
+ import { fail } from './lib/github-actions';
52
+ import { isSet, parseFlags, splitPositional, valueOf } from './lib/cli-args';
53
+ /** Default soft limit, as every source repo uses. */
54
+ export const SOFT_LIMIT = 300;
55
+ /** Default hard limit, as every source repo uses. */
56
+ export const HARD_LIMIT = 400;
57
+ /**
58
+ * Directories the walker never enters.
59
+ *
60
+ * Build output and VCS state; `coverage` is here because a generated HTML report
61
+ * is thousands of "lines" of nothing.
62
+ */
63
+ const EXCLUDE_DIRS = new Set(['node_modules', 'dist', '.wrangler', 'coverage', 'coverage-integration', 'coverage-web', '.git']);
64
+ /**
65
+ * Source extensions the guard counts.
66
+ *
67
+ * `css` is in the set because every source repo counts it, and a 400-line
68
+ * stylesheet is the same reading problem as a 400-line module.
69
+ */
70
+ const SOURCE_EXTENSIONS = /\.(?:ts|tsx|js|mjs|cjs|css)$/;
71
+ /**
72
+ * The test-file suffixes the guard skips.
73
+ *
74
+ * `.test.tsx` matter as much as `.test.ts`: the walker collects both, so a list
75
+ * that only named the `.ts` forms let every React test file through the guard and
76
+ * reported them as god files — which is how Durable-DAV-Router discovered it.
77
+ * Built from the kinds and extensions rather than enumerated, so a new test
78
+ * extension cannot silently escape.
79
+ */
80
+ const TEST_SUFFIXES = ['test', 'spec'].flatMap((kind) => ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].map((ext) => `.${kind}${ext}`));
81
+ /**
82
+ * Whether a repo-relative path is exempt from the guard.
83
+ *
84
+ * Provenance: AWS-AccessBridge's version is the canonical one, because it
85
+ * documents and fixes two live bugs in the others.
86
+ *
87
+ * Every directory pattern is anchored with `(?:^|/)` rather than a bare leading
88
+ * `/`. `relative()` yields `test/helpers/x.ts` for a top-level test directory and
89
+ * `scripts/lib/x.ts` for the scripts directory — **no leading separator** — so a
90
+ * pattern written as `/\/(?:test|tests|__tests__)\//` never matches either one.
91
+ * It looks right, it does match when handed an absolute path, and against these
92
+ * layouts it is silently a no-op. Durable-DAV and ChordDHT-Tracker ship exactly
93
+ * that dead pattern.
94
+ */
95
+ export function shouldSkip(rel) {
96
+ if (/(?:^|\/)(?:locales|generated)\//.test(rel))
97
+ return true;
98
+ if (/(?:^|\/)__(?:tests|mocks)__(?:\/|$)/.test(rel))
99
+ return true;
100
+ if (/(?:^|\/)scripts\//.test(rel))
101
+ return true;
102
+ if (/(?:^|\/)(?:test|tests|__tests__)\//.test(rel))
103
+ return true;
104
+ if (/\.config\.(?:m?[jt]s|cjs)$/.test(rel))
105
+ return true;
106
+ if (rel.endsWith('.d.ts'))
107
+ return true;
108
+ if (rel.endsWith('.json'))
109
+ return true;
110
+ if (rel.endsWith('.sql'))
111
+ return true;
112
+ if (rel.endsWith('.md'))
113
+ return true;
114
+ return TEST_SUFFIXES.some((suffix) => rel.endsWith(suffix));
115
+ }
116
+ /**
117
+ * Whether a repo-relative path is on the allowlist.
118
+ *
119
+ * An entry ending in `/` exempts a whole directory; any other entry is matched
120
+ * exactly, so `apps/api/src/index.ts` does not exempt `apps/api/src/index.util.ts`.
121
+ */
122
+ export function isAllowlisted(rel, allowlist) {
123
+ return allowlist.some((entry) => (entry.endsWith('/') ? rel.startsWith(entry) : rel === entry));
124
+ }
125
+ /**
126
+ * Every source file under `root`, as repo-relative paths.
127
+ *
128
+ * Symlink-safe: a dangling symlink or a file removed mid-walk is skipped rather
129
+ * than thrown, because neither is a god-file problem.
130
+ */
131
+ export function walk(root) {
132
+ const out = [];
133
+ const visit = (dir) => {
134
+ for (const entry of readdirSync(dir)) {
135
+ const full = join(dir, entry);
136
+ let stat;
137
+ try {
138
+ stat = statSync(full);
139
+ }
140
+ catch {
141
+ continue;
142
+ }
143
+ if (stat.isDirectory()) {
144
+ if (EXCLUDE_DIRS.has(entry))
145
+ continue;
146
+ visit(full);
147
+ }
148
+ else if (SOURCE_EXTENSIONS.test(entry)) {
149
+ out.push(relative(root, full));
150
+ }
151
+ }
152
+ };
153
+ visit(root);
154
+ return out;
155
+ }
156
+ /**
157
+ * Run the guard over a tree.
158
+ *
159
+ * Pure with respect to the verdict — the only I/O is the walk and a line count —
160
+ * so a test drives it against a temporary directory.
161
+ */
162
+ export function checkGodFiles(options) {
163
+ const soft = options.soft ?? SOFT_LIMIT;
164
+ const hard = options.hard ?? HARD_LIMIT;
165
+ const allowlist = options.allowlist ?? [];
166
+ const scanned = walk(options.root).filter((rel) => !shouldSkip(rel));
167
+ const findings = [];
168
+ const allowlisted = [];
169
+ for (const rel of scanned) {
170
+ const lines = readFileSync(join(options.root, rel), 'utf8').split('\n').length;
171
+ if (lines <= soft)
172
+ continue;
173
+ const finding = { file: rel, lines, level: lines > hard ? 'critical' : 'warn' };
174
+ if (isAllowlisted(rel, allowlist)) {
175
+ allowlisted.push(finding);
176
+ }
177
+ else {
178
+ findings.push(finding);
179
+ }
180
+ }
181
+ findings.sort((left, right) => right.lines - left.lines);
182
+ allowlisted.sort((left, right) => right.lines - left.lines);
183
+ const critical = findings.filter((finding) => finding.level === 'critical');
184
+ const passed = options.failOnHard !== true || critical.length === 0;
185
+ const reported = passed
186
+ ? `God-file check passed (${scanned.length} files, ${findings.length} over soft limit ${soft}, ${allowlisted.length} allowlisted).`
187
+ : `God-file check failed: ${critical.length} file(s) exceed ${hard} LOC. Split them, or add them to the allowlist in the same commit that grows them.`;
188
+ return { findings, allowlisted, scanned: scanned.length, passed, reported };
189
+ }
190
+ /**
191
+ * Read an allowlist file, or `[]` when it is absent.
192
+ *
193
+ * The file is `scripts/god-files.allowlist.json`: a JSON array of repo-relative
194
+ * paths. Absence is the normal case for a repo with no known offenders, so it is
195
+ * not an error — a guard that fails on a first run nobody has filled in yet is a
196
+ * guard that gets disabled.
197
+ *
198
+ * A malformed file is an error rather than an empty list, because "the allowlist
199
+ * is empty" silently un-reports every known offender.
200
+ */
201
+ export function readAllowlist(path) {
202
+ let raw;
203
+ try {
204
+ raw = readFileSync(path, 'utf8');
205
+ }
206
+ catch {
207
+ return [];
208
+ }
209
+ let parsed;
210
+ try {
211
+ parsed = JSON.parse(raw);
212
+ }
213
+ catch (error) {
214
+ throw new Error(`${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
215
+ }
216
+ if (!Array.isArray(parsed) || parsed.some((entry) => typeof entry !== 'string')) {
217
+ throw new Error(`${path} must be a JSON array of repo-relative path strings.`);
218
+ }
219
+ return parsed;
220
+ }
221
+ /**
222
+ * CLI entry: `checkGodFilesCli(['.', '--fail-on-hard'])`.
223
+ *
224
+ * The directory is a positional argument **or** `--root <dir>`, because every other
225
+ * script in this tree takes its target positionally and a gate that has to be
226
+ * invoked one way in one script and another way in the next is a gate somebody
227
+ * invokes wrong. A second positional is an error.
228
+ *
229
+ * Flags (see FlagSpec): `--root <dir>` (default `.`), `--allowlist <file>`
230
+ * (default `scripts/god-files.allowlist.json` beside the root, absent = none),
231
+ * `--fail-on-hard` (default off, matching every source repo's warn-only gate).
232
+ * Returns 0 unless `--fail-on-hard` is set and a critical file is found.
233
+ */
234
+ export function checkGodFilesCli(argv = process.argv.slice(2)) {
235
+ // Split before parsing, because parseFlags rejects anything that is not a declared
236
+ // flag and a positional directory is neither a mistake nor a flag.
237
+ const { positional, rest } = splitPositional(argv, ['root', 'allowlist']);
238
+ const flags = parseFlags(rest, {
239
+ value: ['root', 'allowlist'],
240
+ boolean: ['fail-on-hard'],
241
+ });
242
+ const root = valueOf(flags, 'root') ?? positional[0] ?? '.';
243
+ const allowlistPath = valueOf(flags, 'allowlist') ?? join(root, 'scripts/god-files.allowlist.json');
244
+ const report = checkGodFiles({
245
+ root,
246
+ allowlist: readAllowlist(allowlistPath),
247
+ failOnHard: isSet(flags, 'fail-on-hard'),
248
+ });
249
+ console.log(report.reported);
250
+ for (const finding of report.findings) {
251
+ console.log(`${finding.level === 'critical' ? 'CRITICAL' : 'WARN'} ${finding.lines} ${finding.file}`);
252
+ }
253
+ for (const finding of report.allowlisted) {
254
+ console.log(`ALLOWLISTED ${finding.lines} ${finding.file}`);
255
+ }
256
+ if (!report.passed) {
257
+ fail(`God-file check failed: run with --root ${root} to see the report.`);
258
+ }
259
+ return 0;
260
+ }
261
+ // Run only when this file is the entrypoint.
262
+ //
263
+ // The rules above are pure and the tests import them directly, so a module-level
264
+ // call would shell out (and exit) during a test run. The URL comparison rather than
265
+ // an unconditional call is the same guard Durable-DAV's init-secrets.ts uses, and
266
+ // it is what keeps the rule modules importable — which is the whole reason they are
267
+ // separate modules in the first place.
268
+ if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
269
+ process.exit(checkGodFilesCli(process.argv.slice(2)));
270
+ }
271
+ //# sourceMappingURL=check-god-files.js.map