From 2a996aa4d855990af113b83f5291a1337c8e779d Mon Sep 17 00:00:00 2001 From: Joe Blau Date: Tue, 10 Nov 2026 08:07:18 -0800 Subject: [PATCH] ci: gate zero deps, tree-shaking, tsrs pins and lockfile sync Closes #101, #102, #103, #104 --- .github/workflows/tests.yml | 6 ++ README.md | 2 +- docs/ci-runners.md | 33 +++++- package.json | 8 +- renovate.json | 13 ++- scripts/check-invariants.mjs | 15 +++ scripts/invariants/checks.mjs | 155 +++++++++++++++++++++++++++++ scripts/invariants/checks.test.mjs | 102 +++++++++++++++++++ 8 files changed, 330 insertions(+), 4 deletions(-) create mode 100644 scripts/check-invariants.mjs create mode 100644 scripts/invariants/checks.mjs create mode 100644 scripts/invariants/checks.test.mjs diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bab16be..a11adf1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -74,6 +74,12 @@ jobs: - name: Tests and coverage gate run: bun run coverage + - name: Package invariants and gate regression tests + run: bun run check:invariants + + - name: TypeScript compiler fallback + run: bun run typecheck:tsc + # Tests import dist/ directly, so check the published entry points separately: every # "exports" subpath must import by package name in plain Node. - name: Package entry points diff --git a/README.md b/README.md index c2f66cb..affd28b 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ Guides, API reference, and the complete chart controls: **https://bloxwap.github ## Development -The repository uses npm workspaces with one root lockfile. Each workspace declares its own dependencies; the chart package has no runtime dependencies. +The repository uses npm workspaces with two root lockfiles: `bun.lock` for CI and `package-lock.json` for npm releases. `npm run check:lockfiles` checks that their package versions agree. Each workspace declares its own dependencies; the chart package has no runtime dependencies. ```sh npm ci diff --git a/docs/ci-runners.md b/docs/ci-runners.md index d14944a..4d13429 100644 --- a/docs/ci-runners.md +++ b/docs/ci-runners.md @@ -22,7 +22,7 @@ In the npmjs.com settings for `@bloxwap/chart`, add a GitHub Actions Trusted Pub | Workflow | Job | Runner | What it runs | | --- | --- | --- | --- | -| [`.github/workflows/tests.yml`](../.github/workflows/tests.yml) | `test` | Mac mini | Library coverage gate and package `exports` imports | +| [`.github/workflows/tests.yml`](../.github/workflows/tests.yml) | `test` | Mac mini | Library coverage, package invariants, tsc fallback, and package `exports` imports | | [`.github/workflows/coverage.yml`](../.github/workflows/coverage.yml) | `coverage` | Mac mini | LCOV report and Codecov upload | | [`.github/workflows/docs.yml`](../.github/workflows/docs.yml) | `build` | Mac mini | Docs export, typecheck, lint, Playwright Chrome checks; Pages artifact on `main` only (PRs still build and validate) | | [`.github/workflows/docs.yml`](../.github/workflows/docs.yml) | `deploy` | Mac mini | `configure-pages` + `deploy-pages` on `main` | @@ -67,3 +67,34 @@ Browsers go to `$RUNNER_TEMP/ms-playwright` (`PLAYWRIGHT_BROWSERS_PATH`). There - Headless Chrome on macOS does not need the Accessibility TCC prompt that headed browsers do. - npm publish (`Publish to npm`) uses the Ubuntu exception and approval setup above. GitHub Pages deploy still uses `pages: write` and `id-token: write`. - Fork pull requests on a public repository should stay on GitHub-hosted runners or a dedicated untrusted pool if org policy forbids self-hosted runners on forks. This repo's workflows themselves do not special-case forks. + +## Package invariant gates + +After the coverage build, the test job runs `bun run check:invariants` and +`bun run typecheck:tsc`. Locally, run `npm run build` followed by +`npm run check:invariants` (Node 24 and Bun required). The aggregate command also +runs 19 gate regression tests using isolated broken fixtures; these do not change +library coverage. Individual checks are available: + +- `npm run check:dependencies`: rejects runtime dependencies and non-relative + imports/re-exports in built JavaScript, except `react` and `react/jsx-runtime` + under `dist/react/`. React remains an optional peer and a development dependency + for adapter tests. Computed or escaped module specifiers require review and fail + the static gate. +- `npm run check:tree-shaking`: bundles package-name fixtures with `bun build + --minify`, checks `sideEffects:false`, excludes the embedded WASM payload from + `parseColor` and `smaValues`, and requires it in `createChart`. Byte budgets are + 2,400 / 800 / 240,000 respectively (measured baseline: 1,762 / 556 / 176,960 with + Bun 1.4.4; CI uses 1.4.2). Review intentional growth before adjusting budgets. + This is a bundle regression gate, not proof that every module has no side effects. +- `npm run check:tsrs`: requires identical exact workspace compiler pins and + matching `tsrs` / `@tsrs/*` locked versions. Renovate groups those updates; + the separate `typecheck:tsc` CI step exercises the fallback compiler. +- `npm run check:lockfiles`: compares package names and sets of resolved versions + in `bun.lock` and `package-lock.json`, excluding workspace links. Update both + lockfiles together. This compares versions, not hoisting layout or integrity hashes. + +To exercise a check against a fixture, use +`node scripts/check-invariants.mjs `. +Temporary bundle and test directories are created inside that root and removed +on completion. No additional dependencies are needed. diff --git a/package.json b/package.json index dcceb83..b99c43e 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,13 @@ "lint": "npm --workspace @bloxwap/chart run lint:icons && node apps/docs/scripts/hig-tokens.mjs --check", "typecheck": "npm run build:ts && npm run docs:check", "typecheck:tsc": "npm --workspace @bloxwap/chart run build:ts:tsc && npm run docs:check:tsc", - "docs:test": "node apps/docs/scripts/test-hig-browser.mjs" + "docs:test": "node apps/docs/scripts/test-hig-browser.mjs", + "check:invariants": "node scripts/check-invariants.mjs && npm run test:invariants", + "check:dependencies": "node scripts/check-invariants.mjs dependencies", + "check:tree-shaking": "node scripts/check-invariants.mjs tree-shaking", + "check:tsrs": "node scripts/check-invariants.mjs tsrs", + "check:lockfiles": "node scripts/check-invariants.mjs lockfiles", + "test:invariants": "node --test scripts/invariants/*.test.mjs" }, "engines": { "node": ">=24" diff --git a/renovate.json b/renovate.json index 3335fef..d934d35 100644 --- a/renovate.json +++ b/renovate.json @@ -1,4 +1,15 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": ["local>bloxwap/.github:renovate-config"] + "extends": [ + "local>bloxwap/.github:renovate-config" + ], + "packageRules": [ + { + "groupName": "tsrs compiler", + "matchPackageNames": [ + "tsrs", + "@tsrs/*" + ] + } + ] } diff --git a/scripts/check-invariants.mjs b/scripts/check-invariants.mjs new file mode 100644 index 0000000..ec8ba8e --- /dev/null +++ b/scripts/check-invariants.mjs @@ -0,0 +1,15 @@ +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { checkDependencies, checkTreeShaking, checkTsrs, checkLocks } from './invariants/checks.mjs'; + +const checks = { dependencies: checkDependencies, 'tree-shaking': checkTreeShaking, tsrs: checkTsrs, lockfiles: checkLocks }; +// Optional root supports isolated negative fixtures without modifying the checkout. +const [name = 'all', root = fileURLToPath(new URL('..', import.meta.url))] = process.argv.slice(2); +try { + if (name === 'all') for (const check of Object.values(checks)) check(resolve(root)); + else if (checks[name]) checks[name](resolve(root)); + else throw new Error(`chart-ts: unknown invariant check ${name}`); +} catch (error) { + console.error(error.message); + process.exitCode = 1; +} diff --git a/scripts/invariants/checks.mjs b/scripts/invariants/checks.mjs new file mode 100644 index 0000000..76478d8 --- /dev/null +++ b/scripts/invariants/checks.mjs @@ -0,0 +1,155 @@ +import { cpSync, mkdtempSync, readFileSync, readdirSync, rmSync, mkdirSync, writeFileSync, existsSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { execFileSync } from 'node:child_process'; + +const fail = (message) => { throw new Error(`chart-ts: ${message}`); }; +const read = (path) => readFileSync(path, 'utf8'); +const json = (path) => JSON.parse(read(path)); +const sections = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']; + +// Preserve strings while removing JSONC trailing commas. +export function parseBunLock(text) { + return JSON.parse(text.replace(/"(?:\\.|[^"\\])*"|,\s*(?=[}\]])/g, + (token) => token.startsWith('"') ? token : '')); +} + +export function lockVersions(root) { + const npm = new Map(); + const bun = new Map(); + function add(map, name, version) { + if (!version) fail(`missing locked version for ${name}`); + if (!map.has(name)) map.set(name, new Set()); + map.get(name).add(version); + } + for (const [path, entry] of Object.entries(json(join(root, 'package-lock.json')).packages)) { + if (entry.link || !path.includes('node_modules/')) continue; + add(npm, entry.name ?? path.split('node_modules/').at(-1), entry.version); + } + for (const entry of Object.values(parseBunLock(read(join(root, 'bun.lock'))).packages)) { + const resolved = entry[0]; + const split = resolved.lastIndexOf('@'); + const name = resolved.slice(0, split); + const version = resolved.slice(split + 1); + if (version.startsWith('workspace:')) continue; + add(bun, name, version); + } + return { npm, bun }; +} + +export function checkLocks(root) { + const { npm, bun } = lockVersions(root); + const errors = []; + for (const name of new Set([...npm.keys(), ...bun.keys()])) { + const a = [...(npm.get(name) ?? [])].sort().join(', '); + const b = [...(bun.get(name) ?? [])].sort().join(', '); + if (a !== b) errors.push(`${name}: npm=[${a}] bun=[${b}]`); + } + if (errors.length) fail(`lockfile mismatch:\n${errors.join('\n')}`); + console.log(`lockfiles OK: ${npm.size} package names, ${[...npm.values()].reduce((n, v) => n + v.size, 0)} name/version pairs`); +} + +export function checkTsrs(root) { + const manifests = [join(root, 'package.json')]; + for (const dir of ['packages', 'apps']) { + for (const entry of readdirSync(join(root, dir), { withFileTypes: true })) { + const path = join(root, dir, entry.name, 'package.json'); + if (entry.isDirectory() && existsSync(path)) manifests.push(path); + } + } + let pin; + for (const path of manifests) { + const pkg = json(path); + for (const section of sections) { + const version = pkg[section]?.tsrs; + if (version === undefined) continue; + if (!/^\d+\.\d+\.\d+(?:-[\da-zA-Z.-]+)?(?:\+[\da-zA-Z.-]+)?$/.test(version)) fail(`${relative(root, path)}: tsrs must be an exact version, got ${version}`); + if (pin && pin !== version) fail(`${relative(root, path)}: tsrs ${version} differs from ${pin}`); + pin = version; + } + } + if (!pin) fail('no tsrs workspace pin found'); + for (const [lock, versions] of Object.entries(lockVersions(root))) { + let platforms = 0; + for (const [name, values] of versions) { + if (name !== 'tsrs' && !name.startsWith('@tsrs/')) continue; + if (name.startsWith('@tsrs/')) platforms++; + if ([...values].some((value) => value !== pin)) fail(`${lock}: ${name} resolves to ${[...values]}, expected ${pin}`); + } + if (!versions.has('tsrs') || !platforms) fail(`${lock}: missing tsrs/compiler platform packages`); + } + console.log(`tsrs pins OK: ${pin}`); +} + +// Tokenize emitted ESM, ignoring comments and keeping quoted strings whole. +// Reject computed imports because their dependencies cannot be verified statically. +export function moduleSpecifiers(source) { + const tokens = [...source.matchAll(/\/\*[\s\S]*?\*\/|\/\/[^\n]*|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`|[\w$]+|[^\s]/g)] + .map(([token]) => token).filter((token) => !token.startsWith('//') && !token.startsWith('/*')); + const result = []; + const string = (token) => token && (token[0] === '"' || token[0] === "'" || token[0] === '`'); + function take(token) { + if (!string(token) || token.includes('${') || token.includes('\\')) fail('unverifiable module specifier (computed or escaped import)'); + result.push(token.slice(1, -1)); + } + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + if (token === 'import' && tokens[i - 1] !== '.') { + if (tokens[i + 1] === '.') continue; // import.meta + if (tokens[i + 1] === '(') { + take(tokens[i + 2]); + if (![',', ')'].includes(tokens[i + 3])) fail('unverifiable computed dynamic import'); + } else if (string(tokens[i + 1])) take(tokens[i + 1]); + } + if (token === 'from' && string(tokens[i + 1])) take(tokens[i + 1]); + } + return result; +} + +export function checkDependencies(root) { + const pkg = json(join(root, 'packages/chart/package.json')); + if (Object.keys(pkg.dependencies ?? {}).length || Object.keys(pkg.optionalDependencies ?? {}).length) fail('chart must have zero runtime dependencies (including optionalDependencies)'); + if (!pkg.peerDependencies?.react || pkg.peerDependenciesMeta?.react?.optional !== true || Object.keys(pkg.peerDependencies).some((name) => name !== 'react')) fail('react must be the only peer dependency and must be optional'); + // React in devDependencies is intentional for testing the optional adapter. + const dist = join(root, 'packages/chart/dist'); + const files = readdirSync(dist, { recursive: true }).filter((path) => path.endsWith('.js')); + if (!files.includes('index.js')) fail('missing dist/index.js; run npm run build first'); + for (const file of files) { + for (const specifier of moduleSpecifiers(read(join(dist, file)))) { + if (specifier.startsWith('./') || specifier.startsWith('../')) continue; + if (file.replaceAll('\\', '/').startsWith('react/') && ['react', 'react/jsx-runtime'].includes(specifier)) continue; + fail(`dist/${file}: forbidden runtime import ${specifier}`); + } + } + console.log(`zero runtime dependencies OK: scanned ${files.length} JavaScript files`); +} + +export function checkTreeShaking(root) { + const pkgDir = join(root, 'packages/chart'); + if (json(join(pkgDir, 'package.json')).sideEffects !== false) fail('chart must declare sideEffects:false'); + const kernel = read(join(pkgDir, 'dist/wasm/kernels.base64.js')).match(/["'](AGFzb[\da-zA-Z+/=]+)["']/)?.[1]; + if (!kernel) fail('could not identify WASM kernel base64 payload'); + const temp = mkdtempSync(join(root, '.invariants-')); + try { + const installed = join(temp, 'node_modules/@bloxwap/chart'); + mkdirSync(installed, { recursive: true }); + cpSync(join(pkgDir, 'dist'), join(installed, 'dist'), { recursive: true }); + cpSync(join(pkgDir, 'package.json'), join(installed, 'package.json')); + for (const [symbol, subpath, budget, expectedKernel] of [ + ['parseColor', '', 2400, false], + ['smaValues', '/indicators', 800, false], + ['createChart', '', 240000, true], + ]) { + const entry = join(temp, `${symbol}.js`); + const output = join(temp, `${symbol}.bundle.js`); + writeFileSync(entry, `export { ${symbol} } from '@bloxwap/chart${subpath}';\n`); + execFileSync('bun', ['build', entry, '--minify', '--target=browser', `--outfile=${output}`], { stdio: 'pipe' }); + const bundle = read(output); + const size = Buffer.byteLength(bundle); + console.log(`${symbol}: ${size} bytes (budget ${budget}), kernel ${bundle.includes(kernel) ? 'present' : 'absent'}`); + if (bundle.includes(kernel) !== expectedKernel) fail(`${symbol}: unexpected kernel payload ${expectedKernel ? 'absence' : 'presence'}`); + if (size > budget) fail(`${symbol}: bundle ${size} bytes exceeds ${budget} byte budget`); + } + } finally { + rmSync(temp, { recursive: true, force: true }); + } +} diff --git a/scripts/invariants/checks.test.mjs b/scripts/invariants/checks.test.mjs new file mode 100644 index 0000000..7e0ab65 --- /dev/null +++ b/scripts/invariants/checks.test.mjs @@ -0,0 +1,102 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { moduleSpecifiers, parseBunLock } from './checks.mjs'; + +const root = fileURLToPath(new URL('../..', import.meta.url)); +const cli = join(root, 'scripts/check-invariants.mjs'); +function fixture(t) { + const dir = mkdtempSync(join(root, '.invariants-test-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + for (const path of ['package.json', 'package-lock.json', 'bun.lock', 'packages/chart/package.json', 'apps/docs/package.json', 'apps/playground/package.json']) { + mkdirSync(join(dir, path, '..'), { recursive: true }); + cpSync(join(root, path), join(dir, path)); + } + return dir; +} +function edit(dir, path, change) { + const file = join(dir, path); + const value = path === 'bun.lock' ? parseBunLock(readFileSync(file, 'utf8')) : JSON.parse(readFileSync(file, 'utf8')); + change(value); + writeFileSync(file, JSON.stringify(value)); +} +function rejects(check, dir, pattern) { + const result = spawnSync(process.execPath, [cli, check, dir], { encoding: 'utf8' }); + assert.equal(result.status, 1, result.stdout + result.stderr); + assert.match(result.stderr, pattern); +} +function dist(dir) { + cpSync(join(root, 'packages/chart/dist'), join(dir, 'packages/chart/dist'), { recursive: true }); +} + +test('all four gates pass on the built repository', () => { + const result = spawnSync(process.execPath, [cli, 'all', root], { encoding: 'utf8' }); + assert.equal(result.status, 0, result.stdout + result.stderr); +}); +test('#101 rejects a declared runtime dependency', (t) => { + const dir = fixture(t); + edit(dir, 'packages/chart/package.json', (pkg) => { pkg.dependencies = { accidental: '1.0.0' }; }); + rejects('dependencies', dir, /zero runtime dependencies/); +}); +test('#101 rejects a required React peer', (t) => { + const dir = fixture(t); + edit(dir, 'packages/chart/package.json', (pkg) => { pkg.peerDependenciesMeta.react.optional = false; }); + rejects('dependencies', dir, /must be optional/); +}); +for (const [name, source] of [ + ['static bare import', "import x from 'accidental';"], + ['bare re-export', "export * from 'accidental';"], + ['dynamic builtin', "const p = import('node:fs');"], + ['React leaking into core', "import 'react';"], + ['computed dynamic import', "const p = import('./' + name);"], +]) test(`#101 rejects ${name}`, (t) => { + const dir = fixture(t); + dist(dir); + writeFileSync(join(dir, 'packages/chart/dist/leak.js'), source); + rejects('dependencies', dir, /forbidden runtime import|unverifiable/); +}); +test('specifier scanner handles comments, multiline imports and optional adapter imports', () => { + assert.deepEqual(moduleSpecifiers(`// import 'ignored';\n/* export * from 'ignored'; */ + import { x }\n from './local.js'; export { y } from 'react'; import('react/jsx-runtime'); import.meta.url;`), + ['./local.js', 'react', 'react/jsx-runtime']); +}); +test('#102 rejects kernel leaking into parseColor', (t) => { + const dir = fixture(t); + dist(dir); + const kernel = readFileSync(join(dir, 'packages/chart/dist/wasm/kernels.base64.js'), 'utf8').match(/["'](AGFzb[\da-zA-Z+/=]+)["']/)[1]; + writeFileSync(join(dir, 'packages/chart/dist/index.js'), `export function parseColor() { return '${kernel}'; }`); + rejects('tree-shaking', dir, /parseColor: unexpected kernel payload presence/); +}); +test('#102 rejects bundle budget regression without kernel leakage', (t) => { + const dir = fixture(t); + dist(dir); + writeFileSync(join(dir, 'packages/chart/dist/index.js'), `export function parseColor() { return '${'a'.repeat(3000)}'; }`); + rejects('tree-shaking', dir, /exceeds .* byte budget/); +}); +for (const version of ['^0.18.0', '0.19.0']) test(`#103 rejects workspace pin ${version}`, (t) => { + const dir = fixture(t); + edit(dir, 'apps/docs/package.json', (pkg) => { pkg.devDependencies.tsrs = version; }); + rejects('tsrs', dir, /exact version|differs from/); +}); +for (const lock of ['bun.lock', 'package-lock.json']) test(`#103 rejects platform compiler drift in ${lock}`, (t) => { + const dir = fixture(t); + edit(dir, lock, (data) => { + if (lock === 'bun.lock') data.packages['@tsrs/linux-x64-gnu'][0] = '@tsrs/linux-x64-gnu@0.0.1'; + else data.packages['node_modules/@tsrs/linux-x64-gnu'].version = '0.0.1'; + }); + rejects('tsrs', dir, /expected 0.18.0/); +}); +for (const mode of ['npm-only', 'bun-only', 'nested-version']) test(`#104 rejects ${mode} drift`, (t) => { + const dir = fixture(t); + if (mode === 'bun-only') edit(dir, 'bun.lock', (lock) => { lock.packages.accidental = ['accidental@1.0.0']; }); + else edit(dir, 'package-lock.json', (lock) => { + lock.packages[mode === 'npm-only' ? 'node_modules/accidental' : 'node_modules/parent/node_modules/react'] = { version: '0.0.1' }; + }); + rejects('lockfiles', dir, /lockfile mismatch/); +}); +test('Bun trailing comma parsing preserves string contents', () => { + assert.deepEqual(parseBunLock('{"x": ["literal,}",],}'), { x: ['literal,}'] }); +});