From 3b76aa3dd911416feba3e9afb6b56f904bebc88e Mon Sep 17 00:00:00 2001 From: Johnny Huynh <27847622+johnnyhuy@users.noreply.github.com> Date: Fri, 31 Jul 2026 06:39:19 +1000 Subject: [PATCH 1/2] feat(catalog): SHA-256 fingerprint in generated files + stale-check CLI Closes #19. - scripts/gen-catalog.mjs: - Export buildForSource() so the stale-check can re-emit the expected body without running the full regen. - Stable stringify: sort entries by id, sort object keys, sha256-fingerprint the result, write as a comment header line. - Entry point at the bottom still produces the on-disk files when run directly. - scripts/check-catalog-stale.mjs: - For each source: rebuild the expected body via buildForSource(), compare byte-for-byte against the on-disk catalog.generated.ts. - Exits 0 if everything is in sync, 1 otherwise. - Reports each stale source with its heading and an actionable hint: "Run: npm run gen:catalog". - Distinguishes "missing file" from "out of sync" cases. - Regenerated catalog.generated.ts files now carry "fingerprint: (entries: N)" in the header. Commit-time diff stays friendly (git diff shows the new entries + the new fingerprint line). --- scripts/check-catalog-stale.mjs | 42 ++++++++++++++++ scripts/gen-catalog.mjs | 64 ++++++++++++++++++++----- src/main/gateways/catalog.generated.ts | 2 + src/main/providers/catalog.generated.ts | 2 + 4 files changed, 99 insertions(+), 11 deletions(-) create mode 100644 scripts/check-catalog-stale.mjs diff --git a/scripts/check-catalog-stale.mjs b/scripts/check-catalog-stale.mjs new file mode 100644 index 0000000..0b90be2 --- /dev/null +++ b/scripts/check-catalog-stale.mjs @@ -0,0 +1,42 @@ +#!/usr/bin/env -S node --no-warnings +// Verify that src/main/.../catalog.generated.ts is in sync with the JSON +// sources. Exits non-zero with a diff hint if the catalog sources change +// without running `npm run gen:catalog`. Part of CI. +// +// Reads the same JSON sources the generator does and runs the build helper +// from gen-catalog.mjs. Compares byte-by-byte against the on-disk file. +'use strict' + +import { readFile } from 'node:fs/promises' +import { buildForSource, SOURCES } from './gen-catalog.mjs' + +let stale = 0 +for (const source of SOURCES) { + let onDisk + try { + onDisk = await readFile(source.out, 'utf8') + } catch (err) { + if (err.code === 'ENOENT') { + stale++ + console.error(`✗ ${source.out}`) + console.error(` catalog.generated.ts is missing. Run: npm run gen:catalog`) + continue + } + throw err + } + const { body: expected } = await buildForSource(source) + if (onDisk !== expected) { + stale++ + console.error(`✗ ${source.out}`) + console.error(` catalog.generated.ts is stale vs ${source.heading}.`) + console.error(` Run: npm run gen:catalog`) + } else { + console.log(`✓ ${source.out.replace(process.cwd() + '/', '')}`) + } +} + +if (stale > 0) { + console.error(`\n${stale} catalog(s) out of date.`) + process.exit(1) +} +console.log('\nall catalog.generated.ts files are in sync with their sources') diff --git a/scripts/gen-catalog.mjs b/scripts/gen-catalog.mjs index 270c143..edc1ab9 100644 --- a/scripts/gen-catalog.mjs +++ b/scripts/gen-catalog.mjs @@ -4,10 +4,12 @@ import { readFile, writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { dirname, resolve } from 'node:path' +import { createHash } from 'node:crypto' const __dirname = dirname(fileURLToPath(import.meta.url)) const ROOT = resolve(__dirname, '..') -const SOURCES = [ + +export const SOURCES = [ { src: resolve(ROOT, 'src/main/providers/catalog.source.json'), out: resolve(ROOT, 'src/main/providers/catalog.generated.ts'), @@ -46,27 +48,67 @@ function jsonToTs(value, indent = 2) { throw new Error(`Unsupported value type: ${typeof value}`) } -async function generate(src, out, typeName, exportName, collection, heading) { - const raw = await readFile(src, 'utf8') +/** + * Stable JSON: sort the entries array by `id` and stringify with deterministic + * key ordering. The hash fingerprint is for cheap CI checks — `git diff` + * still shows every change. + */ +function stableStringify(entries) { + const sorted = [...entries].sort((a, b) => { + const idA = String(a.id ?? '') + const idB = String(b.id ?? '') + if (idA < idB) return -1 + if (idA > idB) return 1 + return 0 + }) + const sortKeys = (v) => { + if (Array.isArray(v)) return v.map(sortKeys) + if (v && typeof v === 'object') { + const out = {} + for (const k of Object.keys(v).sort()) out[k] = sortKeys(v[k]) + return out + } + return v + } + return JSON.stringify(sortKeys(sorted)) +} + +function fingerprint(entries) { + const json = stableStringify(entries) + return createHash('sha256').update(json).digest('hex').slice(0, 16) +} + +export async function buildForSource(source) { + const raw = await readFile(source.src, 'utf8') const parsed = JSON.parse(raw) - const entries = parsed[collection] + const entries = parsed[source.collection] + const fp = fingerprint(entries) const header = `/** * GENERATED FILE — DO NOT EDIT BY HAND. * * To add or modify entries, edit: - * ${heading} + * ${source.heading} * and run \`npm run gen:catalog\`. + * + * fingerprint: ${fp} (entries: ${entries.length}) */ -import type { ${typeName} } from './types' +import type { ${source.type} } from './types' -export const ${exportName}: readonly ${typeName}[] = +export const ${source.name}: readonly ${source.type}[] = ${jsonToTs(entries, 2)} as const ` - await writeFile(out, header, 'utf8') - console.log(`✓ ${out.replace(ROOT + '/', '')} (${entries.length} entries)`) + return { body: header, entries, fingerprint: fp } } -for (const s of SOURCES) { - await generate(s.src, s.out, s.type, s.name, s.collection, s.heading) +export async function writeSource(source) { + const { body } = await buildForSource(source) + await writeFile(source.out, body, 'utf8') + console.log(`✓ ${source.out.replace(ROOT + '/', '')} (${(await readFile(source.src, 'utf8')).length} bytes source)`) +} + +if (import.meta.url === `file://${process.argv[1]}`) { + for (const s of SOURCES) { + await writeSource(s) + } } diff --git a/src/main/gateways/catalog.generated.ts b/src/main/gateways/catalog.generated.ts index 19b1bc7..7ae2a19 100644 --- a/src/main/gateways/catalog.generated.ts +++ b/src/main/gateways/catalog.generated.ts @@ -4,6 +4,8 @@ * To add or modify entries, edit: * src/main/gateways/catalog.source.json * and run `npm run gen:catalog`. + * + * fingerprint: ce1c1cb45bbe308d (entries: 11) */ import type { GatewayEntry } from './types' diff --git a/src/main/providers/catalog.generated.ts b/src/main/providers/catalog.generated.ts index 95373dc..f05aedd 100644 --- a/src/main/providers/catalog.generated.ts +++ b/src/main/providers/catalog.generated.ts @@ -4,6 +4,8 @@ * To add or modify entries, edit: * src/main/providers/catalog.source.json * and run `npm run gen:catalog`. + * + * fingerprint: 3d35ef2c7f6a58e0 (entries: 18) */ import type { ProviderEntry } from './types' From d014cfc23246a055ea50a8f4503edc5d578aacd5 Mon Sep 17 00:00:00 2001 From: Johnny Huynh <27847622+johnnyhuy@users.noreply.github.com> Date: Fri, 31 Jul 2026 06:39:26 +1000 Subject: [PATCH 2/2] ci(catalog): fail build if catalog.generated.ts is stale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - package.json: catalog:check script - .github/workflows/ci.yml: app job runs catalog:check immediately after npm ci, before typecheck. This is the failing-fast layer for the fingerprint loop above — if a PR edits catalog.source.json without running npm run gen:catalog, CI surfaces it with a direct error message and the fix in one line. The prebuild hook remains for developer convenience (so local builds still auto-regen), but the CI check is authoritative. --- .github/workflows/ci.yml | 1 + package.json | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3948343..9b7e49e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,7 @@ jobs: node-version: '20' cache: 'npm' - run: npm ci + - run: npm run catalog:check - run: npm run typecheck - run: npm run lint - run: npm run test:extractor diff --git a/package.json b/package.json index cbb8578..5bbbc23 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "gen:catalog": "node scripts/gen-catalog.mjs", "prebuild": "npm run gen:catalog", "prebuild:renderer": "npm run gen:catalog", + "catalog:check": "node scripts/check-catalog-stale.mjs", "test:extractor": "node scripts/test-extract-base-url.mjs", "package": "npm run build && electron-builder" },