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
69 changes: 69 additions & 0 deletions packages/config/scripts/__tests__/check-shared-config.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,75 @@ describe('checkSharedConfig (static)', () => {
for (const reason of Object.values(EXEMPT_WORKSPACES))
expect(reason.length).toBeGreaterThan(20);
});

it('verifies barrel exports match ./networks (passing case)', async () => {
// The sandbox copies the real src/index.ts and src/networks.ts.
expect((await staticCheck()).errors).toEqual([]);
});

it('fails when the barrel re-exports a symbol absent from networks.ts', async () => {
const networksPath = path.join(root, 'packages/config/src/networks.ts');
const original = fs.readFileSync(networksPath, 'utf8');
// Drop the `export` keyword so networks.ts no longer exports getExplorerTxUrl.
fs.writeFileSync(networksPath, original.replace('export function getExplorerTxUrl', 'function getExplorerTxUrl'));
const result = await staticCheck();
expect(codes(result)).toContain(ERROR_CODES.BARREL_EXPORT_MISMATCH);
const mismatch = result.errors.find((e) => e.code === ERROR_CODES.BARREL_EXPORT_MISMATCH);
expect(mismatch.message).toContain('getExplorerTxUrl');
});

it('fails when networks.ts exports a symbol the barrel does not re-export', async () => {
const networksPath = path.join(root, 'packages/config/src/networks.ts');
const original = fs.readFileSync(networksPath, 'utf8');
// Add an export the barrel does not advertise.
fs.writeFileSync(
networksPath,
`${original}\nexport const __unusedExtra = 42;\n`
);
const result = await staticCheck();
expect(codes(result)).toContain(ERROR_CODES.BARREL_EXPORT_MISMATCH);
const mismatch = result.errors.find((e) => e.code === ERROR_CODES.BARREL_EXPORT_MISMATCH);
expect(mismatch.message).toContain('__unusedExtra');
});
});

// ── EXEMPT_WORKSPACES deny-by-default (#1198) ────────────────────────────────

describe('EXEMPT_WORKSPACES deny-by-default', () => {
it('flags TSCONFIG_MISSING for a new workspace without a tsconfig', async () => {
write(root, 'packages/newpkg/package.json', { name: 'newpkg' });
const result = await staticCheck();
expect(codes(result)).toContain(ERROR_CODES.TSCONFIG_MISSING);
expect(result.errors.find((e) => e.code === ERROR_CODES.TSCONFIG_MISSING).file).toBe(
'packages/newpkg/tsconfig.json'
);
});

it('flags ESLINT_MISSING for a workspace with a lint script but no eslint config', async () => {
write(root, 'packages/newpkg/package.json', { name: 'newpkg', scripts: { lint: 'eslint' } });
write(root, 'packages/newpkg/tsconfig.json', {
extends: '../../packages/config/tsconfig.base.json',
});
const result = await staticCheck();
expect(codes(result)).toContain(ERROR_CODES.ESLINT_MISSING);
});

it('exempting a workspace suppresses its errors without suppressing others', async () => {
// Two workspaces, both missing a tsconfig → two TSCONFIG_MISSING errors.
write(root, 'packages/newpkg/package.json', { name: 'newpkg' });
write(root, 'packages/brokenlint/package.json', { name: 'brokenlint' });

const before = await staticCheck();
const beforeMissing = before.errors.filter((e) => e.code === ERROR_CODES.TSCONFIG_MISSING);
expect(beforeMissing).toHaveLength(2);

// Exempt only brokenlint.
const exempt = { ...EXEMPT_WORKSPACES, 'packages/brokenlint': 'test exemption' };
const after = await checkSharedConfig({ root, effective: false, exempt });
const afterMissing = after.errors.filter((e) => e.code === ERROR_CODES.TSCONFIG_MISSING);
expect(afterMissing).toHaveLength(1);
expect(afterMissing[0].file).toBe('packages/newpkg/tsconfig.json');
});
});

// ── Effective (ESLint-computed) enforcement ───────────────────────────────────
Expand Down
99 changes: 87 additions & 12 deletions packages/config/scripts/check-shared-config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const ERROR_CODES = Object.freeze({
ESLINT_UNRESOLVED: 'SHARED_CONFIG_ESLINT_UNRESOLVED',
ESLINT_RULE_WEAKENED: 'SHARED_CONFIG_ESLINT_RULE_WEAKENED',
PACKAGE_FILE_MISSING: 'SHARED_CONFIG_PACKAGE_FILE_MISSING',
BARREL_EXPORT_MISMATCH: 'SHARED_CONFIG_BARREL_EXPORT_MISMATCH',
});

