Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .github/workflows/latest-blend-gate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Latest-blend pre-release gate (#203) — resolve the LATEST `@juspay/blend-design-system@beta`
# dist-tag (NOT a pinned version), generate with this checkout, and assert it exits 0 with
# non-empty output that compiles on ReScript 12.
#
# Why this exists (see #203): the benchmark pins specific blend versions, but blend is the primary
# downstream target and moves faster than our pins. Twice a regression compiled fine on the pins but
# broke on a newer real one (#110, #198 — the latter a crash-BEFORE-emit that compile gates can't
# see, because there is no output to compile). Only running the LATEST beta catches that class.
#
# Runs: weekly (catch a new blend beta soon after it ships), on demand, and — most importantly —
# manually BEFORE cutting a release. It is intentionally NOT a required PR check: the version floats,
# so it belongs on a cadence + pre-release, not on every PR.
name: Latest-blend gate

on:
schedule:
- cron: '17 6 * * 1' # Mondays 06:17 UTC — before the weekly stable release cadence
workflow_dispatch: # run this before cutting any release

concurrency:
group: latest-blend-gate-${{ github.ref }}
cancel-in-progress: true

jobs:
latest-blend:
name: Generate + compile the latest blend beta
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7

- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 20
cache: npm

- run: npm ci

# Resolves + installs `@juspay/blend-design-system@beta` from the DEFAULT public registry with
# no auth — the same access the benchmark's sandbox `npm ci` already relies on (blend is
# published public). If blend ever becomes access-restricted, add an `npm config set
# //registry.npmjs.org/:_authToken` step with a scoped-read token; until then no secret is needed,
# and an unresolvable `@beta` correctly fails the gate closed rather than passing silently.
- name: Run latest-blend gate
run: node benchmark/run.mjs --latest-blend

- name: Job summary
if: always()
run: cat benchmark/.work/latest-blend.md >> "$GITHUB_STEP_SUMMARY" || echo "no latest-blend.md produced" >> "$GITHUB_STEP_SUMMARY"
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,30 @@ This project adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Added

- **Post-emit dangling-reference guard** (#202) — the reachability sweep's root/child allowlist fell
behind the emitter four times (#191, #195, #197 ×2), each shipping output where a `Module.type` or
`JsFn.t` reference pointed at a declaration the sweep had dropped — a ReScript **compile error**
caught only by manual review or downstream validation. A new allowlist-INDEPENDENT check
(`src/validate.mjs`) scans every written `.res` after generation and asserts each reference into one
of our own file-modules resolves to a real declaration (plus bare `JsFn.t` requires a written
`JsFn.res`). It **hard-fails** the golden suite (controlled output must be clean) and **warns**
(non-fatal) at generation time for real packages, so a text-parse edge case can never break a
user's build. Validated for zero false positives across every golden + benchmark baseline, and it
trips on a synthetic dangling ref (`test/dangling-refs.mjs`). It would have caught all four past
bugs mechanically — "the next reviewer finds it" becomes "CI finds it."

- **Latest-blend pre-release gate** (#203) — the benchmark pins specific blend versions, but blend is
the primary downstream target and moves faster than the pins; twice a regression compiled on the
pins yet broke on a newer real blend (#110, and #198's crash-before-emit, invisible to compile
gates because there is no output to compile). `npm run bench:latest-blend` (and
`.github/workflows/latest-blend-gate.yml`, weekly + on demand) resolves the live
`@juspay/blend-design-system@beta` dist-tag, generates with the shipped checkout, and fails on a
crash, empty output, or a compile break — surfacing a diff vs the newest pinned baseline. Run it
before cutting any release. Complements pinning the exact failing version as a permanent regression
lock: the pin catches *that* case forever; this gate catches the *next* one.

## [1.4.0-beta.2] — 2026-08-20

> **Upgrading: regenerate your bindings, and commit `.bindgen-manifest.json` alongside them.** #190
Expand Down
15 changes: 15 additions & 0 deletions benchmark/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,21 @@ The job exits 1 (red check) only on FAIL.
| `npm run bench` | verify all packages against baselines (what CI runs) |
| `npm run bench:update` | regenerate `baselines/<slug>/{bindings/,metrics.json,package-lock.json}` |
| `node benchmark/run.mjs --only react-markdown` | debug one package (slug or name) |
| `npm run bench:latest-blend` | **pre-release gate (#203):** resolve the LATEST `@juspay/blend-design-system@beta` and prove it still generates + compiles |

## The latest-blend gate (#203)

The pins above are frozen, but blend — the primary downstream target — moves faster than them.
Twice a regression compiled on the pins yet broke on a newer real blend (#110, #198; the latter a
**crash-before-emit**, invisible to any compile gate because there is no output to compile). The
latest-blend gate closes that gap: it resolves the live `@juspay/blend-design-system@beta` dist-tag
(not a pin), generates with this checkout, and **fails** on a crash, empty output, or a compile
break. It also prints an informational diff vs the newest pinned blend baseline.

Run it **before cutting any release** (`npm run bench:latest-blend`). CI runs it weekly and on
demand via `.github/workflows/latest-blend-gate.yml` — deliberately *not* a per-PR required check,
since the version floats. Complements pinning the exact failing version as a permanent regression
lock (as `0.0.38-beta.1` is): the pin catches *that* case forever; this gate catches the *next* one.

## Accepting an intentional output change (WARN → PASS)

Expand Down
131 changes: 125 additions & 6 deletions benchmark/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ const WORK = join(HERE, '.work')
const UPDATE = process.argv.includes('--update')
const onlyIx = process.argv.indexOf('--only')
const ONLY = onlyIx === -1 ? null : process.argv[onlyIx + 1]
// #203: the pre-release gate — resolve the LATEST blend beta dist-tag (not a pinned version) and
// prove the shipped checkout still generates + compiles it. blend is the primary downstream target
// and moves faster than our pins; twice a regression compiled on the pins but broke on the newer
// real one (#110, #198 — the latter a crash-before-emit that compile gates can't see, since there
// is no output to compile). Only running the LATEST catches that class before it ships.
const LATEST_BLEND = process.argv.includes('--latest-blend')
const BLEND_PKG = '@juspay/blend-design-system'

const YELLOW = (s) => `\x1b[33m${s}\x1b[0m`
const slugOf = (name) => name.replace(/[@/]/g, '_') // "@s/p" -> "_s_p" (a leading @ already becomes _)
Expand All @@ -54,16 +61,17 @@ function sh(cmd, cwd) {
* the committed lockfile. node_modules is kept across runs and only reinstalled
* when the lockfile stamp changes (so CI can cache it).
*/
function setupSandbox(pkg, slug, sandbox) {
function setupSandbox(pkg, slug, sandbox, { freshInstall = false } = {}) {
mkdirSync(sandbox, { recursive: true })
const tpl = JSON.parse(readFileSync(join(TEMPLATE, 'package.json'), 'utf-8'))
tpl.dependencies[pkg.name] = pkg.version // exact pin, no range
writeFileSync(join(sandbox, 'package.json'), JSON.stringify(tpl, null, 2) + '\n')
cpSync(join(TEMPLATE, 'rescript.json'), join(sandbox, 'rescript.json'))

const committedLock = join(BASELINES, slug, 'package-lock.json')
if (UPDATE) {
// Fresh resolve; the resulting lockfile becomes the committed baseline.
if (UPDATE || freshInstall) {
// Fresh resolve. Under --update the resulting lockfile becomes the committed baseline; under
// --latest-blend (fresh) the version floats and has no committed lockfile to reuse anyway.
rmSync(join(sandbox, 'package-lock.json'), { force: true })
const r = sh('npm install --silent --no-audit --no-fund', sandbox)
if (!r.ok) throw new Error(`npm install failed:\n${r.out.slice(0, 2000)}`)
Expand Down Expand Up @@ -141,14 +149,125 @@ function verdictFor(metrics, baseline, diffProblems) {
return { verdict: 'PASS', reason: '' }
}

/** Semver precedence for the version strings we pin. Returns <0 / 0 / >0. Correct on the subtleties
* that can arise: build metadata (`+sha`) is ignored (spec: not part of precedence); all numeric
* release segments compare numerically (not just the first three); a prerelease is OLDER than its
* release (`0.0.38-beta.1` < `0.0.38`); and numeric prerelease identifiers compare numerically
* (`beta.2` < `beta.10`, `alpha` < `beta` < `rc`). */
function cmpVersion(a, b) {
const split = (v) => { const s = v.split('+')[0]; const i = s.indexOf('-'); return i < 0 ? [s, ''] : [s.slice(0, i), s.slice(i + 1)] }
const [ma, pa] = split(a), [mb, pb] = split(b)
const na = ma.split('.').map(Number), nb = mb.split('.').map(Number)
for (let i = 0; i < Math.max(na.length, nb.length); i++) if ((na[i] || 0) !== (nb[i] || 0)) return (na[i] || 0) - (nb[i] || 0)
if (!pa && !pb) return 0
if (!pa) return 1 // a is the release -> newer than any prerelease of it
if (!pb) return -1
const ia = pa.split('.'), ib = pb.split('.')
for (let i = 0; i < Math.max(ia.length, ib.length); i++) {
const x = ia[i], y = ib[i]
if (x === undefined) return -1
if (y === undefined) return 1
if (/^\d+$/.test(x) && /^\d+$/.test(y)) { const dv = Number(x) - Number(y); if (dv) return dv }
else if (x !== y) return x < y ? -1 : 1
}
return 0
}

/** Slug for a package's baseline dir, shared by the main loop AND the latest-blend gate so the
* scheme lives in ONE place: a name pinned at multiple versions gets a version-qualified slug; a
* single-version name keeps its bare slug. */
function slugForPkg(pkg, allPackages) {
const multiVersion = allPackages.filter((p) => p.name === pkg.name).length > 1
return slugOf(multiVersion ? `${pkg.name}@${pkg.version}` : pkg.name)
}

/** Pick the newest pinned blend baseline to diff a floating latest build against — informational
* only; a newer version legitimately differs from every pin. */
function newestPinnedBlendSlug(allPackages) {
const blends = allPackages.filter((p) => p.name === BLEND_PKG)
if (!blends.length) return null
blends.sort((a, b) => cmpVersion(a.version, b.version))
return slugForPkg(blends[blends.length - 1], allPackages)
}

/** #203 pre-release gate: resolve the LATEST blend beta and prove the shipped checkout generates
* non-empty output that compiles on ReScript 12. Fails on crash / empty output / compile break —
* the three ways a "compiles on pinned, breaks on latest" regression shows up. Diff vs the newest
* pinned blend baseline is surfaced for review but never fails the gate (a newer version differs). */
function runLatestBlendGate(allPackages) {
const ver = sh(`npm view ${BLEND_PKG}@beta version`)
// sh() merges stderr, so an `npm notice`/warning line can precede the value — take the LAST line
// that looks like a version, not blindly the last line. Still fails closed (empty -> exit 1).
const version = (ver.ok ? ver.out.trim().split('\n').map((s) => s.trim()) : []).reverse().find((l) => /^\d+\.\d+\.\d+/.test(l)) || ''
if (!/^\d+\.\d+\.\d+/.test(version)) {
console.error(RED(`✗ could not resolve ${BLEND_PKG}@beta version:\n${ver.out.slice(0, 500)}`))
process.exit(1)
}
const pkg = { name: BLEND_PKG, version, flags: ['--webapi'] }
const label = `${pkg.name}@${version} (latest beta)`
const slug = slugForPkg(pkg, allPackages)
const workDir = join(WORK, 'latest-blend')
const sandbox = join(workDir, 'sandbox')
mkdirSync(workDir, { recursive: true })
console.error(DIM(`── ${label} ──`))

const pinnedSlug = newestPinnedBlendSlug(allPackages)
const alreadyPinned = allPackages.some((p) => p.name === BLEND_PKG && p.version === version)
if (alreadyPinned) console.error(DIM(` (latest beta ${version} is already pinned in packages.json — the benchmark covers it; this gate re-verifies the LATEST regardless)`))

let res
try {
setupSandbox(pkg, slug, sandbox, { freshInstall: true })
const gen = generate(pkg, sandbox, workDir)
const generated = readDir(join(sandbox, 'src'))
const hasRes = [...generated.keys()].some((f) => f.endsWith('.res'))
const cmp = compile(sandbox, workDir, hasRes)

const failures = []
if (gen.exit !== 0) failures.push(`generator exited ${gen.exit} (crash-before-emit — invisible to compile gates)`)
if (!hasRes || generated.size === 0) failures.push('no output produced')
if (hasRes && !cmp.ok) failures.push('generated bindings do not compile on ReScript 12')

// Informational diff vs the newest pinned blend baseline.
let diff = []
if (pinnedSlug && existsSync(join(BASELINES, pinnedSlug, 'bindings'))) {
diff = diffDirs(readDir(join(BASELINES, pinnedSlug, 'bindings')), generated).map(stripAnsi)
}
const metrics = {
resolvedVersion: version, generatorExit: gen.exit, compileOk: cmp.ok, warnings: cmp.warnings,
files: generated.size, buckets: gen.summary?.components ?? null,
}
const verdict = failures.length ? 'FAIL' : 'PASS'
res = { pkg: label, slug, verdict, reason: failures.join('; '), metrics, diffVsPinned: pinnedSlug, diff, compileErrors: cmp.errors }
const color = verdict === 'PASS' ? GREEN : RED
console.error(color(`${verdict === 'PASS' ? '✓' : '✗'} ${label}${res.reason ? ' — ' + res.reason : ''}`))
if (verdict === 'PASS') console.error(DIM(` generated ${generated.size} file(s), compiled clean${diff.length ? `; ${diff.filter((d) => !d.startsWith(' ')).length} diff(s) vs pinned ${pinnedSlug}` : ''}`))
} catch (e) {
console.error(RED(`✗ ${label} — ${e.message.split('\n')[0]}`))
res = { pkg: label, slug, verdict: 'FAIL', reason: e.message.split('\n')[0], metrics: null, diff: [] }
}

mkdirSync(WORK, { recursive: true })
writeFileSync(join(WORK, 'latest-blend.json'), JSON.stringify(res, null, 2) + '\n')
const md = [`## Latest-blend gate: ${res.verdict === 'PASS' ? '✅ PASS' : '❌ FAIL'}`, '', `- Resolved \`${BLEND_PKG}@beta\` → \`${res.metrics?.resolvedVersion || '?'}\``,
`- Generator exit: ${res.metrics?.generatorExit ?? '—'} · Compiles: ${res.metrics ? (res.metrics.compileOk ? '✅' : '❌') : '—'} · Files: ${res.metrics?.files ?? '—'}`,
res.reason ? `- **Failure:** ${res.reason}` : '', res.diff?.length ? `- Diff vs pinned \`${res.diffVsPinned}\`: ${res.diff.filter((d) => !d.startsWith(' ')).length} file(s) changed (informational)` : '']
if (res.compileErrors) md.push('', '```', res.compileErrors, '```')
writeFileSync(join(WORK, 'latest-blend.md'), md.filter(Boolean).join('\n') + '\n')

console.error('')
if (res.verdict === 'FAIL') { console.error(RED(`❌ latest-blend gate FAILED — ${res.reason}`)); process.exit(1) }
console.error(GREEN(`✅ latest-blend gate PASS — ${BLEND_PKG}@${version} generates + compiles`))
process.exit(0)
}

// ── main ────────────────────────────────────────────────────────────────────
const allPackages = JSON.parse(readFileSync(join(HERE, 'packages.json'), 'utf-8'))
if (LATEST_BLEND) runLatestBlendGate(allPackages)
// Two pinned versions of the SAME package (blend 0.0.36 stable + the 0.0.37 beta line that
// blend-rescript actually regenerates from) need separate baselines — a duplicated name gets a
// version-qualified slug; single-version packages keep their existing baseline dirs.
const nameCount = new Map()
for (const p of allPackages) nameCount.set(p.name, (nameCount.get(p.name) || 0) + 1)
const slugFor = (p) => slugOf(nameCount.get(p.name) > 1 ? `${p.name}@${p.version}` : p.name)
const slugFor = (p) => slugForPkg(p, allPackages)
const packages = allPackages.filter((p) => !ONLY || slugFor(p) === ONLY || slugOf(p.name) === ONLY || p.name === ONLY)
if (!packages.length) {
console.error(RED(`no package matches --only ${ONLY}`))
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,18 @@
},
"scripts": {
"gen": "node src/cli.mjs",
"test": "node test/smoke.mjs && node test/public-name-stability.mjs && node test/representation-flip.mjs && node test/module-move-compat.mjs && node test/golden.mjs && node test/html-attrs.mjs",
"test": "node test/smoke.mjs && node test/public-name-stability.mjs && node test/representation-flip.mjs && node test/module-move-compat.mjs && node test/dangling-refs.mjs && node test/golden.mjs && node test/html-attrs.mjs",
"test:smoke": "node test/smoke.mjs",
"test:golden": "node test/golden.mjs",
"test:names": "node test/public-name-stability.mjs",
"test:flip": "node test/representation-flip.mjs",
"test:module-move": "node test/module-move-compat.mjs",
"test:dangling": "node test/dangling-refs.mjs",
"test:golden:update": "node test/golden.mjs --update",
"test:compile": "node test/golden-compile.mjs",
"bench": "node benchmark/run.mjs",
"bench:update": "node benchmark/run.mjs --update",
"bench:latest-blend": "node benchmark/run.mjs --latest-blend",
"gen:attrs": "node scripts/gen-html-attrs.mjs"
},
"files": [
Expand Down
22 changes: 22 additions & 0 deletions src/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { extractComponent, extractModule } from './extract.mjs'
import { emit, emitFunction, emitClass, emitNamespace, report, planSharedModules, emitSharedModule, makeResolveRef } from './emit.mjs'
import { resolveInput } from './resolve.mjs'
import { writeReport } from './report.mjs'
import { findDanglingRefs } from './validate.mjs'
import { planHtmlAttrs, HTML_ATTRS_PIN } from './html-attrs.mjs'
import { RESCRIPT_RESERVED } from './stdlib-types.mjs'
import { writeFileSync, mkdirSync, existsSync, readFileSync, readdirSync, unlinkSync, renameSync } from 'fs'
Expand Down Expand Up @@ -665,6 +666,27 @@ async function main() {
}
}

// #202: post-emit dangling-reference guard. Read back every .res we just wrote and assert each
// `Module.type` / `JsFn.t` reference into one of OUR OWN file-modules resolves to a real
// declaration. This is allowlist-INDEPENDENT — it catches the recurring failure (an emitter
// change outruns the reachability sweep's roots and strands a reference whose declaration was
// dropped → a ReScript compile error) without compiling. WARN, never fail: a real user's build
// must never be blocked by a text-parse edge case (the golden suite hard-fails on controlled
// output instead). Skipped for --stdout (nothing on disk to read back).
if (!opts.stdout) {
const emitted = []
for (const rel of written) {
if (!rel.endsWith('.res')) continue
try { emitted.push([rel, readFileSync(join(outDir, rel), 'utf-8')]) } catch { /* vanished — skip */ }
}
const dangling = findDanglingRefs(emitted)
if (dangling.length) {
console.error(`[bindgen] ⚠️ ${dangling.length} dangling reference(s) in generated output (please report — #202):`)
for (const p of dangling.slice(0, 20)) console.error(`[bindgen] - ${p}`)
if (dangling.length > 20) console.error(`[bindgen] … and ${dangling.length - 20} more`)
}
}

// The registry is append-only. A removed/moved/renamed upstream declaration becomes inactive,
// but its names stay reserved forever; if the same source identity reappears, it gets them back.
// Single-file mode has no shared registry, so it preserves any module-mode rows untouched.
Expand Down
Loading
Loading