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
6 changes: 6 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 32 additions & 1 deletion docs/ci-runners.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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 <dependencies|tree-shaking|tsrs|lockfiles|all> <repo-root>`.
Temporary bundle and test directories are created inside that root and removed
on completion. No additional dependencies are needed.
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
13 changes: 12 additions & 1 deletion renovate.json
Original file line number Diff line number Diff line change
@@ -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/*"
]
}
]
}
15 changes: 15 additions & 0 deletions scripts/check-invariants.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
155 changes: 155 additions & 0 deletions scripts/invariants/checks.mjs
Original file line number Diff line number Diff line change
@@ -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 });
}
}
102 changes: 102 additions & 0 deletions scripts/invariants/checks.test.mjs
Original file line number Diff line number Diff line change
@@ -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,}'] });
});