diff --git a/.github/workflows/javascript-ci.yml b/.github/workflows/javascript-ci.yml index 49c515b01..73f252421 100644 --- a/.github/workflows/javascript-ci.yml +++ b/.github/workflows/javascript-ci.yml @@ -81,18 +81,30 @@ jobs: run: pnpm smoke:dist working-directory: javascript - - name: Lint + # Every workspace package except the root. Also asserts, before linting, + # that dist/ exists and that no package can escape the gate by omitting a + # lint script — both failures used to be silent (#565). + - name: Lint (workspace packages) run: pnpm lint:all working-directory: javascript # Lints the shipped library source (src/, excluding tests) with the root # package config, which the workspace-recursive lint:all does not cover. # Enforces no-non-null-assertion (#751) and the rest of the config on the - # library; extending the gate to tests/examples is tracked in #565. + # library. - name: Lint (library) run: pnpm lint:lib working-directory: javascript + # Everything the root package owns that lint:all does not reach: src/ + # INCLUDING tests, root-level *.ts, and scripts/. The pre-existing + # no-explicit-any debt in test files is recorded per-file in + # eslint-suppressions.json rather than waived by severity, so a NEW one + # fails here while the known 106 stay green (#565). + - name: Lint (root package) + run: pnpm lint:root + working-directory: javascript + - name: Type check run: pnpm typecheck:all working-directory: javascript diff --git a/javascript/demo-sliding-deadline.ts b/javascript/demo-sliding-deadline.ts index ddde05b8c..c63476ad6 100644 --- a/javascript/demo-sliding-deadline.ts +++ b/javascript/demo-sliding-deadline.ts @@ -11,9 +11,9 @@ * * Run: node_modules/.bin/tsx demo-sliding-deadline.ts */ -import { ElevenLabsAgentAdapter } from "./src/voice/adapters/elevenlabs.js"; import { Buffer } from "node:buffer"; import type { RawData } from "ws"; +import { ElevenLabsAgentAdapter } from "./src/voice/adapters/elevenlabs.js"; // ── timing constants ────────────────────────────────────────────────────────── const TIMEOUT_S = 0.5; // 500ms raw idle deadline diff --git a/javascript/eslint-suppressions.json b/javascript/eslint-suppressions.json new file mode 100644 index 000000000..4637bb963 --- /dev/null +++ b/javascript/eslint-suppressions.json @@ -0,0 +1,17 @@ +{ + "src/agents/__tests__/red-team.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 102 + } + }, + "src/agents/judge/__tests__/judge-agent.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 3 + } + }, + "src/tracing/__tests__/setup.test.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + } +} \ No newline at end of file diff --git a/javascript/eslint.config.mjs b/javascript/eslint.config.mjs index 59757a17f..b987ca7b3 100644 --- a/javascript/eslint.config.mjs +++ b/javascript/eslint.config.mjs @@ -57,7 +57,14 @@ export default defineConfig([ languageOptions: { globals: globals.browser }, }, { - files: ["**/*.config.{js,mjs,cjs,ts}", "eslint.config.mjs"], + // Build tooling and the maintenance scripts run under Node, not a browser. + // Without this, `scripts/**` is linted with browser globals and reports + // no-undef on Buffer/process (#565). + files: [ + "**/*.config.{js,mjs,cjs,ts}", + "eslint.config.mjs", + "scripts/**/*.{js,mjs,cjs,ts}", + ], languageOptions: { globals: globals.node }, }, tseslint.configs.recommended, @@ -78,8 +85,7 @@ export default defineConfig([ { // Forbid non-null assertions (`!`) in shipped library source. Tests and // examples legitimately assert known-present fixtures, so the rule is - // scoped to non-test `src/` only; extending the lint gate to the rest of - // the package is tracked in #565. + // scoped to non-test `src/` only. files: ["src/**/*.ts"], ignores: ["src/**/*.test.ts", "src/**/__tests__/**"], rules: { diff --git a/javascript/examples/custom-observability/package.json b/javascript/examples/custom-observability/package.json index cd50ca95a..79287a3e7 100644 --- a/javascript/examples/custom-observability/package.json +++ b/javascript/examples/custom-observability/package.json @@ -3,6 +3,7 @@ "private": true, "type": "module", "scripts": { + "lint": "eslint .", "test:no-auto-init": "tsx test-no-auto-init.ts", "test:scenario-only": "tsx test-scenario-only.ts", "test:custom-scopes": "tsx test-custom-scopes.ts", diff --git a/javascript/examples/custom-observability/scope-name.ts b/javascript/examples/custom-observability/scope-name.ts new file mode 100644 index 000000000..9caed3f2e --- /dev/null +++ b/javascript/examples/custom-observability/scope-name.ts @@ -0,0 +1,22 @@ +import type { ReadableSpan } from "@opentelemetry/sdk-trace-base"; + +/** The two shapes OTel has used for the instrumentation scope, across SDK majors. */ +type ScopedSpan = ReadableSpan & { + instrumentationScope?: { name?: string }; + instrumentationLibrary?: { name?: string }; +}; + +/** + * Returns the instrumentation scope name for a span, handling both + * OTel SDK v1 (instrumentationLibrary) and v2 (instrumentationScope). + * + * Shared rather than copied into each probe: this is a version-compatibility + * shim, and the copy nobody remembers to update is the one that breaks when + * OTel renames the field again. + */ +export function getScopeName(span: ReadableSpan): string { + const s = span as ScopedSpan; + return ( + s.instrumentationScope?.name ?? s.instrumentationLibrary?.name ?? "unknown" + ); +} diff --git a/javascript/examples/custom-observability/test-config-file.ts b/javascript/examples/custom-observability/test-config-file.ts index a8693a53c..c80c64149 100644 --- a/javascript/examples/custom-observability/test-config-file.ts +++ b/javascript/examples/custom-observability/test-config-file.ts @@ -19,18 +19,18 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); process.chdir(path.join(__dirname, "with-config-file")); console.log(`Working directory: ${process.cwd()}`); +import { + run, + AgentRole, + user, + agent, + succeed, + type AgentInput, +} from "@langwatch/scenario"; import { trace } from "@opentelemetry/api"; import type { ReadableSpan } from "@opentelemetry/sdk-trace-base"; -import { run, AgentRole, user, agent, succeed } from "@langwatch/scenario"; - -function getScopeName(span: ReadableSpan): string { - const s = span as any; - return ( - s.instrumentationScope?.name ?? - s.instrumentationLibrary?.name ?? - "unknown" - ); -} +import { getScopeName } from "./scope-name"; + // --- Step 1: Create agents (no LLM needed) --- const dummyUserAgent = { @@ -40,7 +40,7 @@ const dummyUserAgent = { const echoAgent = { role: AgentRole.AGENT as const, - call: async (input: any) => { + call: async (input: AgentInput) => { const lastMessage = input.messages.at(-1); const content = typeof lastMessage?.content === "string" diff --git a/javascript/examples/custom-observability/test-custom-scopes.ts b/javascript/examples/custom-observability/test-custom-scopes.ts index 869f6acc0..4701e7f20 100644 --- a/javascript/examples/custom-observability/test-custom-scopes.ts +++ b/javascript/examples/custom-observability/test-custom-scopes.ts @@ -4,12 +4,6 @@ * Demonstrates the advanced use case where a user wants to include their own * instrumented code (e.g., database calls) alongside scenario spans. */ -import { trace } from "@opentelemetry/api"; -import { - SimpleSpanProcessor, - InMemorySpanExporter, -} from "@opentelemetry/sdk-trace-base"; -import type { ReadableSpan } from "@opentelemetry/sdk-trace-base"; import { run, AgentRole, @@ -19,19 +13,13 @@ import { setupScenarioTracing, withCustomScopes, } from "@langwatch/scenario"; +import { trace } from "@opentelemetry/api"; +import { + SimpleSpanProcessor, + InMemorySpanExporter, +} from "@opentelemetry/sdk-trace-base"; +import { getScopeName } from "./scope-name"; -/** - * Returns the instrumentation scope name for a span, handling both - * OTel SDK v1 (instrumentationLibrary) and v2 (instrumentationScope). - */ -function getScopeName(span: ReadableSpan): string { - const s = span as any; - return ( - s.instrumentationScope?.name ?? - s.instrumentationLibrary?.name ?? - "unknown" - ); -} // --- Step 1: Set up span collection --- const memoryExporter = new InMemorySpanExporter(); @@ -40,7 +28,7 @@ const collectorProcessor = new SimpleSpanProcessor(memoryExporter); setupScenarioTracing({ instrumentations: [], spanProcessors: [collectorProcessor], - langwatch: "disabled" as any, + langwatch: "disabled", }); // --- Step 2: Create a "database" tracer under a custom scope --- @@ -50,7 +38,7 @@ const httpTracer = trace.getTracer("http-server"); // Simulate a database-backed agent const dbAgent = { role: AgentRole.AGENT as const, - call: async (input: any) => { + call: async () => { // Simulate a database query (tagged with custom scope) return dbTracer.startActiveSpan( "db.query SELECT users", @@ -123,7 +111,8 @@ for (const [scope, scopeSpans] of byScope) { // --- Step 6: Show what withCustomScopes would filter --- const filters = withCustomScopes("my-app/database"); console.log("\n--- Filter config for LangWatchTraceExporter ---"); -console.log('withCustomScopes("my-app/database") would include:'); +console.log('withCustomScopes("my-app/database") returns:', filters); +console.log("which would include:"); console.log( ` @langwatch/scenario spans (${byScope.get("@langwatch/scenario")?.length ?? 0})` ); diff --git a/javascript/examples/custom-observability/test-no-auto-init.ts b/javascript/examples/custom-observability/test-no-auto-init.ts index ee19dd803..f85fc5859 100644 --- a/javascript/examples/custom-observability/test-no-auto-init.ts +++ b/javascript/examples/custom-observability/test-no-auto-init.ts @@ -10,8 +10,9 @@ import { trace } from "@opentelemetry/api"; const providerBefore = trace.getTracerProvider(); const providerNameBefore = providerBefore.constructor.name; -// Dynamically import scenario to test the side-effect -const scenario = await import("@langwatch/scenario"); +// Dynamically import scenario to test the side-effect. The module namespace is +// deliberately discarded — the import itself is what this test exercises. +await import("@langwatch/scenario"); // Check the provider AFTER importing scenario const providerAfter = trace.getTracerProvider(); diff --git a/javascript/examples/custom-observability/test-scenario-only.ts b/javascript/examples/custom-observability/test-scenario-only.ts index e10db1bb6..23360ed97 100644 --- a/javascript/examples/custom-observability/test-scenario-only.ts +++ b/javascript/examples/custom-observability/test-scenario-only.ts @@ -4,12 +4,6 @@ * This simulates the production use case: a server process that imports scenario * and only wants scenario-scoped spans, not HTTP/middleware noise. */ -import { trace } from "@opentelemetry/api"; -import { - SimpleSpanProcessor, - InMemorySpanExporter, -} from "@opentelemetry/sdk-trace-base"; -import type { ReadableSpan } from "@opentelemetry/sdk-trace-base"; import { run, AgentRole, @@ -17,21 +11,15 @@ import { agent, succeed, setupScenarioTracing, - scenarioOnly, + type AgentInput, } from "@langwatch/scenario"; +import { trace } from "@opentelemetry/api"; +import { + SimpleSpanProcessor, + InMemorySpanExporter, +} from "@opentelemetry/sdk-trace-base"; +import { getScopeName } from "./scope-name"; -/** - * Returns the instrumentation scope name for a span, handling both - * OTel SDK v1 (instrumentationLibrary) and v2 (instrumentationScope). - */ -function getScopeName(span: ReadableSpan): string { - const s = span as any; - return ( - s.instrumentationScope?.name ?? - s.instrumentationLibrary?.name ?? - "unknown" - ); -} // --- Step 1: Set up a custom InMemorySpanExporter to capture what gets collected --- const memoryExporter = new InMemorySpanExporter(); @@ -41,7 +29,7 @@ const collectorProcessor = new SimpleSpanProcessor(memoryExporter); setupScenarioTracing({ instrumentations: [], // disable auto-instrumentation spanProcessors: [collectorProcessor], - langwatch: "disabled" as any, // don't send to LangWatch for this test + langwatch: "disabled", // don't send to LangWatch for this test }); // --- Step 3: Simulate "server noise" -- create spans that should be filtered out --- @@ -61,7 +49,7 @@ const dummyUserAgent = { const echoAgent = { role: AgentRole.AGENT as const, - call: async (input: any) => { + call: async (input: AgentInput) => { const lastMessage = input.messages.at(-1); const content = typeof lastMessage?.content === "string" diff --git a/javascript/examples/openai-realtime-demo/package.json b/javascript/examples/openai-realtime-demo/package.json index c54c5075e..6d6d69d33 100644 --- a/javascript/examples/openai-realtime-demo/package.json +++ b/javascript/examples/openai-realtime-demo/package.json @@ -7,7 +7,7 @@ "types": "index.ts", "scripts": { "typecheck": "tsc --noEmit", - "lint": "eslint agents/ index.ts", + "lint": "eslint . --ignore-pattern 'realtime-client/**'", "format": "pnpm lint --fix" }, "dependencies": { diff --git a/javascript/package.json b/javascript/package.json index a1ee75616..02f1ab275 100644 --- a/javascript/package.json +++ b/javascript/package.json @@ -20,6 +20,7 @@ "typecheck": "tsc --noEmit", "lint": "eslint .", "lint:lib": "eslint 'src/**/*.ts' --ignore-pattern '**/*.test.ts' --ignore-pattern '**/__tests__/**'", + "lint:root": "eslint . --ignore-pattern 'examples/**'", "format": "eslint . --fix", "test": "vitest run", "test:watch": "vitest", @@ -29,7 +30,7 @@ "clean:all": "pnpm -r --parallel exec rm -rf dist *.tgz .cache", "build:all": "pnpm run build", "typecheck:all": "pnpm -r --parallel run typecheck", - "lint:all": "pnpm -r --parallel run lint", + "lint:all": "node scripts/check-lint-preconditions.mjs && pnpm -r --parallel run lint", "format:all": "pnpm -r --parallel run format", "test:all": "pnpm -r --parallel run test", "vitest-examples": "pnpm -F vitest-examples", diff --git a/javascript/scripts/check-lint-preconditions.mjs b/javascript/scripts/check-lint-preconditions.mjs new file mode 100644 index 000000000..f4656c70e --- /dev/null +++ b/javascript/scripts/check-lint-preconditions.mjs @@ -0,0 +1,156 @@ +/** + * Preconditions for the workspace lint gate (#565). + * + * Two failure modes made real lint debt invisible in CI, and neither surfaced + * as a red build — they surfaced as silence: + * + * 1. `pnpm -r run