Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
15 changes: 13 additions & 2 deletions .github/workflows/javascript-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,18 +81,29 @@ 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/. Carries a
# --max-warnings ceiling equal to the measured no-explicit-any baseline in
# tests, so that debt cannot grow silently (#565).
- name: Lint (root package)
run: pnpm lint:root
working-directory: javascript

- name: Type check
run: pnpm typecheck:all
working-directory: javascript
Expand Down
2 changes: 1 addition & 1 deletion javascript/demo-sliding-deadline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 20 additions & 3 deletions javascript/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -78,12 +85,22 @@ 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: {
"@typescript-eslint/no-non-null-assertion": "error",
},
},
{
// `no-explicit-any` stays an ERROR on the shipped library (currently 0) and
// drops to a warning in tests. Test suites reach private members through
// `(agent as any).internalField` to drive state directly; typing those away
// would mean widening the production API or mirroring its privates, so the
// `any` is the lesser evil. See #565 and dec.2026-08-01-scenario-565-lint-ac-set.
files: ["src/**/*.test.ts", "src/**/__tests__/**/*.ts"],
rules: {
"@typescript-eslint/no-explicit-any": "warn",
},
},
]);
1 change: 1 addition & 0 deletions javascript/examples/custom-observability/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
23 changes: 17 additions & 6 deletions javascript/examples/custom-observability/test-config-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,27 @@ 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";

/** The two shapes OTel has used for the instrumentation scope, across SDK majors. */
type ScopedSpan = ReadableSpan & {
instrumentationScope?: { name?: string };
instrumentationLibrary?: { name?: string };
};

function getScopeName(span: ReadableSpan): string {
const s = span as any;
const s = span as ScopedSpan;
return (
s.instrumentationScope?.name ??
s.instrumentationLibrary?.name ??
"unknown"
s.instrumentationScope?.name ?? s.instrumentationLibrary?.name ?? "unknown"
);
}

Expand All @@ -40,7 +51,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"
Expand Down
31 changes: 18 additions & 13 deletions javascript/examples/custom-observability/test-custom-scopes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -19,17 +13,27 @@ import {
setupScenarioTracing,
withCustomScopes,
} from "@langwatch/scenario";
import { trace } from "@opentelemetry/api";
import {
SimpleSpanProcessor,
InMemorySpanExporter,
} from "@opentelemetry/sdk-trace-base";
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).
*/
function getScopeName(span: ReadableSpan): string {
const s = span as any;
const s = span as ScopedSpan;
return (
s.instrumentationScope?.name ??
s.instrumentationLibrary?.name ??
"unknown"
s.instrumentationScope?.name ?? s.instrumentationLibrary?.name ?? "unknown"
);
}

Expand All @@ -40,7 +44,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 ---
Expand All @@ -50,7 +54,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",
Expand Down Expand Up @@ -123,7 +127,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})`
);
Expand Down
5 changes: 3 additions & 2 deletions javascript/examples/custom-observability/test-no-auto-init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
30 changes: 17 additions & 13 deletions javascript/examples/custom-observability/test-scenario-only.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,36 @@
* 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,
user,
agent,
succeed,
setupScenarioTracing,
scenarioOnly,
type AgentInput,
} from "@langwatch/scenario";
import { trace } from "@opentelemetry/api";
import {
SimpleSpanProcessor,
InMemorySpanExporter,
} from "@opentelemetry/sdk-trace-base";
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).
*/
function getScopeName(span: ReadableSpan): string {
const s = span as any;
const s = span as ScopedSpan;
return (
s.instrumentationScope?.name ??
s.instrumentationLibrary?.name ??
"unknown"
s.instrumentationScope?.name ?? s.instrumentationLibrary?.name ?? "unknown"
);
}

Expand All @@ -41,7 +45,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 ---
Expand All @@ -61,7 +65,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"
Expand Down
2 changes: 1 addition & 1 deletion javascript/examples/openai-realtime-demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
3 changes: 2 additions & 1 deletion javascript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/**' --max-warnings=106",
"format": "eslint . --fix",
"test": "vitest run",
"test:watch": "vitest",
Expand All @@ -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-coverage.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",
Expand Down
85 changes: 85 additions & 0 deletions javascript/scripts/check-lint-coverage.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
#!/usr/bin/env node
/**
* 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 lint` SKIPS a package that has no `lint` script, without
* erroring. `examples/custom-observability` sat that way with 19 problems.
* 2. The examples import `@langwatch/scenario` through its published
* `exports` map, which points at `dist/`. Lint before a build and every
* one of those imports reports `import/no-unresolved` — 63 errors that
* say nothing about code quality.
*
* This script turns both into loud, actionable failures. It runs before
* `lint:all`, so a contributor sees the real cause instead of a wall of
* resolver noise or a green run that checked less than they think.
*/
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

const packageRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
".."
);

/** Workspace packages as pnpm itself resolves them, so the globs stay in one place. */
function workspacePackages() {
const raw = execFileSync(
"pnpm",
["list", "-r", "--depth", "-1", "--json"],
{ cwd: packageRoot, encoding: "utf8" }
);
return JSON.parse(raw);
}

const failures = [];

if (!existsSync(path.join(packageRoot, "dist"))) {
failures.push(
"javascript/dist is missing, so `@langwatch/scenario` cannot resolve from the\n" +
" examples and lint would report import/no-unresolved on every import of it.\n" +
" Run `pnpm build` first (CI does this in the Build step)."
);
}

for (const pkg of workspacePackages()) {
// The root package is linted by `lint:root`, not by the recursive `lint:all`.
if (path.resolve(pkg.path) === packageRoot) continue;

const manifestPath = path.join(pkg.path, "package.json");
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));

const lint = manifest.scripts?.lint;
const where = path.relative(packageRoot, pkg.path);

if (!lint) {
failures.push(
`workspace package "${pkg.name}" (${where}) has no\n` +
" `lint` script, so `pnpm -r run lint` skips it silently and its lint debt is\n" +
" invisible. Add one (`\"lint\": \"eslint .\"`) or the gate does not cover it."
);
} else if (!/^eslint \./.test(lint)) {
// An enumerated file list (`eslint agents/ index.ts`) lints only what someone
// remembered on the day they wrote it; a new sibling file is silently ungated.
// `eslint .` plus --ignore-pattern for nested workspace packages is glob-complete.
failures.push(
`workspace package "${pkg.name}" (${where}) has an enumerated\n` +
` lint script (\`${lint}\`). A file added next to those paths would not be linted.\n` +
" Use `eslint .` and exclude nested workspace packages with --ignore-pattern."
);
}
}

if (failures.length > 0) {
console.error("\nLint gate preconditions failed:\n");
for (const failure of failures) console.error(` - ${failure}\n`);
process.exit(1);
}

console.log(
"Lint gate preconditions OK: dist/ present, every workspace package has a lint script."
);
Loading
Loading