/**
Expand Down Expand Up @@ -169,11 +170,11 @@ export function resolveTsconfigChain(root, file, depth = 0, seen = new Set()) {
return { chain, configs };
}

export function checkTsconfigs(root, workspaces) {
export function checkTsconfigs(root, workspaces, exempt = EXEMPT_WORKSPACES) {
const errors = [];
const base = path.join(root, BASE_TSCONFIG);
for (const ws of workspaces) {
if (EXEMPT_WORKSPACES[ws.id]) continue;
if (exempt[ws.id]) continue;
const primary = path.join(ws.dir, 'tsconfig.json');
if (!fs.existsSync(primary)) {
errors.push(
Expand Down Expand Up @@ -237,16 +238,16 @@ function findEslintConfig(dir) {
return null;
}

function lintWorkspaces(workspaces) {
function lintWorkspaces(workspaces, exempt = EXEMPT_WORKSPACES) {
return workspaces.filter(
(ws) => !EXEMPT_WORKSPACES[ws.id] && typeof ws.pkg.scripts?.lint === 'string'
(ws) => !exempt[ws.id] && typeof ws.pkg.scripts?.lint === 'string'
);
}

/** Static check: each lint workspace's config imports and spreads the shared layer. */
export function checkEslintStatic(root, workspaces) {
export function checkEslintStatic(root, workspaces, exempt = EXEMPT_WORKSPACES) {
const errors = [];
for (const ws of lintWorkspaces(workspaces)) {
for (const ws of lintWorkspaces(workspaces, exempt)) {
const file = findEslintConfig(ws.dir);
if (!file) {
errors.push(
Expand Down Expand Up @@ -296,10 +297,10 @@ function severity(entry) {
* computed config of a probe file, then asserts every shared security rule is
* still `error`. Fails closed if ESLint cannot be loaded.
*/
export async function checkEslintEffective(root, workspaces) {
export async function checkEslintEffective(root, workspaces, exempt = EXEMPT_WORKSPACES) {
const { securityRules } = require(path.join(root, SHARED_ESLINT));
const errors = [];
for (const ws of lintWorkspaces(workspaces)) {
for (const ws of lintWorkspaces(workspaces, exempt)) {
const file = findEslintConfig(ws.dir);
if (!file) continue; // reported by the static check
let computed;
Expand Down Expand Up @@ -345,6 +346,79 @@ export async function checkEslintEffective(root, workspaces) {
return errors;
}

// ── Barrel / networks export parity ───────────────────────────────────────────

/**
* Extracts the named value-exports from a TypeScript barrel/source file by
* scanning `export { ... } from '...'` and `export function/const/class NAME`
* statements. Types-only exports (`export type { ... }`) are intentionally
* excluded because they vanish at runtime.
*/
function extractValueExports(source) {
const names = new Set();
// export { A, B, C } from '...' (not `export type {`)
for (const m of source.matchAll(/^export\s+\{([^}]+)\}\s+from\s+['"][^'"]+['"]/gm)) {
for (const part of m[1].split(',')) {
const name = part.trim().split(/\s+as\s+/).at(-1).trim();
if (name) names.add(name);
}
}
// export function/const/class/let/var Name
for (const m of source.matchAll(/^export\s+(?:async\s+)?(?:function|const|class|let|var)\s+(\w+)/gm)) {
names.add(m[1]);
}
return names;
}

/**
* Verifies that every value symbol re-exported by packages/config/src/index.ts
* is actually exported by packages/config/src/networks.ts (issue #1194).
*
* Failing fast here means a rename in networks.ts surfaces in config:check
* rather than only as a downstream TypeScript error.
*/
export function checkBarrelExports(root) {
const pkgDir = path.join(root, 'packages', 'config');
const indexFile = path.join(pkgDir, 'src', 'index.ts');
const networksFile = path.join(pkgDir, 'src', 'networks.ts');
const errors = [];

if (!fs.existsSync(indexFile) || !fs.existsSync(networksFile)) {
// Missing source files are already caught by checkPackageFiles.
return errors;
}

const indexSource = fs.readFileSync(indexFile, 'utf8');
const networksSource = fs.readFileSync(networksFile, 'utf8');

const barrelExports = extractValueExports(indexSource);
const networksExports = extractValueExports(networksSource);

for (const name of barrelExports) {
if (!networksExports.has(name)) {
errors.push(
violation(
ERROR_CODES.BARREL_EXPORT_MISMATCH,
'packages/config/src/index.ts',
`re-exports "${name}" but packages/config/src/networks.ts does not export it`
)
);
}
}
for (const name of networksExports) {
if (!barrelExports.has(name)) {
errors.push(
violation(
ERROR_CODES.BARREL_EXPORT_MISMATCH,
'packages/config/src/networks.ts',
`exports "${name}" but packages/config/src/index.ts does not re-export it`
)
);
}
}
return errors;
}

// ── @swyft/config package integrity ───────────────────────────────────────────

export function checkPackageFiles(root) {
Expand Down Expand Up @@ -381,14 +455,15 @@ function dedupe(errors) {
* Runs every shared-config invariant. Read-only.
* @returns {Promise<{ errors: Array<{code: string, file: string, message: string}>, workspaces: number }>}
*/
export async function checkSharedConfig({ root = DEFAULT_ROOT, effective = true } = {}) {
export async function checkSharedConfig({ root = DEFAULT_ROOT, effective = true, exempt = EXEMPT_WORKSPACES } = {}) {
const workspaces = listWorkspaces(root);
const errors = [
...checkPackageFiles(root),
...checkTsconfigs(root, workspaces),
...checkEslintStatic(root, workspaces),
...checkBarrelExports(root),
...checkTsconfigs(root, workspaces, exempt),
...checkEslintStatic(root, workspaces, exempt),
];
if (effective) errors.push(...(await checkEslintEffective(root, workspaces)));
if (effective) errors.push(...(await checkEslintEffective(root, workspaces, exempt)));
return { errors, workspaces: workspaces.length };
}

Expand Down
71 changes: 71 additions & 0 deletions scripts/__tests__/fixtures.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,38 @@ describe('checkFixtures invariants', () => {
});
});

describe('checkFixtures/writeFixtures error semantics (#1199)', () => {
let root;
beforeEach(() => {
root = makeSandbox();
});
afterEach(() => {
fs.rmSync(root, { recursive: true, force: true });
});

it('checkFixtures counts all inspected fixtures even when some are invalid', () => {
// Make e2e-seed invalid: non-testnet network is a non-repairable schema violation.
editJson(root, 'fixtures/e2e-seed.json', (s) => {
s.network = 'mainnet';
});
const result = fixtures.checkFixtures({ root });
assert.ok(result.errors.length > 0, 'expected errors for invalid fixture');
assert.ok(
result.fixtures >= 2,
`expected fixtures count >= 2 even with errors, got ${result.fixtures}`
);
});

it('writeFixtures is all-or-nothing: written is [] when any fixture is invalid', () => {
editJson(root, 'fixtures/e2e-seed.json', (s) => {
s.network = 'mainnet';
});
const result = fixtures.writeFixtures({ root, env: {} });
assert.ok(result.errors.length > 0, 'expected blocking errors');
assert.deepEqual(result.written, [], 'written must be [] when errors exist');
});
});

describe('validateE2eSeed', () => {
const seed = () =>
JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'fixtures/e2e-seed.json'), 'utf8'));
Expand Down Expand Up @@ -210,6 +242,45 @@ describe('validateClMathVectors', () => {
});
});

describe('parseRustTickVectors (#1197 — null contract)', () => {
it('returns null when no mod fixture_tests block exists in the source', () => {
assert.equal(fixtures.parseRustTickVectors(''), null);
assert.equal(fixtures.parseRustTickVectors('fn main() { println!("hi"); }'), null);
assert.equal(fixtures.parseRustTickVectors('mod other { (100, 200) }'), null);
});

it('returns null for a source string with no parseable tick vectors', () => {
assert.equal(fixtures.parseRustTickVectors('just some rust code with no tuples'), null);
});

it('distinguishes null (not found) from [] (found but empty)', () => {
// When the module exists but contains no vector tuples, the result is [],
// NOT null — the | null contract means "no module found".
assert.deepEqual(
fixtures.parseRustTickVectors('mod fixture_tests {\n // no vectors here\n}'),
[]
);
});

it('parses vectors with numeric tick and string sqrtPriceX96', () => {
const result = fixtures.parseRustTickVectors(
'mod fixture_tests {\n' +
' (100, 79228162514264337593543950336)\n' +
' (-100, 79624303326835659281511670087)\n' +
'}'
);
assert.ok(Array.isArray(result));
assert.equal(result.length, 2);
assert.equal(result[0].tick, 100);
assert.equal(typeof result[0].tick, 'number');
assert.equal(result[0].sqrtPriceX96, '79228162514264337593543950336');
assert.equal(typeof result[0].sqrtPriceX96, 'string');
assert.equal(result[1].tick, -100);
assert.equal(typeof result[1].tick, 'number');
assert.equal(typeof result[1].sqrtPriceX96, 'string');
});
});

describe('correlation ids and CLI', () => {
it('strips log-injection characters from correlation ids', () => {
assert.equal(fixtures.sanitizeCorrelationId('abc\n{"status":"ok"}'), 'abcstatusok');
Expand Down
19 changes: 19 additions & 0 deletions scripts/fixtures.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,29 @@ export interface FixtureError {
export const ERROR_CODES: Readonly<Record<string, string>>;

export function canonicalJson(value: unknown): string;

/**
* Runs every fixture invariant. Read-only.
*
* @returns `errors` — violations found (empty when all fixtures pass).
* `fixtures` — the number of manifest fixture entries inspected,
* counted regardless of whether errors were found.
*/
export function checkFixtures(options?: { root?: string }): {
errors: FixtureError[];
fixtures: number;
};
/**
* Re-canonicalises fixtures, syncs mirrors and refreshes manifest hashes.
* Refuses under CI and refuses when non-repairable errors exist (fail-closed).
*
* All-or-nothing: when `errors` is non-empty, no files are written and
* `written` is always `[]`.
*
* @returns `errors` — blocking (non-repairable) violations found (empty on success).
* `written` — files that were re-canonicalised or synced
* (always `[]` when `errors` is non-empty).
*/
export function writeFixtures(options?: {
root?: string;
env?: Record<string, string | undefined>;
Expand Down
Loading