@rexezuge/tooling 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +297 -2
- package/dist/eslint.d.ts +120 -0
- package/dist/eslint.d.ts.map +1 -0
- package/dist/eslint.js +551 -0
- package/dist/eslint.js.map +1 -0
- package/dist/functions/pages-proxy.d.ts +113 -0
- package/dist/functions/pages-proxy.d.ts.map +1 -0
- package/dist/functions/pages-proxy.js +131 -0
- package/dist/functions/pages-proxy.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/scripts/backup/d1-target.d.ts +35 -0
- package/dist/scripts/backup/d1-target.d.ts.map +1 -0
- package/dist/scripts/backup/d1-target.js +31 -0
- package/dist/scripts/backup/d1-target.js.map +1 -0
- package/dist/scripts/backup/destination-config.d.ts +61 -0
- package/dist/scripts/backup/destination-config.d.ts.map +1 -0
- package/dist/scripts/backup/destination-config.js +57 -0
- package/dist/scripts/backup/destination-config.js.map +1 -0
- package/dist/scripts/backup/encrypt-backup.d.ts +35 -0
- package/dist/scripts/backup/encrypt-backup.d.ts.map +1 -0
- package/dist/scripts/backup/encrypt-backup.js +98 -0
- package/dist/scripts/backup/encrypt-backup.js.map +1 -0
- package/dist/scripts/backup/naming.d.ts +44 -0
- package/dist/scripts/backup/naming.d.ts.map +1 -0
- package/dist/scripts/backup/naming.js +58 -0
- package/dist/scripts/backup/naming.js.map +1 -0
- package/dist/scripts/check-god-files.d.ts +164 -0
- package/dist/scripts/check-god-files.d.ts.map +1 -0
- package/dist/scripts/check-god-files.js +271 -0
- package/dist/scripts/check-god-files.js.map +1 -0
- package/dist/scripts/ensure-spa-shell-stub.d.ts +3 -0
- package/dist/scripts/ensure-spa-shell-stub.d.ts.map +1 -0
- package/dist/scripts/ensure-spa-shell-stub.js +30 -0
- package/dist/scripts/ensure-spa-shell-stub.js.map +1 -0
- package/dist/scripts/init-secrets.d.ts +63 -0
- package/dist/scripts/init-secrets.d.ts.map +1 -0
- package/dist/scripts/init-secrets.js +240 -0
- package/dist/scripts/init-secrets.js.map +1 -0
- package/dist/scripts/lib/cli-args.d.ts +78 -0
- package/dist/scripts/lib/cli-args.d.ts.map +1 -0
- package/dist/scripts/lib/cli-args.js +116 -0
- package/dist/scripts/lib/cli-args.js.map +1 -0
- package/dist/scripts/lib/github-actions.d.ts +26 -0
- package/dist/scripts/lib/github-actions.d.ts.map +1 -0
- package/dist/scripts/lib/github-actions.js +38 -0
- package/dist/scripts/lib/github-actions.js.map +1 -0
- package/dist/scripts/lib/wrangler-table.d.ts +46 -0
- package/dist/scripts/lib/wrangler-table.d.ts.map +1 -0
- package/dist/scripts/lib/wrangler-table.js +99 -0
- package/dist/scripts/lib/wrangler-table.js.map +1 -0
- package/dist/scripts/migrations-lock.d.ts +3 -0
- package/dist/scripts/migrations-lock.d.ts.map +1 -0
- package/dist/scripts/migrations-lock.js +46 -0
- package/dist/scripts/migrations-lock.js.map +1 -0
- package/dist/scripts/prepare-wrangler-config.d.ts +3 -0
- package/dist/scripts/prepare-wrangler-config.d.ts.map +1 -0
- package/dist/scripts/prepare-wrangler-config.js +50 -0
- package/dist/scripts/prepare-wrangler-config.js.map +1 -0
- package/dist/scripts/spa-shell.d.ts +41 -0
- package/dist/scripts/spa-shell.d.ts.map +1 -0
- package/dist/scripts/spa-shell.js +155 -0
- package/dist/scripts/spa-shell.js.map +1 -0
- package/dist/scripts/validate-locales.d.ts +26 -0
- package/dist/scripts/validate-locales.d.ts.map +1 -0
- package/dist/scripts/validate-locales.js +350 -0
- package/dist/scripts/validate-locales.js.map +1 -0
- package/dist/scripts/verify-migrations.d.ts +62 -0
- package/dist/scripts/verify-migrations.d.ts.map +1 -0
- package/dist/scripts/verify-migrations.js +302 -0
- package/dist/scripts/verify-migrations.js.map +1 -0
- package/dist/scripts/verify-spa-shell.d.ts +3 -0
- package/dist/scripts/verify-spa-shell.d.ts.map +1 -0
- package/dist/scripts/verify-spa-shell.js +53 -0
- package/dist/scripts/verify-spa-shell.js.map +1 -0
- package/dist/scripts/wrangler-config/cli.d.ts +22 -0
- package/dist/scripts/wrangler-config/cli.d.ts.map +1 -0
- package/dist/scripts/wrangler-config/cli.js +51 -0
- package/dist/scripts/wrangler-config/cli.js.map +1 -0
- package/dist/scripts/wrangler-config/patches.d.ts +51 -0
- package/dist/scripts/wrangler-config/patches.d.ts.map +1 -0
- package/dist/scripts/wrangler-config/patches.js +140 -0
- package/dist/scripts/wrangler-config/patches.js.map +1 -0
- package/dist/scripts/wrangler-config/resources.d.ts +70 -0
- package/dist/scripts/wrangler-config/resources.d.ts.map +1 -0
- package/dist/scripts/wrangler-config/resources.js +290 -0
- package/dist/scripts/wrangler-config/resources.js.map +1 -0
- package/dist/scripts/wrangler-config/types.d.ts +103 -0
- package/dist/scripts/wrangler-config/types.d.ts.map +1 -0
- package/dist/scripts/wrangler-config/types.js +49 -0
- package/dist/scripts/wrangler-config/types.js.map +1 -0
- package/dist/test/integration-migrations.d.ts +167 -0
- package/dist/test/integration-migrations.d.ts.map +1 -0
- package/dist/test/integration-migrations.js +171 -0
- package/dist/test/integration-migrations.js.map +1 -0
- package/dist/test/mocks/cloudflare-workers.d.ts +106 -0
- package/dist/test/mocks/cloudflare-workers.d.ts.map +1 -0
- package/dist/test/mocks/cloudflare-workers.js +90 -0
- package/dist/test/mocks/cloudflare-workers.js.map +1 -0
- package/dist/vite.d.ts +117 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +125 -0
- package/dist/vite.js.map +1 -0
- package/dist/vitest-web.d.ts +73 -0
- package/dist/vitest-web.d.ts.map +1 -0
- package/dist/vitest-web.js +72 -0
- package/dist/vitest-web.js.map +1 -0
- package/dist/vitest.d.ts +92 -0
- package/dist/vitest.d.ts.map +1 -0
- package/dist/vitest.js +128 -0
- package/dist/vitest.js.map +1 -0
- package/package.json +58 -3
- package/src/eslint.test.ts +175 -0
- package/src/eslint.ts +640 -0
- package/src/functions/pages-proxy.test.ts +72 -0
- package/src/functions/pages-proxy.ts +187 -0
- package/src/github/actions/retry-step/action.yml +39 -0
- package/src/github/actions/setup-env/action.yml +20 -0
- package/src/github/dependabot.yml +30 -0
- package/src/github/workflows/backup-main.yml +46 -0
- package/src/github/workflows/continuous-deployment.yml +188 -0
- package/src/github/workflows/continuous-integration.yml +259 -0
- package/src/github/workflows/scheduled-version-update.yml +38 -0
- package/src/github/workflows/upstream-sync.yml +56 -0
- package/src/index.ts +42 -0
- package/src/scripts/backup/backup-rules.test.ts +105 -0
- package/src/scripts/backup/d1-target.ts +54 -0
- package/src/scripts/backup/destination-config.ts +92 -0
- package/src/scripts/backup/encrypt-backup.ts +107 -0
- package/src/scripts/backup/naming.ts +62 -0
- package/src/scripts/check-god-files.test.ts +131 -0
- package/src/scripts/check-god-files.ts +327 -0
- package/src/scripts/ensure-spa-shell-stub.ts +34 -0
- package/src/scripts/init-secrets.ts +265 -0
- package/src/scripts/lib/cli-args.ts +154 -0
- package/src/scripts/lib/github-actions.ts +41 -0
- package/src/scripts/lib/wrangler-table.ts +105 -0
- package/src/scripts/migrations-lock.ts +52 -0
- package/src/scripts/prepare-wrangler-config.ts +51 -0
- package/src/scripts/spa-shell.test.ts +91 -0
- package/src/scripts/spa-shell.ts +179 -0
- package/src/scripts/validate-locales.test.ts +89 -0
- package/src/scripts/validate-locales.ts +380 -0
- package/src/scripts/verify-migrations.test.ts +71 -0
- package/src/scripts/verify-migrations.ts +364 -0
- package/src/scripts/verify-spa-shell.ts +56 -0
- package/src/scripts/wrangler-config/cli.ts +51 -0
- package/src/scripts/wrangler-config/patches.ts +157 -0
- package/src/scripts/wrangler-config/resources.ts +330 -0
- package/src/scripts/wrangler-config/types.ts +113 -0
- package/src/test/integration-migrations.test.ts +169 -0
- package/src/test/integration-migrations.ts +267 -0
- package/src/test/mocks/cloudflare-workers.ts +115 -0
- package/src/vite.test.ts +83 -0
- package/src/vite.ts +202 -0
- package/src/vitest-web.ts +109 -0
- package/src/vitest.ts +185 -0
|
@@ -0,0 +1,107 @@
|
|
|
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
|
+
|
|
35
|
+
import { encryptSecret } from '@rexezuge/d1';
|
|
36
|
+
import { execFileSync } from 'node:child_process';
|
|
37
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
38
|
+
import { basename } from 'node:path';
|
|
39
|
+
import { fail, setOutput } from '../lib/github-actions';
|
|
40
|
+
import { backupFileName } from './naming';
|
|
41
|
+
|
|
42
|
+
/** The dump the export step leaves in the working directory. */
|
|
43
|
+
const SOURCE_FILE = 'backup.sql';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* xz preset 6, chosen for ratio over speed: this runs once a day against a dump
|
|
47
|
+
* that is mostly SQL text and JSON blobs, and LZMA typically beats gzip by 20-30%
|
|
48
|
+
* there. `-T0` uses every available thread, which matters because xz is markedly
|
|
49
|
+
* slower than gzip and the job is wall-clock bound.
|
|
50
|
+
*/
|
|
51
|
+
const XZ_ARGS = ['-T0', '-6'];
|
|
52
|
+
|
|
53
|
+
function run(command: string, args: readonly string[]): void {
|
|
54
|
+
execFileSync(command, [...args], { stdio: ['ignore', 'inherit', 'inherit'] });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!process.env['BACKUP_ENCRYPTION_KEY']) {
|
|
58
|
+
fail('BACKUP_ENCRYPTION_KEY is not set. Refusing to export an unencrypted database.');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!existsSync(SOURCE_FILE)) {
|
|
62
|
+
fail(`Expected ${SOURCE_FILE} from the export step, but it does not exist.`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const key = process.env['BACKUP_ENCRYPTION_KEY'] as string;
|
|
66
|
+
const file = backupFileName(new Date());
|
|
67
|
+
|
|
68
|
+
let plaintext: string;
|
|
69
|
+
try {
|
|
70
|
+
plaintext = readFileSync(SOURCE_FILE, 'utf8');
|
|
71
|
+
} catch (error: unknown) {
|
|
72
|
+
fail(`Could not read ${SOURCE_FILE}: ${error instanceof Error ? error.message : String(error)}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (plaintext === '') {
|
|
76
|
+
fail(`${SOURCE_FILE} is empty. An empty dump is not a backup, and uploading one is a false sense of safety.`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Compress first, then encrypt.
|
|
81
|
+
*
|
|
82
|
+
* The order matters: compression over ciphertext buys nothing, and encrypting the
|
|
83
|
+
* compressed bytes means the plaintext is never re-materialised on disk at full
|
|
84
|
+
* size. Unlike gzip, xz leaves its input in place, so the plaintext SQL has to be
|
|
85
|
+
* removed explicitly here or it survives into the artifact store.
|
|
86
|
+
*/
|
|
87
|
+
let compressed: Buffer;
|
|
88
|
+
try {
|
|
89
|
+
compressed = execFileSync('xz', [...XZ_ARGS, '--keep', '--stdout', SOURCE_FILE], { maxBuffer: 512 * 1024 * 1024 });
|
|
90
|
+
} catch (error: unknown) {
|
|
91
|
+
fail(`xz failed on ${SOURCE_FILE}: ${error instanceof Error ? error.message : String(error)}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let envelope: { ciphertext: string; iv: string };
|
|
95
|
+
try {
|
|
96
|
+
envelope = await encryptSecret(compressed.toString('base64'), key);
|
|
97
|
+
} catch (error: unknown) {
|
|
98
|
+
// A key that is not 32 base64 bytes, or not valid base64 at all, fails here
|
|
99
|
+
// rather than producing a file nothing can open.
|
|
100
|
+
fail(`Could not encrypt the dump: ${error instanceof Error ? error.message : String(error)}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
writeFileSync(file, `${JSON.stringify({ ...envelope, source: basename(SOURCE_FILE) }, null, 2)}\n`, 'utf8');
|
|
104
|
+
rmSync(SOURCE_FILE, { force: true });
|
|
105
|
+
|
|
106
|
+
console.log(`Backup encrypted: ${file} (${compressed.byteLength} compressed bytes)`);
|
|
107
|
+
setOutput('file', file);
|
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
/**
|
|
11
|
+
* The filename prefix.
|
|
12
|
+
*
|
|
13
|
+
* Exported rather than inlined, because the workflow uploads with
|
|
14
|
+
* `<prefix>_*.sql.xz.enc` and `if-no-files-found: error`. That glob and this prefix
|
|
15
|
+
* are one fact written in two places — the failure mode being a rename here that
|
|
16
|
+
* silently stops every upload while each upload job still reports success, since
|
|
17
|
+
* the upload jobs skip on `needs` rather than failing.
|
|
18
|
+
*/
|
|
19
|
+
export const BACKUP_FILE_PREFIX = 'backup_prod_';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `2026-10-01_04-15-00` — sortable, filename-safe, and UTC.
|
|
23
|
+
*
|
|
24
|
+
* UTC rather than local: a retention window computed across a DST boundary is one
|
|
25
|
+
* day wide in one direction, and a backup pruned a day early is a backup that was
|
|
26
|
+
* never needed.
|
|
27
|
+
*/
|
|
28
|
+
export function backupStamp(date: Date): string {
|
|
29
|
+
const iso = date.toISOString();
|
|
30
|
+
return `${iso.slice(0, 10)}_${iso.slice(11, 19).replaceAll(':', '-')}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The full artifact name: `<prefix><stamp>.sql.xz.enc`. */
|
|
34
|
+
export function backupFileName(date: Date): string {
|
|
35
|
+
return `${BACKUP_FILE_PREFIX}${backupStamp(date)}.sql.xz.enc`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `YYYY-MM-DD`, `days` before `now`.
|
|
40
|
+
*/
|
|
41
|
+
export function retentionCutoff(now: Date, days: number): string {
|
|
42
|
+
return new Date(now.getTime() - days * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Read `BACKUP_RETENTION_DAYS` from the environment, rejecting bad values.
|
|
47
|
+
*
|
|
48
|
+
* Rejected rather than defaulted: silently falling back would either prune
|
|
49
|
+
* everything or keep backups forever, and both are worse than a failed run that
|
|
50
|
+
* names the problem.
|
|
51
|
+
*
|
|
52
|
+
* `Number()` rather than `parseInt()`: `parseInt('30d')` yields 30, which would
|
|
53
|
+
* silently accept a mistyped value and prune against the wrong window.
|
|
54
|
+
*/
|
|
55
|
+
export function requireRetentionDays(): number {
|
|
56
|
+
const raw = process.env['BACKUP_RETENTION_DAYS'];
|
|
57
|
+
const days = Number(raw);
|
|
58
|
+
if (!Number.isFinite(days) || days <= 0) {
|
|
59
|
+
throw new Error(`BACKUP_RETENTION_DAYS must be a positive number, got ${JSON.stringify(raw)}.`);
|
|
60
|
+
}
|
|
61
|
+
return days;
|
|
62
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { checkGodFiles, checkGodFilesCli, readAllowlist, HARD_LIMIT, SOFT_LIMIT } from './check-god-files';
|
|
6
|
+
|
|
7
|
+
function tempTree(files: Record<string, string>): string {
|
|
8
|
+
const root = mkdtempSync(join(tmpdir(), 'god-files-'));
|
|
9
|
+
for (const [name, content] of Object.entries(files)) {
|
|
10
|
+
const path = join(root, name);
|
|
11
|
+
mkdirSync(join(path, '..'), { recursive: true });
|
|
12
|
+
writeFileSync(path, content, 'utf8');
|
|
13
|
+
}
|
|
14
|
+
return root;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const pad = (lines: number): string => Array.from({ length: lines }, (_, index) => `// line ${index}`).join('\n');
|
|
18
|
+
|
|
19
|
+
describe('checkGodFiles', () => {
|
|
20
|
+
it('passes a small product source tree', () => {
|
|
21
|
+
const root = tempTree({ 'src/app.ts': pad(10) });
|
|
22
|
+
const report = checkGodFiles({ root });
|
|
23
|
+
expect(report.passed).toBe(true);
|
|
24
|
+
expect(report.findings).toStrictEqual([]);
|
|
25
|
+
expect(report.scanned).toBe(1);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('reports over-soft as warn and over-hard as critical', () => {
|
|
29
|
+
const soft = checkGodFiles({ root: tempTree({ 'src/medium.ts': pad(320) }) });
|
|
30
|
+
expect(soft.findings).toStrictEqual([{ file: 'src/medium.ts', lines: 320, level: 'warn' }]);
|
|
31
|
+
expect(soft.passed).toBe(true); // warn-only is the family's gate
|
|
32
|
+
|
|
33
|
+
const hard = checkGodFiles({ root: tempTree({ 'src/huge.ts': pad(450) }) });
|
|
34
|
+
expect(hard.findings[0]).toMatchObject({ file: 'src/huge.ts', level: 'critical' });
|
|
35
|
+
expect(hard.passed).toBe(true); // still passes without --fail-on-hard
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('fails only when the caller opts into the hard gate', () => {
|
|
39
|
+
const root = tempTree({ 'src/huge.ts': pad(450) });
|
|
40
|
+
expect(checkGodFiles({ root, failOnHard: true }).passed).toBe(false);
|
|
41
|
+
expect(checkGodFiles({ root, failOnHard: false }).passed).toBe(true);
|
|
42
|
+
expect(checkGodFiles({ root }).passed).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('keeps allowlisted files out of the findings but still reports them', () => {
|
|
46
|
+
const root = tempTree({ 'src/huge.ts': pad(450), 'src/other.ts': pad(350) });
|
|
47
|
+
const report = checkGodFiles({ root, allowlist: ['src/huge.ts'] });
|
|
48
|
+
expect(report.findings).toStrictEqual([{ file: 'src/other.ts', lines: 350, level: 'warn' }]);
|
|
49
|
+
expect(report.allowlisted).toStrictEqual([{ file: 'src/huge.ts', lines: 450, level: 'critical' }]);
|
|
50
|
+
expect(report.passed).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('ignores tests, generated, locales, scripts, configs, and lockfiles', () => {
|
|
54
|
+
const root = tempTree({
|
|
55
|
+
'src/app.test.ts': pad(500),
|
|
56
|
+
'src/generated/x.ts': pad(500),
|
|
57
|
+
'src/locales/de/translation.json': JSON.stringify({ a: 'b' }),
|
|
58
|
+
'scripts/build.ts': pad(500),
|
|
59
|
+
'vite.config.ts': pad(500),
|
|
60
|
+
'package.json': '{}',
|
|
61
|
+
'migrations/0001_init.sql': 'SELECT 1;',
|
|
62
|
+
});
|
|
63
|
+
expect(checkGodFiles({ root }).scanned).toBe(0);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('skips node_modules, dist, and caches', () => {
|
|
67
|
+
const root = tempTree({
|
|
68
|
+
'node_modules/pkg/index.ts': pad(500),
|
|
69
|
+
'dist/bundle.js': pad(500),
|
|
70
|
+
'coverage/report.js': pad(500),
|
|
71
|
+
});
|
|
72
|
+
expect(checkGodFiles({ root }).scanned).toBe(0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('sorts findings worst-first', () => {
|
|
76
|
+
const root = tempTree({ 'src/a.ts': pad(320), 'src/b.ts': pad(450), 'src/c.ts': pad(360) });
|
|
77
|
+
expect(checkGodFiles({ root }).findings.map((finding) => finding.file)).toStrictEqual(['src/b.ts', 'src/c.ts', 'src/a.ts']);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('readAllowlist', () => {
|
|
82
|
+
it('returns [] for an absent file', () => {
|
|
83
|
+
expect(readAllowlist('/nonexistent-xyz/allowlist.json')).toStrictEqual([]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('rejects a malformed allowlist rather than reading it as empty', () => {
|
|
87
|
+
const root = mkdtempSync(join(tmpdir(), 'god-allow-'));
|
|
88
|
+
const path = join(root, 'allowlist.json');
|
|
89
|
+
writeFileSync(path, '{ not: an array }', 'utf8');
|
|
90
|
+
expect(() => readAllowlist(path)).toThrow(/not valid JSON/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('rejects a non-array allowlist', () => {
|
|
94
|
+
const root = mkdtempSync(join(tmpdir(), 'god-allow-'));
|
|
95
|
+
const path = join(root, 'allowlist.json');
|
|
96
|
+
writeFileSync(path, '{"src/a.ts": 400}', 'utf8');
|
|
97
|
+
expect(() => readAllowlist(path)).toThrow(/JSON array of repo-relative path strings/);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('reads a valid allowlist', () => {
|
|
101
|
+
const root = mkdtempSync(join(tmpdir(), 'god-allow-'));
|
|
102
|
+
const path = join(root, 'allowlist.json');
|
|
103
|
+
writeFileSync(path, '["a.ts", "b.ts"]', 'utf8');
|
|
104
|
+
expect(readAllowlist(path)).toStrictEqual(['a.ts', 'b.ts']);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('checkGodFilesCli', () => {
|
|
109
|
+
it('returns 0 in the default warn-only mode, even over the hard limit', () => {
|
|
110
|
+
const root = tempTree({ 'src/huge.ts': pad(450) });
|
|
111
|
+
expect(checkGodFilesCli(['--root', root])).toBe(0);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('exits non-zero with --fail-on-hard over the hard limit', () => {
|
|
115
|
+
const exit = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never);
|
|
116
|
+
const root = tempTree({ 'src/huge.ts': pad(450) });
|
|
117
|
+
checkGodFilesCli(['--root', root, '--fail-on-hard']);
|
|
118
|
+
expect(exit).toHaveBeenCalledWith(1);
|
|
119
|
+
exit.mockRestore();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('returns 0 on a clean tree with the gate on', () => {
|
|
123
|
+
const root = tempTree({ 'src/app.ts': pad(10) });
|
|
124
|
+
expect(checkGodFilesCli(['--root', root, '--fail-on-hard'])).toBe(0);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('exposes the family constants', () => {
|
|
128
|
+
expect(SOFT_LIMIT).toBe(300);
|
|
129
|
+
expect(HARD_LIMIT).toBe(400);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,327 @@
|
|
|
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
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
50
|
+
import { pathToFileURL } from 'node:url';
|
|
51
|
+
import { join, relative } from 'node:path';
|
|
52
|
+
import { fail } from './lib/github-actions';
|
|
53
|
+
import { isSet, parseFlags, splitPositional, valueOf } from './lib/cli-args';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A file past a size the guard reports on.
|
|
57
|
+
*/
|
|
58
|
+
export interface GodFileFinding {
|
|
59
|
+
/** Path relative to the root the check ran against. */
|
|
60
|
+
readonly file: string;
|
|
61
|
+
/** Lines it has. */
|
|
62
|
+
readonly lines: number;
|
|
63
|
+
/** `warn` past the soft limit, `critical` past the hard one. */
|
|
64
|
+
readonly level: 'warn' | 'critical';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The outcome of one run.
|
|
69
|
+
*/
|
|
70
|
+
export interface GodFileReport {
|
|
71
|
+
/** Files over a limit, worst first, minus the allowlisted. */
|
|
72
|
+
readonly findings: readonly GodFileFinding[];
|
|
73
|
+
/** How many files the walk measured. */
|
|
74
|
+
readonly scanned: number;
|
|
75
|
+
/** Allowlisted files that are over a limit — reported, never counted. */
|
|
76
|
+
readonly allowlisted: readonly GodFileFinding[];
|
|
77
|
+
/**
|
|
78
|
+
* Whether the tree may be committed as it stands.
|
|
79
|
+
*
|
|
80
|
+
* `true` in warn-only mode, which is the default; a `critical` finding fails
|
|
81
|
+
* only when the caller asked for the gate.
|
|
82
|
+
*/
|
|
83
|
+
readonly passed: boolean;
|
|
84
|
+
/** The message to print, either way. */
|
|
85
|
+
readonly reported: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* What `checkGodFiles` accepts.
|
|
90
|
+
*/
|
|
91
|
+
export interface GodFileOptions {
|
|
92
|
+
/** Directory to walk. */
|
|
93
|
+
readonly root: string;
|
|
94
|
+
/**
|
|
95
|
+
* Repo-relative paths (or directory prefixes ending in `/`) exempt from the
|
|
96
|
+
* report. Defaults to `god-files.allowlist.json` beside the caller's script
|
|
97
|
+
* when that file exists — see `readAllowlist`.
|
|
98
|
+
*/
|
|
99
|
+
readonly allowlist?: readonly string[];
|
|
100
|
+
/** Soft limit, past which a file is reported as `warn`. */
|
|
101
|
+
readonly soft?: number;
|
|
102
|
+
/** Hard limit, past which a file is reported as `critical`. */
|
|
103
|
+
readonly hard?: number;
|
|
104
|
+
/** Make a `critical` finding fail the run. */
|
|
105
|
+
readonly failOnHard?: boolean;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Default soft limit, as every source repo uses. */
|
|
109
|
+
export const SOFT_LIMIT = 300;
|
|
110
|
+
/** Default hard limit, as every source repo uses. */
|
|
111
|
+
export const HARD_LIMIT = 400;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Directories the walker never enters.
|
|
115
|
+
*
|
|
116
|
+
* Build output and VCS state; `coverage` is here because a generated HTML report
|
|
117
|
+
* is thousands of "lines" of nothing.
|
|
118
|
+
*/
|
|
119
|
+
const EXCLUDE_DIRS = new Set(['node_modules', 'dist', '.wrangler', 'coverage', 'coverage-integration', 'coverage-web', '.git']);
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Source extensions the guard counts.
|
|
123
|
+
*
|
|
124
|
+
* `css` is in the set because every source repo counts it, and a 400-line
|
|
125
|
+
* stylesheet is the same reading problem as a 400-line module.
|
|
126
|
+
*/
|
|
127
|
+
const SOURCE_EXTENSIONS = /\.(?:ts|tsx|js|mjs|cjs|css)$/;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The test-file suffixes the guard skips.
|
|
131
|
+
*
|
|
132
|
+
* `.test.tsx` matter as much as `.test.ts`: the walker collects both, so a list
|
|
133
|
+
* that only named the `.ts` forms let every React test file through the guard and
|
|
134
|
+
* reported them as god files — which is how Durable-DAV-Router discovered it.
|
|
135
|
+
* Built from the kinds and extensions rather than enumerated, so a new test
|
|
136
|
+
* extension cannot silently escape.
|
|
137
|
+
*/
|
|
138
|
+
const TEST_SUFFIXES: readonly string[] = ['test', 'spec'].flatMap((kind) =>
|
|
139
|
+
['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].map((ext) => `.${kind}${ext}`),
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Whether a repo-relative path is exempt from the guard.
|
|
144
|
+
*
|
|
145
|
+
* Provenance: AWS-AccessBridge's version is the canonical one, because it
|
|
146
|
+
* documents and fixes two live bugs in the others.
|
|
147
|
+
*
|
|
148
|
+
* Every directory pattern is anchored with `(?:^|/)` rather than a bare leading
|
|
149
|
+
* `/`. `relative()` yields `test/helpers/x.ts` for a top-level test directory and
|
|
150
|
+
* `scripts/lib/x.ts` for the scripts directory — **no leading separator** — so a
|
|
151
|
+
* pattern written as `/\/(?:test|tests|__tests__)\//` never matches either one.
|
|
152
|
+
* It looks right, it does match when handed an absolute path, and against these
|
|
153
|
+
* layouts it is silently a no-op. Durable-DAV and ChordDHT-Tracker ship exactly
|
|
154
|
+
* that dead pattern.
|
|
155
|
+
*/
|
|
156
|
+
export function shouldSkip(rel: string): boolean {
|
|
157
|
+
if (/(?:^|\/)(?:locales|generated)\//.test(rel)) return true;
|
|
158
|
+
if (/(?:^|\/)__(?:tests|mocks)__(?:\/|$)/.test(rel)) return true;
|
|
159
|
+
if (/(?:^|\/)scripts\//.test(rel)) return true;
|
|
160
|
+
if (/(?:^|\/)(?:test|tests|__tests__)\//.test(rel)) return true;
|
|
161
|
+
if (/\.config\.(?:m?[jt]s|cjs)$/.test(rel)) return true;
|
|
162
|
+
if (rel.endsWith('.d.ts')) return true;
|
|
163
|
+
if (rel.endsWith('.json')) return true;
|
|
164
|
+
if (rel.endsWith('.sql')) return true;
|
|
165
|
+
if (rel.endsWith('.md')) return true;
|
|
166
|
+
return TEST_SUFFIXES.some((suffix) => rel.endsWith(suffix));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Whether a repo-relative path is on the allowlist.
|
|
171
|
+
*
|
|
172
|
+
* An entry ending in `/` exempts a whole directory; any other entry is matched
|
|
173
|
+
* exactly, so `apps/api/src/index.ts` does not exempt `apps/api/src/index.util.ts`.
|
|
174
|
+
*/
|
|
175
|
+
export function isAllowlisted(rel: string, allowlist: readonly string[]): boolean {
|
|
176
|
+
return allowlist.some((entry) => (entry.endsWith('/') ? rel.startsWith(entry) : rel === entry));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Every source file under `root`, as repo-relative paths.
|
|
181
|
+
*
|
|
182
|
+
* Symlink-safe: a dangling symlink or a file removed mid-walk is skipped rather
|
|
183
|
+
* than thrown, because neither is a god-file problem.
|
|
184
|
+
*/
|
|
185
|
+
export function walk(root: string): string[] {
|
|
186
|
+
const out: string[] = [];
|
|
187
|
+
const visit = (dir: string): void => {
|
|
188
|
+
for (const entry of readdirSync(dir)) {
|
|
189
|
+
const full = join(dir, entry);
|
|
190
|
+
let stat;
|
|
191
|
+
try {
|
|
192
|
+
stat = statSync(full);
|
|
193
|
+
} catch {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (stat.isDirectory()) {
|
|
197
|
+
if (EXCLUDE_DIRS.has(entry)) continue;
|
|
198
|
+
visit(full);
|
|
199
|
+
} else if (SOURCE_EXTENSIONS.test(entry)) {
|
|
200
|
+
out.push(relative(root, full));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
visit(root);
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Run the guard over a tree.
|
|
210
|
+
*
|
|
211
|
+
* Pure with respect to the verdict — the only I/O is the walk and a line count —
|
|
212
|
+
* so a test drives it against a temporary directory.
|
|
213
|
+
*/
|
|
214
|
+
export function checkGodFiles(options: GodFileOptions): GodFileReport {
|
|
215
|
+
const soft = options.soft ?? SOFT_LIMIT;
|
|
216
|
+
const hard = options.hard ?? HARD_LIMIT;
|
|
217
|
+
const allowlist = options.allowlist ?? [];
|
|
218
|
+
|
|
219
|
+
const scanned = walk(options.root).filter((rel) => !shouldSkip(rel));
|
|
220
|
+
const findings: GodFileFinding[] = [];
|
|
221
|
+
const allowlisted: GodFileFinding[] = [];
|
|
222
|
+
|
|
223
|
+
for (const rel of scanned) {
|
|
224
|
+
const lines = readFileSync(join(options.root, rel), 'utf8').split('\n').length;
|
|
225
|
+
if (lines <= soft) continue;
|
|
226
|
+
const finding: GodFileFinding = { file: rel, lines, level: lines > hard ? 'critical' : 'warn' };
|
|
227
|
+
if (isAllowlisted(rel, allowlist)) {
|
|
228
|
+
allowlisted.push(finding);
|
|
229
|
+
} else {
|
|
230
|
+
findings.push(finding);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
findings.sort((left, right) => right.lines - left.lines);
|
|
235
|
+
allowlisted.sort((left, right) => right.lines - left.lines);
|
|
236
|
+
|
|
237
|
+
const critical = findings.filter((finding) => finding.level === 'critical');
|
|
238
|
+
const passed = options.failOnHard !== true || critical.length === 0;
|
|
239
|
+
|
|
240
|
+
const reported = passed
|
|
241
|
+
? `God-file check passed (${scanned.length} files, ${findings.length} over soft limit ${soft}, ${allowlisted.length} allowlisted).`
|
|
242
|
+
: `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.`;
|
|
243
|
+
|
|
244
|
+
return { findings, allowlisted, scanned: scanned.length, passed, reported };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Read an allowlist file, or `[]` when it is absent.
|
|
249
|
+
*
|
|
250
|
+
* The file is `scripts/god-files.allowlist.json`: a JSON array of repo-relative
|
|
251
|
+
* paths. Absence is the normal case for a repo with no known offenders, so it is
|
|
252
|
+
* not an error — a guard that fails on a first run nobody has filled in yet is a
|
|
253
|
+
* guard that gets disabled.
|
|
254
|
+
*
|
|
255
|
+
* A malformed file is an error rather than an empty list, because "the allowlist
|
|
256
|
+
* is empty" silently un-reports every known offender.
|
|
257
|
+
*/
|
|
258
|
+
export function readAllowlist(path: string): string[] {
|
|
259
|
+
let raw: string;
|
|
260
|
+
try {
|
|
261
|
+
raw = readFileSync(path, 'utf8');
|
|
262
|
+
} catch {
|
|
263
|
+
return [];
|
|
264
|
+
}
|
|
265
|
+
let parsed: unknown;
|
|
266
|
+
try {
|
|
267
|
+
parsed = JSON.parse(raw);
|
|
268
|
+
} catch (error: unknown) {
|
|
269
|
+
throw new Error(`${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
270
|
+
}
|
|
271
|
+
if (!Array.isArray(parsed) || parsed.some((entry) => typeof entry !== 'string')) {
|
|
272
|
+
throw new Error(`${path} must be a JSON array of repo-relative path strings.`);
|
|
273
|
+
}
|
|
274
|
+
return parsed as string[];
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* CLI entry: `checkGodFilesCli(['.', '--fail-on-hard'])`.
|
|
279
|
+
*
|
|
280
|
+
* The directory is a positional argument **or** `--root <dir>`, because every other
|
|
281
|
+
* script in this tree takes its target positionally and a gate that has to be
|
|
282
|
+
* invoked one way in one script and another way in the next is a gate somebody
|
|
283
|
+
* invokes wrong. A second positional is an error.
|
|
284
|
+
*
|
|
285
|
+
* Flags (see FlagSpec): `--root <dir>` (default `.`), `--allowlist <file>`
|
|
286
|
+
* (default `scripts/god-files.allowlist.json` beside the root, absent = none),
|
|
287
|
+
* `--fail-on-hard` (default off, matching every source repo's warn-only gate).
|
|
288
|
+
* Returns 0 unless `--fail-on-hard` is set and a critical file is found.
|
|
289
|
+
*/
|
|
290
|
+
export function checkGodFilesCli(argv: readonly string[] = process.argv.slice(2)): number {
|
|
291
|
+
// Split before parsing, because parseFlags rejects anything that is not a declared
|
|
292
|
+
// flag and a positional directory is neither a mistake nor a flag.
|
|
293
|
+
const { positional, rest } = splitPositional(argv, ['root', 'allowlist']);
|
|
294
|
+
const flags = parseFlags(rest, {
|
|
295
|
+
value: ['root', 'allowlist'],
|
|
296
|
+
boolean: ['fail-on-hard'],
|
|
297
|
+
});
|
|
298
|
+
const root = valueOf(flags, 'root') ?? positional[0] ?? '.';
|
|
299
|
+
const allowlistPath = valueOf(flags, 'allowlist') ?? join(root, 'scripts/god-files.allowlist.json');
|
|
300
|
+
const report = checkGodFiles({
|
|
301
|
+
root,
|
|
302
|
+
allowlist: readAllowlist(allowlistPath),
|
|
303
|
+
failOnHard: isSet(flags, 'fail-on-hard'),
|
|
304
|
+
});
|
|
305
|
+
console.log(report.reported);
|
|
306
|
+
for (const finding of report.findings) {
|
|
307
|
+
console.log(`${finding.level === 'critical' ? 'CRITICAL' : 'WARN'} ${finding.lines} ${finding.file}`);
|
|
308
|
+
}
|
|
309
|
+
for (const finding of report.allowlisted) {
|
|
310
|
+
console.log(`ALLOWLISTED ${finding.lines} ${finding.file}`);
|
|
311
|
+
}
|
|
312
|
+
if (!report.passed) {
|
|
313
|
+
fail(`God-file check failed: run with --root ${root} to see the report.`);
|
|
314
|
+
}
|
|
315
|
+
return 0;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Run only when this file is the entrypoint.
|
|
319
|
+
//
|
|
320
|
+
// The rules above are pure and the tests import them directly, so a module-level
|
|
321
|
+
// call would shell out (and exit) during a test run. The URL comparison rather than
|
|
322
|
+
// an unconditional call is the same guard Durable-DAV's init-secrets.ts uses, and
|
|
323
|
+
// it is what keeps the rule modules importable — which is the whole reason they are
|
|
324
|
+
// separate modules in the first place.
|
|
325
|
+
if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
326
|
+
process.exit(checkGodFilesCli(process.argv.slice(2)));
|
|
327
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Write a placeholder `apps/api/src/generated/spa-shell.ts` when it is absent.
|
|
4
|
+
*
|
|
5
|
+
* Provenance: Durable-DAV's `scripts/ensure-spa-shell-stub.mjs` and Edge-Sonic's
|
|
6
|
+
* `scripts/build/ensure-spa-shell-stub.ts`. The rule lives in `spa-shell.ts`; this
|
|
7
|
+
* is the entrypoint that runs it from `postinstall`.
|
|
8
|
+
*
|
|
9
|
+
* `pnpm install` runs this before `typegen` so the API worker typechecks in a
|
|
10
|
+
* fresh clone, where the real file has not been produced by the Vite build yet.
|
|
11
|
+
* It exits having done nothing when the file already exists, so a real build
|
|
12
|
+
* output is never overwritten — overwriting would erase the last build with a
|
|
13
|
+
* stub on the next install. The file is gitignored.
|
|
14
|
+
*
|
|
15
|
+
* Usage, from the repo root (typically wired as `postinstall`):
|
|
16
|
+
*
|
|
17
|
+
* ```bash
|
|
18
|
+
* pnpm exec tsx scripts/ensure-spa-shell-stub.ts [repo-root]
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
import { ensureSpaShellStub } from './spa-shell';
|
|
24
|
+
|
|
25
|
+
const REPOSITORY_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
26
|
+
const root = process.argv[2] === undefined ? REPOSITORY_ROOT : path.resolve(process.argv[2]);
|
|
27
|
+
|
|
28
|
+
const result = ensureSpaShellStub({ root });
|
|
29
|
+
|
|
30
|
+
console.log(
|
|
31
|
+
result.created
|
|
32
|
+
? `ensure-spa-shell-stub: created stub at ${result.path}`
|
|
33
|
+
: `ensure-spa-shell-stub: ${result.path} already present, left untouched`,
|
|
34
|
+
);
|