Files
composiohq__composio/ts/scripts/validate-examples.ts
Alberto Schiabel f233e46937 chore(repo): migrate eslint to oxlint and typecheck to TypeScript 7 (#3966)
This PR:

- replaces ESLint with oxlint across the pnpm workspace and the
Bun-based docs site, porting the rules to `.oxlintrc.json` /
`docs/.oxlintrc.json` with behavior parity (restricted-syntax selectors
kept via `oxlint-plugin-eslint`)
- migrates typecheck to TypeScript 7 (`typescript@^7.0.2` catalog) and
keeps a TS6 pin for JS compiler API consumers via a named `ts6` pnpm
catalog (`ts/scripts/validate-examples.ts`, the `@composio/cli` generate
pipeline). The CLI's `typescript` dependency rebinds only the
compiler-API import — its typecheck still runs the root TS7 `tsc`, since
the alias package only ships a `tsc6` bin (documented in
`ts/packages/cli/AGENTS.md`)
- removes the `paths` mappings that pointed `@composio/core` (and, in
`experimental`, `@composio/json-schema-to-zod` plus core-internal
`#`-imports) at sibling `src` directories: under TS7, tsdown's
tsgo-based dts step emitted stray `.d.ts` files next to those
out-of-root sources on every dependent package build. Workspace deps now
resolve through their built dist types, which turbo's `dependsOn:
^build` already guarantees exist — and which the deep-path exports
(`@composio/core/*`) always used anyway
- renames the cli boundary tooling `eslint-boundaries*` →
`lint-boundaries*` and hardens the scanner to reject `oxlint-disable`
spellings so the disable manifest cannot be bypassed
- rewrites inline `eslint-disable` comments to oxlint rule names
(comment-only; no runtime changes), and adds **one new** declared
boundary: `tool-file-uploads.ts` needs `no-restricted-imports` disabled
for `node:crypto` (MD5 for the presigned-upload checksum is not in Web
Crypto), because oxlint also catches dynamic `await import()` where
ESLint did not. The manifest grows 46 → 47 deliberately
- updates CI path filters, `turbo.jsonc` lint inputs, and the docs
typescript-check workflow (renamed to "Docs - Lint and TypeScript
Validation" since it now lints too); drops `eslint`,
`typescript-eslint`, `eslint-config-next`, and `globals` from the
dependency graphs
- ships no changeset: I built `@composio/core` and `@composio/anthropic`
on this branch and on the pre-migration base and diffed the emitted
`dist/**/*.d.mts`. The provider output is byte-identical. Core's output
is **semantically identical but not byte-identical**: TS7 changes quote
style (`"x"` → `'x'`), object-property and union-member ordering in
inferred types, and picks equivalent shorter re-export alias paths for
five signatures (e.g. `OpenAI.Beta.Threads.Runs.Run` →
`OpenAI.Beta.Threads.Run` — verified both names alias the same type in
the shipped typings). Chunk-name hashes shift as a consequence. No type
gains, losses, or shape changes; `attw` and `publint` pass on the TS7
build

## Context

First of a three-PR split of #3958. The type-safety refactors are
stacked on this branch and merge after it:

- docs: https://github.com/ComposioHQ/composio/pull/3967
- `@composio/core`: https://github.com/ComposioHQ/composio/pull/3968
2026-07-28 19:16:57 +05:30

226 lines
6.6 KiB
TypeScript

import * as fs from 'node:fs';
import * as path from 'node:path';
// TS7 (tsgo) ships no JS compiler API yet
// (see: https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/#the-road-forward).
// Use the TS6 alias for transpileModule for now.
import ts from 'typescript6';
const repoRoot = process.cwd();
const examplesRoot = path.join(repoRoot, 'ts', 'examples');
const allowedSourceExtensions = new Set(['.js', '.jsx', '.mjs', '.mts', '.ts', '.tsx', '.json']);
type ValidationIssue = {
example: string;
file: string;
message: string;
};
const issues: ValidationIssue[] = [];
const addIssue = (example: string, file: string, message: string) => {
issues.push({ example, file, message });
};
const collectFiles = (dir: string): string[] => {
const entries = fs.readdirSync(dir, { withFileTypes: true });
return entries.flatMap(entry => {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
return collectFiles(fullPath);
}
return [fullPath];
});
};
const validatePackageJson = (exampleName: string, exampleDir: string) => {
const packageJsonPath = path.join(exampleDir, 'package.json');
if (!fs.existsSync(packageJsonPath)) {
addIssue(exampleName, packageJsonPath, 'Missing package.json');
return;
}
try {
const raw = fs.readFileSync(packageJsonPath, 'utf8');
const pkg = JSON.parse(raw) as {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
name?: string;
private?: boolean;
scripts?: Record<string, string>;
};
if (!pkg.name?.endsWith('-example')) {
addIssue(exampleName, packageJsonPath, 'Package name should end with "-example"');
}
if (pkg.private !== true) {
addIssue(exampleName, packageJsonPath, 'Examples must be marked private');
}
if (!pkg.scripts || (!pkg.scripts.start && !pkg.scripts.dev)) {
addIssue(exampleName, packageJsonPath, 'Examples must expose a start or dev script');
}
const allDeps = {
...(pkg.dependencies ?? {}),
...(pkg.devDependencies ?? {}),
};
if (!Object.keys(allDeps).some(dep => dep.startsWith('@composio/'))) {
addIssue(
exampleName,
packageJsonPath,
'Examples must depend on at least one @composio package'
);
}
} catch (error) {
addIssue(
exampleName,
packageJsonPath,
`Invalid package.json: ${error instanceof Error ? error.message : String(error)}`
);
}
};
const validateTsconfig = (exampleName: string, exampleDir: string) => {
const tsconfigPath = path.join(exampleDir, 'tsconfig.json');
if (!fs.existsSync(tsconfigPath)) {
addIssue(exampleName, tsconfigPath, 'Missing tsconfig.json');
return;
}
try {
const raw = fs.readFileSync(tsconfigPath, 'utf8');
const parsed = ts.parseConfigFileTextToJson(tsconfigPath, raw);
if (parsed.error) {
const message = ts.flattenDiagnosticMessageText(parsed.error.messageText, '\n');
addIssue(exampleName, tsconfigPath, `Invalid tsconfig.json: ${message}`);
return;
}
const config = parsed.config as { include?: string[] } | undefined;
if (!Array.isArray(config?.include) || config.include.length === 0) {
addIssue(exampleName, tsconfigPath, 'tsconfig.json should define a non-empty include array');
}
} catch (error) {
addIssue(
exampleName,
tsconfigPath,
`Invalid tsconfig.json: ${error instanceof Error ? error.message : String(error)}`
);
}
};
const validateSourceFile = (exampleName: string, filePath: string) => {
const extension = path.extname(filePath);
const relativePath = path.relative(repoRoot, filePath);
if (extension === '.json') {
try {
JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (error) {
addIssue(
exampleName,
relativePath,
`Invalid JSON: ${error instanceof Error ? error.message : String(error)}`
);
}
return;
}
const sourceText = fs.readFileSync(filePath, 'utf8');
const isTsFile = ['.ts', '.tsx', '.mts'].includes(extension);
const scriptKind =
extension === '.tsx'
? ts.ScriptKind.TSX
: extension === '.jsx'
? ts.ScriptKind.JSX
: extension === '.js'
? ts.ScriptKind.JS
: extension === '.mjs'
? ts.ScriptKind.JS
: ts.ScriptKind.TS;
const transpiled = ts.transpileModule(sourceText, {
compilerOptions: {
allowJs: true,
module: ts.ModuleKind.ESNext,
target: ts.ScriptTarget.ES2022,
},
fileName: filePath,
reportDiagnostics: true,
});
for (const diagnostic of transpiled.diagnostics ?? []) {
if (diagnostic.category !== ts.DiagnosticCategory.Error) {
continue;
}
const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
const location =
diagnostic.start !== undefined
? ts.getLineAndCharacterOfPosition(
ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.ES2022, true, scriptKind),
diagnostic.start
)
: undefined;
const formattedLocation = location ? `:${location.line + 1}:${location.character + 1}` : '';
addIssue(exampleName, `${relativePath}${formattedLocation}`, `Syntax error: ${message}`);
}
if (isTsFile && sourceText.trim().length === 0) {
addIssue(exampleName, relativePath, 'Source file must not be empty');
}
};
const validateExample = (exampleDirName: string) => {
const exampleDir = path.join(examplesRoot, exampleDirName);
const srcDir = path.join(exampleDir, 'src');
validatePackageJson(exampleDirName, exampleDir);
validateTsconfig(exampleDirName, exampleDir);
if (!fs.existsSync(srcDir) || !fs.statSync(srcDir).isDirectory()) {
addIssue(exampleDirName, path.relative(repoRoot, srcDir), 'Missing src directory');
return;
}
const sourceFiles = collectFiles(srcDir).filter(file =>
allowedSourceExtensions.has(path.extname(file))
);
if (sourceFiles.length === 0) {
addIssue(exampleDirName, path.relative(repoRoot, srcDir), 'No source files found');
return;
}
for (const filePath of sourceFiles) {
validateSourceFile(exampleDirName, filePath);
}
};
const exampleDirs = fs
.readdirSync(examplesRoot, { withFileTypes: true })
.filter(entry => entry.isDirectory())
.map(entry => entry.name)
.sort();
for (const exampleDirName of exampleDirs) {
validateExample(exampleDirName);
}
if (issues.length > 0) {
for (const issue of issues) {
console.error(`[${issue.example}] ${issue.file}: ${issue.message}`);
}
console.error(`\nExample validation failed with ${issues.length} issue(s).`);
process.exit(1);
}
console.log(`Validated ${exampleDirs.length} example packages.`);