From 5f98f049d3acc2de68aaf98298e93ab4690aba7e Mon Sep 17 00:00:00 2001 From: Duncan Mackenzie Date: Mon, 5 Oct 2026 11:56:41 -0700 Subject: [PATCH 1/2] Add advisory checker for hand-written TypeScript samples Type-checks the TypeScript and JavaScript code blocks in docs/ that aren't synced by Snipsync against the latest @temporalio packages from npm, and reports SDK methods, properties, and exports the samples use but the packages don't have. Nothing compiled these samples before, which is how Connection.create() (#5200) stayed on the site for years. The same pass checks docs links in code comments, including a link that only resolves through a catch-all redirect that drops its anchor. Runs weekly and on pull requests that touch docs/develop/typescript/. Findings never fail the job; the scheduled run manages a tracking issue. Accepted exceptions go in bin/typescript-samples-baseline.json. --- .../workflows/check-typescript-samples.yml | 176 ++++ .../workflows/notify-automation-failures.yml | 8 +- AGENTS.md | 6 + bin/check-typescript-samples.js | 960 ++++++++++++++++++ bin/check-typescript-samples.test.js | 576 +++++++++++ bin/typescript-samples-baseline.json | 11 + package.json | 2 + readme/AUTOMATIONS.md | 2 + yarn.lock | 5 + 9 files changed, 1743 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/check-typescript-samples.yml create mode 100644 bin/check-typescript-samples.js create mode 100644 bin/check-typescript-samples.test.js create mode 100644 bin/typescript-samples-baseline.json diff --git a/.github/workflows/check-typescript-samples.yml b/.github/workflows/check-typescript-samples.yml new file mode 100644 index 0000000000..ee8710057e --- /dev/null +++ b/.github/workflows/check-typescript-samples.yml @@ -0,0 +1,176 @@ +name: Check TypeScript Samples + +# Type-checks the hand-written TypeScript and JavaScript samples in docs/ +# against the latest published @temporalio packages, and reports SDK methods, +# properties, and exports that the samples use but the packages don't have. +# The same pass checks docs links written in code comments. +# +# This is advisory. Findings never fail the job: the packages are fetched at +# their latest version, so an SDK release can break a sample without any docs +# change, and a page may deliberately show an older API. On pull requests it +# annotates the changed pages. On its weekly run it opens a tracking issue, +# updates it while findings remain, and closes it once they're gone. The job +# fails only when the check can't run at all, for example when npm is +# unreachable. + +on: + schedule: + # Mondays at 11:00 UTC. + - cron: '0 11 * * 1' + workflow_dispatch: + pull_request: + paths: + - 'docs/develop/typescript/**' + - 'bin/check-typescript-samples.js' + - 'bin/check-typescript-samples.test.js' + - 'bin/typescript-samples-baseline.json' + - '.github/workflows/check-typescript-samples.yml' + +permissions: + contents: read + +concurrency: + group: check-typescript-samples-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + name: Check samples against the SDK packages + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # Pull requests diff against their base to find the changed pages. + # (0 is falsy in an expression, so the condition is written this way.) + fetch-depth: ${{ github.event_name != 'pull_request' && 1 || 0 }} + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + cache: yarn + + - name: Install dependencies + run: yarn install --frozen-lockfile --ignore-scripts + + - name: Test the checker + run: node --test bin/check-typescript-samples.test.js + + # A pull request that changes the checker gets a full scan. Otherwise it + # gets only the pages it changes, so it isn't annotated with findings on + # pages it didn't touch. + - name: Choose the pages to check + id: scope + env: + EVENT: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + if [ "$EVENT" != "pull_request" ]; then + echo "mode=full" >> "$GITHUB_OUTPUT" + exit 0 + fi + + changed=$(git diff --name-only --diff-filter=d "$BASE_SHA"...HEAD) + if echo "$changed" | grep -qE '^(bin/check-typescript-samples|bin/typescript-samples-baseline|\.github/workflows/check-typescript-samples)'; then + echo "mode=full" >> "$GITHUB_OUTPUT" + exit 0 + fi + + pages=$(echo "$changed" | grep -E '^docs/.*\.mdx$' | tr '\n' ' ' || true) + if [ -z "$pages" ]; then + echo "mode=none" >> "$GITHUB_OUTPUT" + else + echo "mode=pages" >> "$GITHUB_OUTPUT" + echo "pages=$pages" >> "$GITHUB_OUTPUT" + fi + + - name: Check the samples + id: check + if: steps.scope.outputs.mode != 'none' + env: + PAGES: ${{ steps.scope.outputs.pages }} + run: | + set -f + set +e + # --github adds a workflow command per finding, which annotates the + # page as tee echoes it. The report proper is everything else. + node bin/check-typescript-samples.js --github $PAGES 2>&1 | tee output.txt + status=${PIPESTATUS[0]} + set -e + + grep -v '^::' output.txt > report.txt || true + + # 0 is clean and 2 is findings. Anything else means the check could + # not run, usually because npm or a package download failed. + if [ "$status" -ne 0 ] && [ "$status" -ne 2 ]; then + echo "::error::The TypeScript sample check could not run. See the log above." + exit "$status" + fi + + if [ "$status" -eq 2 ]; then + echo "findings=true" >> "$GITHUB_OUTPUT" + else + echo "findings=false" >> "$GITHUB_OUTPUT" + fi + + { + echo "### TypeScript samples (informational; does not block merge)" + echo + echo '```' + cat report.txt + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Open or update the tracking issue + if: >- + github.event_name != 'pull_request' && github.ref_name == github.event.repository.default_branch && + steps.check.outputs.findings == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + TITLE: 'Hand-written TypeScript samples need fixes' + run: | + { + echo "\`bin/check-typescript-samples.js\` found hand-written TypeScript samples in" + echo "\`docs/\` that use SDK APIs the latest @temporalio packages don't have, or" + echo "docs links in code comments that don't resolve." + echo + echo '```' + cat report.txt + echo '```' + echo + echo "To resolve, fix the sample, or record the finding with a note in" + echo "\`bin/typescript-samples-baseline.json\` if the page shows that API on purpose." + echo "Reproduce locally with \`yarn check:ts-samples\`." + echo + echo "_Updated by [${GITHUB_WORKFLOW}](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})._" + } > issue-body.md + + number=$(gh issue list --state open --search "in:title \"$TITLE\"" --json number --jq '.[0].number // empty') + + if [ -n "$number" ]; then + gh issue edit "$number" --body-file issue-body.md + echo "Updated issue #$number." + else + gh issue create --title "$TITLE" --body-file issue-body.md + fi + + - name: Close the tracking issue + if: >- + github.event_name != 'pull_request' && github.ref_name == github.event.repository.default_branch && + steps.check.outputs.findings == 'false' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + TITLE: 'Hand-written TypeScript samples need fixes' + run: | + number=$(gh issue list --state open --search "in:title \"$TITLE\"" --json number --jq '.[0].number // empty') + + if [ -n "$number" ]; then + gh issue close "$number" --comment "The checker no longer finds anything to fix." + fi diff --git a/.github/workflows/notify-automation-failures.yml b/.github/workflows/notify-automation-failures.yml index 9d1b12a4bb..a28ddbd76a 100644 --- a/.github/workflows/notify-automation-failures.yml +++ b/.github/workflows/notify-automation-failures.yml @@ -15,6 +15,7 @@ on: - Update Custom Role Permissions - Update SDK Versions - Check Metrics Against SDKs + - Check TypeScript Samples - CLI Docs Update - Delete Visual Tests Reports - Warm Build Cache @@ -27,9 +28,10 @@ permissions: jobs: notify: - # `Check Metrics Against SDKs` and `Environment config drift` also run on - # pull_request; exclude those runs so this only reports invocations that have - # nowhere else to surface, such as the scheduled ones. + # `Check Metrics Against SDKs`, `Check TypeScript Samples`, and + # `Environment config drift` also run on pull_request; exclude those runs so + # this only reports invocations that have nowhere else to surface, such as + # the scheduled ones. if: >- github.event.workflow_run.event != 'pull_request' && contains(fromJSON('["failure", "timed_out", "startup_failure"]'), github.event.workflow_run.conclusion) diff --git a/AGENTS.md b/AGENTS.md index 898a9d9568..90cb8b7b70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -182,6 +182,7 @@ yarn check:metrics # SDK metrics reference against itself; runs in CI on P yarn check:metrics:sdks # SDK metrics reference against the SDK sources; advisory, clones the SDK repos yarn check:orphans # docs pages Docusaurus renders but no sidebar entry links to; not yet wired into CI yarn check:redirects # vercel.json redirect rules that can't work as written; runs in CI on PRs +yarn check:ts-samples # hand-written TypeScript samples against the published SDK packages; advisory ``` `yarn check:metrics:sdks` reports metrics an SDK defines but the page omits. When one is deliberately left undocumented, @@ -197,6 +198,11 @@ belongs in `bin/orphan-pages-baseline.json` with a note, rather than being silen containing `#`, a `:param` with no `/` before it, a rule shadowed by an earlier one, or a destination the site doesn't serve. Fix the rule. A known, accepted exception belongs in `bin/redirect-baseline.json` with a note. +`yarn check:ts-samples` type-checks the TypeScript and JavaScript code blocks that aren't synced by Snipsync against +the latest `@temporalio` packages, and reports methods, properties, and exports the packages don't have, plus docs +links in code comments that don't resolve. Pass page paths to check only those pages. A sample that shows an API +on purpose belongs in `bin/typescript-samples-baseline.json` with a note. + Vale linting (style). Requires Vale 3.20+ (CI already runs 3.20.0; upgrade a local install with `brew upgrade vale`) — `vale/styles/Std` needs it for its nested rule directories. diff --git a/bin/check-typescript-samples.js b/bin/check-typescript-samples.js new file mode 100644 index 0000000000..e34673532c --- /dev/null +++ b/bin/check-typescript-samples.js @@ -0,0 +1,960 @@ +#!/usr/bin/env node + +// Type-checks the hand-written TypeScript and JavaScript samples in docs/ +// against the published @temporalio packages, and reports SDK methods, +// properties, and exports that the samples use but the packages don't have. +// +// Samples synced by Snipsync come from sample repositories that compile in CI. +// Most TypeScript samples on the site are written inline instead, and nothing +// compiles them: the Docusaurus build treats code fences as opaque strings and +// Vale skips code. A sample calling Connection.create(), which no release of +// @temporalio/client has had, stayed on the site for years. +// +// Every hand-written sample goes into one TypeScript program alongside the +// declaration files from the latest @temporalio packages. Samples are +// fragments, so most of what the compiler reports is noise: undeclared +// variables, missing imports, placeholder arguments. Only diagnostics about a +// type or module declared in an @temporalio package are kept, which is the +// class of mistake a reader copying the sample would hit. +// +// The same pass checks docs links written in code comments, which the link +// checker doesn't see. +// +// This is advisory rather than a merge gate. The packages are fetched at +// their latest version, so a sample can start failing when an SDK release +// removes something, and a page may deliberately show an older API. +// +// node bin/check-typescript-samples.js # report +// node bin/check-typescript-samples.js docs/develop/typescript/client +// node bin/check-typescript-samples.js --json # machine-readable +// node bin/check-typescript-samples.js --github # also print Actions annotations +// node bin/check-typescript-samples.js --update-baseline # accept current findings +// node bin/check-typescript-samples.js --sdk-version 1.24.0 # instead of latest +// node bin/check-typescript-samples.js --cache-dir /tmp/ts-sdk +// +// Exit codes: 0 clean, 2 findings (or stale baseline entries), 1 the check +// could not run at all, for example because npm was unreachable. + +const { execFile } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { promisify } = require('util'); +const ts = require('typescript'); + +const execFileAsync = promisify(execFile); + +const DOCS_DIR = 'docs'; +const BASELINE = path.join('bin', 'typescript-samples-baseline.json'); + +const DEFAULT_COMMENT = + 'Findings from bin/check-typescript-samples.js that we have decided to leave as they are, for ' + + 'example a sample that deliberately shows an older API. Each entry is a page, a kind, and a subject; ' + + 'line numbers are left out so an entry survives edits elsewhere on the page. An empty note means the ' + + 'entry has not been reviewed yet; either fix the sample or fill in the note explaining why it stays. ' + + 'Regenerate with: node bin/check-typescript-samples.js --update-baseline'; + +const LANGUAGES = new Set(['ts', 'typescript', 'js', 'javascript']); + +// --------------------------------------------------------------------------- +// Extraction +// --------------------------------------------------------------------------- + +// Snipsync wraps synced code in either an HTML comment or an MDX comment. +const SNIPSTART = /^\s*(?:/g, '').replace(/\{\/\*.*?\*\/\}/g, ''); + if (rest.includes(''; + if (rest.includes('{/*')) return '*/}'; + return null; +} + +function dedent(line, indent) { + let i = 0; + while (i < indent && (line[i] === ' ' || line[i] === '\t')) i++; + return line.slice(i); +} + +// Every fenced code block on a page, with the 1-based line of its first line +// of code and whether Snipsync manages it. Code inside a list item or a JSX +// component is indented to match its fence; that indentation is removed. +function extractCodeBlocks(source) { + const lines = source.split('\n'); + const blocks = []; + let fence = null; + let snipsync = false; + let comment = null; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (fence) { + const close = line.match(FENCE_CLOSE); + if (close && close[1][0] === fence.marker[0] && close[1].length >= fence.marker.length) { + blocks.push({ + lang: fence.lang, + line: fence.line, + code: fence.body.join('\n'), + snipsync: fence.snipsync, + }); + fence = null; + } else { + fence.body.push(dedent(line, fence.indent)); + } + continue; + } + + if (comment) { + if (line.includes(comment)) comment = null; + continue; + } + + if (SNIPSTART.test(line)) { + snipsync = true; + continue; + } + if (SNIPEND.test(line)) { + snipsync = false; + continue; + } + + const open = line.match(FENCE_OPEN); + if (open) { + fence = { + marker: open[2], + indent: open[1].length, + lang: open[3].toLowerCase(), + line: i + 2, + body: [], + snipsync, + }; + continue; + } + + comment = openComment(line); + } + + return blocks; +} + +// The hand-written TypeScript and JavaScript samples on a page. +function extractSamples(source) { + return extractCodeBlocks(source).filter((b) => LANGUAGES.has(b.lang) && !b.snipsync); +} + +function walkMdx(target) { + const stat = fs.statSync(target); + if (stat.isFile()) return target.endsWith('.mdx') ? [target] : []; + return fs + .readdirSync(target, { withFileTypes: true }) + .sort((a, b) => a.name.localeCompare(b.name)) + .flatMap((entry) => walkMdx(path.join(target, entry.name))); +} + +function collectSamples(targets) { + const samples = []; + let snipsync = 0; + for (const file of [...new Set(targets.flatMap(walkMdx))]) { + const blocks = extractCodeBlocks(fs.readFileSync(file, 'utf8')); + for (const block of blocks) { + if (!LANGUAGES.has(block.lang)) continue; + if (block.snipsync) { + snipsync++; + continue; + } + samples.push({ file: file.split(path.sep).join('/'), line: block.line, code: block.code }); + } + } + return { samples, snipsync }; +} + +// The @temporalio package names a sample imports or requires. +function importedPackages(code) { + const names = new Set(); + for (const { fileName } of ts.preProcessFile(code, true, true).importedFiles) { + const m = fileName.match(/^(@temporalio\/[^/]+)/); + if (m) names.add(m[1]); + } + return names; +} + +// --------------------------------------------------------------------------- +// Packages +// --------------------------------------------------------------------------- + +// Always fetched, because the ambient declarations below refer to them. +const CORE_PACKAGES = ['client', 'worker', 'workflow', 'activity', 'testing', 'common'].map( + (name) => `@temporalio/${name}` +); + +// Never fetched. The native bridge ships a prebuilt binary for every platform, +// about 150 MB, and declares nothing a sample calls directly. +const SKIP_PACKAGES = new Set(['@temporalio/core-bridge']); + +// npm pack downloads one tarball without installing dependencies. Installing +// @temporalio/worker would also pull in webpack, swc, and the native bridge, +// when only the declaration files are needed. +async function packPackage(name, version, cacheDir) { + const tarballs = path.join(cacheDir, 'tarballs'); + fs.mkdirSync(tarballs, { recursive: true }); + + let stdout; + try { + ({ stdout } = await execFileAsync('npm', ['pack', `${name}@${version}`, '--json', '--pack-destination', tarballs], { + maxBuffer: 1024 * 1024 * 64, + })); + } catch (error) { + const detail = `${error.stdout ?? ''}${error.stderr ?? ''}`; + // A package that doesn't exist is a finding about the samples that import + // it. Anything else, such as a network failure, stops the check. + if (/\bE404\b/.test(detail)) return { name, missing: true }; + if (/\bETARGET\b/.test(detail)) return { name, noSuchVersion: true }; + throw new Error(`npm pack ${name}@${version} failed:\n${detail.trim() || error.message}`); + } + + const [packed] = JSON.parse(stdout); + const target = path.join(cacheDir, 'node_modules', name); + fs.mkdirSync(target, { recursive: true }); + await execFileAsync('tar', ['-xzf', path.join(tarballs, packed.filename), '-C', target, '--strip-components=1']); + + const manifest = JSON.parse(fs.readFileSync(path.join(target, 'package.json'), 'utf8')); + const dependencies = Object.keys({ ...manifest.dependencies, ...manifest.peerDependencies }).filter((d) => + d.startsWith('@temporalio/') + ); + return { name, version: packed.version, dependencies }; +} + +// Fetches the requested packages and every @temporalio package they depend +// on, so a class that extends one from another package keeps its inherited +// members. +async function fetchPackages(requested, version, cacheDir) { + fs.rmSync(path.join(cacheDir, 'node_modules'), { recursive: true, force: true }); + + const results = new Map(); + let wave = [...new Set(requested)].filter((name) => !SKIP_PACKAGES.has(name)); + + while (wave.length > 0) { + const fetched = await Promise.all(wave.map((name) => packPackage(name, version, cacheDir))); + for (const result of fetched) results.set(result.name, result); + wave = [ + ...new Set( + fetched.flatMap((r) => r.dependencies ?? []).filter((name) => !results.has(name) && !SKIP_PACKAGES.has(name)) + ), + ]; + } + + return [...results.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +// --------------------------------------------------------------------------- +// Type checking +// --------------------------------------------------------------------------- + +// Names a sample often uses without importing, because the import is in an +// earlier sample on the page. They're declared as globals, so a sample's own +// import or declaration of the same name shadows them without a conflict. +const AMBIENT = [ + ['Connection', "typeof import('@temporalio/client').Connection"], + ['Client', "typeof import('@temporalio/client').Client"], + ['WorkflowClient', "typeof import('@temporalio/client').WorkflowClient"], + ['ScheduleClient', "typeof import('@temporalio/client').ScheduleClient"], + ['Worker', "typeof import('@temporalio/worker').Worker"], + ['NativeConnection', "typeof import('@temporalio/worker').NativeConnection"], + ['Runtime', "typeof import('@temporalio/worker').Runtime"], + ['TestWorkflowEnvironment', "typeof import('@temporalio/testing').TestWorkflowEnvironment"], + ['MockActivityEnvironment', "typeof import('@temporalio/testing').MockActivityEnvironment"], + ['Context', "typeof import('@temporalio/activity').Context"], + ['ApplicationFailure', "typeof import('@temporalio/common').ApplicationFailure"], + // Conventions across the TypeScript pages. `client` being a Client is what + // makes client.workflow.* and client.schedule.* checkable. `handle` is left + // out because it's a WorkflowHandle on some pages and a ScheduleHandle on + // others. + ['wf', "typeof import('@temporalio/workflow')"], + ['client', "import('@temporalio/client').Client"], + ['testEnv', "import('@temporalio/testing').TestWorkflowEnvironment"], +]; + +function ambientDeclarations() { + return AMBIENT.map(([name, type]) => `declare const ${name}: ${type};`).join('\n') + '\n'; +} + +const COMPILER_OPTIONS = { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + // Every sample is its own module, so top-level declarations in one sample + // don't collide with or leak into another. + moduleDetection: ts.ModuleDetectionKind.Force, + lib: ['lib.es2023.d.ts'], + // Node's types, from this repository's own devDependencies. Some SDK + // classes extend Node classes, such as MockActivityEnvironment extending + // EventEmitter, and their inherited members are otherwise unknown. + typeRoots: [path.join(__dirname, '..', 'node_modules', '@types')], + types: ['node'], + strict: false, + noImplicitAny: false, + skipLibCheck: true, + noEmit: true, + esModuleInterop: true, + resolveJsonModule: true, + allowUnreachableCode: true, + allowUnusedLabels: true, + experimentalDecorators: true, +}; + +// A compiler host that serves `files` from memory and everything else from +// disk. Samples are always in memory; tests also put fake packages there. +function createHost(files) { + const host = ts.createCompilerHost(COMPILER_OPTIONS, true); + const directories = new Set(); + for (const file of files.keys()) { + for (let dir = path.posix.dirname(file); !directories.has(dir); dir = path.posix.dirname(dir)) { + directories.add(dir); + if (dir === path.posix.dirname(dir)) break; + } + } + + const getSourceFile = host.getSourceFile.bind(host); + host.getSourceFile = (fileName, languageVersion, onError, shouldCreate) => + files.has(fileName) + ? ts.createSourceFile(fileName, files.get(fileName), languageVersion, true) + : getSourceFile(fileName, languageVersion, onError, shouldCreate); + host.fileExists = (fileName) => files.has(fileName) || ts.sys.fileExists(fileName); + host.readFile = (fileName) => (files.has(fileName) ? files.get(fileName) : ts.sys.readFile(fileName)); + host.directoryExists = (dir) => directories.has(dir) || ts.sys.directoryExists(dir); + host.realpath = (p) => (files.has(p) || directories.has(p) ? p : ts.sys.realpath(p)); + host.writeFile = () => {}; + return host; +} + +// The package that declares a file, if it's one of ours. +function packageOfFile(fileName) { + const m = fileName.match(/\/node_modules\/(@temporalio\/[^/]+)\//); + return m ? m[1] : null; +} + +function packageOfSymbol(symbol) { + for (const declaration of symbol?.declarations ?? []) { + const pkg = packageOfFile(declaration.getSourceFile().fileName); + if (pkg) return pkg; + } + return null; +} + +// False when a class or interface extends something the compiler couldn't +// resolve, such as a type from a package that wasn't fetched. Its members +// are then only partly known, and a missing one proves nothing. +function membersAreKnown(symbol, checker, seen = new Set()) { + if (!symbol || seen.has(symbol)) return true; + seen.add(symbol); + for (const declaration of symbol.declarations ?? []) { + for (const clause of declaration.heritageClauses ?? []) { + if (clause.token !== ts.SyntaxKind.ExtendsKeyword) continue; + for (const base of clause.types) { + const type = checker.getTypeAtLocation(base); + if (type.flags & ts.TypeFlags.Any) return false; + if (!membersAreKnown(type.aliasSymbol ?? type.getSymbol(), checker, seen)) return false; + } + } + } + return true; +} + +// The @temporalio type a property was looked up on, or null when the type +// isn't ours or its members can't all be known. `module` is set when the +// type is a namespace import (import * as wf), which is the module itself. +function sdkOwner(type, checker) { + const types = type.isUnionOrIntersection() ? type.types : [type]; + for (const t of types) { + for (const symbol of [t.aliasSymbol, t.getSymbol()]) { + const pkg = packageOfSymbol(symbol); + if (!pkg) continue; + if (!membersAreKnown(symbol, checker)) return null; + const isModule = Boolean(symbol.flags & ts.SymbolFlags.ValueModule) && symbol.getName().startsWith('"'); + return { pkg, owner: symbol.getName(), module: isModule }; + } + } + return null; +} + +// Compiler messages name modules by absolute path inside the download +// directory, and quote module names twice. Name them by package instead. +function tidy(message) { + return message + .replace(/import\("[^"]*\/node_modules\/(@temporalio\/[^/"]+)[^"]*"\)/g, "import('$1')") + .replace(/'"(@temporalio\/[^"]+)"'/g, "'$1'"); +} + +// The innermost node that starts exactly at `position`. +function nodeAt(sourceFile, position) { + let found = null; + const visit = (node) => { + if (position < node.getFullStart() || position >= node.getEnd()) return; + if (node.getStart(sourceFile) === position) found = node; + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return found; +} + +function moduleSpecifierOf(node) { + for (let n = node; n; n = n.parent) { + if ((ts.isImportDeclaration(n) || ts.isExportDeclaration(n)) && n.moduleSpecifier) { + return ts.isStringLiteral(n.moduleSpecifier) ? n.moduleSpecifier.text : null; + } + if (ts.isImportEqualsDeclaration(n)) { + const ref = n.moduleReference; + return ts.isExternalModuleReference(ref) && ts.isStringLiteral(ref.expression) ? ref.expression.text : null; + } + } + return null; +} + +function squash(text, max = 60) { + const flat = text.replace(/\s+/g, ' ').trim(); + return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat; +} + +// Property access: 2339 "Property 'x' does not exist on type 'Y'", 2551 the +// same with a spelling suggestion, 2576 an instance access of a static member. +const MISSING_MEMBER = new Set([2339, 2551, 2576]); +// 2305 "Module has no exported member", 2724 the same with a suggestion, 2614 +// the same when a default import would work. +const MISSING_EXPORT = new Set([2305, 2724, 2614]); +// 2694 "Namespace 'wf' has no exported member 'X'", in a type position. +const MISSING_NAMESPACE_MEMBER = new Set([2694]); +// 2307 "Cannot find module", 2792 the same with a moduleResolution hint. +const MISSING_MODULE = new Set([2307, 2792]); + +// Turns one compiler diagnostic into a finding, or null when it isn't about +// something an @temporalio package declares. +function classify(diagnostic, checker, packages) { + const { file: sourceFile, start, code } = diagnostic; + if (!sourceFile || start === undefined) return null; + const node = nodeAt(sourceFile, start); + if (!node) return null; + const message = tidy(ts.flattenDiagnosticMessageText(diagnostic.messageText, ' ')); + + if (MISSING_MEMBER.has(code)) { + const access = node.parent; + if (!access || !ts.isPropertyAccessExpression(access) || access.name !== node) return null; + const owner = sdkOwner(checker.getTypeAtLocation(access.expression), checker); + if (!owner) return null; + const expression = squash(access.getText(sourceFile)); + if (owner.module) { + return { + kind: 'missing-export', + subject: `${owner.pkg}:${node.text}`, + pkg: owner.pkg, + message: `${expression}: ${owner.pkg} has no export named '${node.text}'.`, + }; + } + return { + kind: 'missing-member', + subject: `${owner.owner}.${node.text}`, + pkg: owner.pkg, + message: `${expression}: ${message}`, + }; + } + + if (MISSING_EXPORT.has(code)) { + const specifier = moduleSpecifierOf(node); + if (!specifier?.startsWith('@temporalio/')) return null; + return { + kind: 'missing-export', + subject: `${specifier}:${node.getText(sourceFile)}`, + pkg: specifier.match(/^@temporalio\/[^/]+/)[0], + message, + }; + } + + if (MISSING_NAMESPACE_MEMBER.has(code)) { + const qualified = node.parent; + if (!qualified || !ts.isQualifiedName(qualified) || qualified.right !== node) return null; + let symbol = checker.getSymbolAtLocation(qualified.left); + if (symbol && symbol.flags & ts.SymbolFlags.Alias) symbol = checker.getAliasedSymbol(symbol); + const pkg = packageOfSymbol(symbol); + if (!pkg) return null; + return { + kind: 'missing-export', + subject: `${pkg}:${node.text}`, + pkg, + message, + }; + } + + if (MISSING_MODULE.has(code)) { + if (!ts.isStringLiteral(node) || !node.text.startsWith('@temporalio/')) return null; + const pkg = node.text.match(/^@temporalio\/[^/]+/)[0]; + const info = packages.get(pkg); + // A package that was skipped, or that has no release at the requested + // version, says nothing about the sample. + if (!info || info.noSuchVersion) return null; + return { + kind: 'missing-module', + subject: node.text, + pkg, + message: info.missing + ? `${pkg} is not published on npm.` + : `${pkg}@${info.version} has no module '${node.text}'.`, + }; + } + + return null; +} + +// Builds one program holding every sample. `root` is the directory whose +// node_modules holds the packages; `files` are extra in-memory files, which +// tests use for fake packages. +function createSampleProgram(samples, { root, files = new Map() }) { + const posixRoot = root.split(path.sep).join('/'); + const virtual = new Map(files); + const ambient = `${posixRoot}/samples/ambient.d.ts`; + virtual.set(ambient, ambientDeclarations()); + + const byFile = new Map(); + samples.forEach((sample, i) => { + const fileName = `${posixRoot}/samples/sample-${i}.ts`; + virtual.set(fileName, sample.code); + byFile.set(fileName, sample); + }); + + const program = ts.createProgram({ + rootNames: [ambient, ...byFile.keys()], + options: COMPILER_OPTIONS, + host: createHost(virtual), + }); + return { program, byFile }; +} + +// Type-checks every sample and returns the findings. `packages` maps package +// name to the result of fetching it. +function checkSamples(samples, { root, files, packages = new Map() }) { + const { program, byFile } = createSampleProgram(samples, { root, files }); + const checker = program.getTypeChecker(); + + const findings = []; + for (const [fileName, sample] of byFile) { + const sourceFile = program.getSourceFile(fileName); + for (const diagnostic of program.getSemanticDiagnostics(sourceFile)) { + const finding = classify(diagnostic, checker, packages); + if (!finding) continue; + const { line } = sourceFile.getLineAndCharacterOfPosition(diagnostic.start); + const version = packages.get(finding.pkg)?.version; + findings.push({ + file: sample.file, + line: sample.line + line, + kind: finding.kind, + subject: finding.subject, + message: version ? `${finding.message} (${finding.pkg}@${version})` : finding.message, + }); + } + } + return findings; +} + +// --------------------------------------------------------------------------- +// Links in code comments +// --------------------------------------------------------------------------- + +// Every comment in a sample, found from the parse tree so a `//` inside a +// string or URL isn't mistaken for one. +function commentsIn(code) { + const sourceFile = ts.createSourceFile('sample.ts', code, ts.ScriptTarget.Latest, true); + const seen = new Set(); + const comments = []; + const collect = (ranges) => { + for (const range of ranges ?? []) { + if (seen.has(range.pos)) continue; + seen.add(range.pos); + comments.push({ + text: code.slice(range.pos, range.end), + line: sourceFile.getLineAndCharacterOfPosition(range.pos).line, + pos: range.pos, + }); + } + }; + const visit = (node) => { + collect(ts.getLeadingCommentRanges(code, node.getFullStart())); + collect(ts.getTrailingCommentRanges(code, node.getEnd())); + for (const child of node.getChildren(sourceFile)) visit(child); + }; + visit(sourceFile); + return comments.sort((a, b) => a.pos - b.pos); +} + +const ABSOLUTE_DOCS_URL = /https?:\/\/docs\.temporal\.io(\/[^\s'"`<>()[\]]*)?/g; +// A site-relative path, starting at a word boundary rather than inside a URL. +const RELATIVE_PATH = /(? { + const found = new Set(); + for (const m of text.matchAll(ABSOLUTE_DOCS_URL)) { + found.add(m[1] ?? '/'); + text = text.replace(m[0], ' '); + } + for (const m of text.matchAll(RELATIVE_PATH)) { + if (isSitePath(m[1])) found.add(m[1]); + } + for (const href of found) { + links.push({ line: comment.line + offset, href: href.replace(/[.,;:]+$/, '') }); + } + }); + } + return links; +} + +// Heading anchors on a page, computed the way Docusaurus does: an explicit +// {#id}, otherwise the heading's text through github-slugger. Plus any id +// attribute written in JSX or HTML. +function anchorsOf(source, { createSlugger, parseMarkdownHeadingId }) { + const slugger = createSlugger(); + const anchors = new Set(); + let fence = null; + for (const line of source.split('\n')) { + const marker = line.match(/^\s*(`{3,}|~{3,})/); + if (marker) { + if (!fence) fence = marker[1]; + else if (marker[1][0] === fence[0] && marker[1].length >= fence.length) fence = null; + continue; + } + if (fence) continue; + + const heading = line.match(/^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/); + if (heading) { + const { text, id } = parseMarkdownHeadingId(heading[1]); + const plain = text + .replace(/!\[[^\]]*\]\([^)]*\)/g, '') + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/<[^>]+>/g, ''); + anchors.add(id ?? slugger.slug(plain)); + } + for (const m of line.matchAll(/\b(?:id|name)=(?:"([^"]+)"|'([^']+)'|\{['"]([^'"]+)['"]\})/g)) { + anchors.add(m[1] ?? m[2] ?? m[3]); + } + } + return anchors; +} + +// The partials a page renders, which contribute their headings to the page. +function importedMdx(file, source, root) { + const out = []; + for (const m of source.matchAll(/^import\s+\w+\s+from\s+['"]([^'"]+\.mdx?)['"]/gm)) { + const target = m[1].startsWith('@site/') + ? path.join(root, m[1].slice('@site/'.length)) + : path.resolve(path.dirname(file), m[1]); + if (fs.existsSync(target)) out.push(target); + } + return out; +} + +// What the comment-link check needs to know about the site: which paths it +// serves, how vercel.json redirects them, and the anchors on each page. +function loadSite(root = process.cwd()) { + const matter = require('gray-matter'); + const docusaurusUtils = require('@docusaurus/utils'); + const { buildRouteIndex, docUrl } = require('./redirect-routes'); + const { compileRedirects } = require('./redirect-utils'); + const { walkDir } = require('../plugins/shared/docsRouting'); + + const routes = buildRouteIndex(root); + const redirects = compileRedirects( + JSON.parse(fs.readFileSync(path.join(root, 'vercel.json'), 'utf8')).redirects ?? [] + ); + + const docsDir = path.join(root, DOCS_DIR); + const pages = new Map(); + for (const file of walkDir(docsDir)) { + const { data } = matter(fs.readFileSync(file, 'utf8')); + pages.set(docUrl(docsDir, file, data, '/'), file); + } + + const firstSegments = new Set( + [...routes.urls, ...redirects.map((r) => r.source ?? '')].map((p) => p.split('/')[1]).filter(Boolean) + ); + + const anchorCache = new Map(); + const anchors = (urlPath) => { + const file = pages.get(urlPath); + if (!file) return null; + if (!anchorCache.has(file)) { + const source = fs.readFileSync(file, 'utf8'); + const set = anchorsOf(source, docusaurusUtils); + for (const partial of importedMdx(file, source, root)) { + for (const a of anchorsOf(fs.readFileSync(partial, 'utf8'), docusaurusUtils)) set.add(a); + } + anchorCache.set(file, set); + } + return anchorCache.get(file); + }; + + return { + has: routes.has, + maybe: routes.maybe, + redirects, + anchors, + isSitePath: (p) => firstSegments.has(p.split(/[/#]/)[1]), + }; +} + +// Why a comment link doesn't resolve, or null when it does. A redirect counts +// as resolving only if the page it lands on has the anchor, because a +// catch-all redirect sends every old path to a section landing page. +function checkLink(href, site) { + const { followRedirects, normalizePath } = require('./redirect-utils'); + const [rawPath, anchor] = href.split('#'); + const start = normalizePath(rawPath || '/'); + + let target = start; + let via = ''; + if (!site.has(start)) { + if (site.maybe(start)) return null; + const result = followRedirects(start, site.redirects); + if (result.external) return null; + if (result.chain.length === 0 || !site.maybe(result.final)) { + return result.chain.length === 0 + ? `${start} is not a page on the site.` + : `${start} redirects to ${result.final}, which is not a page on the site.`; + } + target = result.final; + via = ` (${start} redirects there)`; + } + + if (!anchor) return null; + const anchors = site.anchors(target); + if (!anchors || anchors.has(anchor)) return null; + return `${target} has no #${anchor} heading${via}.`; +} + +function checkCommentLinks(samples, site) { + const findings = []; + for (const sample of samples) { + for (const link of findCommentLinks(sample.code, site.isSitePath)) { + const problem = checkLink(link.href, site); + if (!problem) continue; + findings.push({ + file: sample.file, + line: sample.line + link.line, + kind: 'comment-link', + subject: link.href, + message: `${link.href}: ${problem}`, + }); + } + } + return findings; +} + +// --------------------------------------------------------------------------- +// Baseline and reporting +// --------------------------------------------------------------------------- + +const keyOf = (f) => `${f.file}\u0000${f.kind}\u0000${f.subject}`; + +// Accepted findings are recorded without line numbers, so one entry covers +// every occurrence of the same subject on a page. +function applyBaseline(findings, baseline) { + const known = new Set(baseline.findings.map(keyOf)); + const found = new Set(findings.map(keyOf)); + return { + remaining: findings.filter((f) => !known.has(keyOf(f))), + baselined: findings.filter((f) => known.has(keyOf(f))).length, + stale: baseline.findings.filter((e) => !found.has(keyOf(e))), + }; +} + +function updatedBaseline(findings, baseline) { + const notes = new Map(baseline.findings.map((e) => [keyOf(e), e.note])); + const entries = new Map(); + for (const f of findings) { + const key = keyOf(f); + if (!entries.has(key)) { + entries.set(key, { file: f.file, kind: f.kind, subject: f.subject, note: notes.get(key) ?? '' }); + } + } + return { + comment: baseline.comment || DEFAULT_COMMENT, + findings: [...entries.values()].sort((a, b) => keyOf(a).localeCompare(keyOf(b))), + }; +} + +function loadBaseline() { + if (!fs.existsSync(BASELINE)) return { comment: DEFAULT_COMMENT, findings: [] }; + return JSON.parse(fs.readFileSync(BASELINE, 'utf8')); +} + +function describeSources(packages) { + const fetched = packages.filter((p) => p.version); + const versions = [...new Set(fetched.map((p) => p.version))]; + const lines = []; + if (versions.length === 1) { + lines.push(`Checked against @temporalio/* ${versions[0]}: ${fetched.map((p) => p.name.slice(12)).join(', ')}`); + } else { + lines.push('Checked against:'); + for (const p of fetched) lines.push(` ${p.name}@${p.version}`); + } + for (const p of packages.filter((p) => p.noSuchVersion)) + lines.push(` ${p.name}: no release at that version, skipped`); + return lines; +} + +function report({ remaining, baselined, stale }, { packages, sampleCount, snipsync, fullScan }) { + const lines = [...describeSources(packages)]; + lines.push(`${sampleCount} hand-written samples checked; ${snipsync} Snipsync samples skipped.`, ''); + + const byFile = new Map(); + for (const f of remaining) { + if (!byFile.has(f.file)) byFile.set(f.file, []); + byFile.get(f.file).push(f); + } + for (const [file, list] of byFile) { + lines.push(file); + for (const f of list) lines.push(` ${String(f.line).padStart(5)} ${f.kind.padEnd(14)} ${f.message}`); + lines.push(''); + } + + if (fullScan && stale.length) { + lines.push('Baseline entries that no longer match anything (remove them):'); + for (const e of stale) lines.push(` ${e.file} ${e.kind} ${e.subject}`); + lines.push(''); + } + + const accepted = baselined ? ` (${baselined} more accepted in ${BASELINE})` : ''; + lines.push( + remaining.length === 0 + ? `No findings${accepted}.` + : `${remaining.length} finding(s) in ${byFile.size} page(s)${accepted}.` + ); + return lines.join('\n'); +} + +// GitHub Actions workflow commands, one warning per finding. +function annotations(findings) { + const escape = (s) => s.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A'); + return findings.map( + (f) => `::warning file=${f.file},line=${f.line},title=TypeScript sample (${f.kind})::${escape(f.message)}` + ); +} + +function optionValue(args, name) { + const i = args.indexOf(name); + if (i === -1) return null; + const value = args[i + 1]; + if (!value || value.startsWith('--')) throw new Error(`${name} needs a value.`); + args.splice(i, 2); + return value; +} + +async function main() { + const args = process.argv.slice(2); + const version = optionValue(args, '--sdk-version') ?? 'latest'; + const cacheDir = path.resolve( + optionValue(args, '--cache-dir') ?? path.join(os.tmpdir(), 'temporal-typescript-samples') + ); + const flags = new Set(args.filter((a) => a.startsWith('--'))); + const targets = args.filter((a) => !a.startsWith('--')); + const fullScan = targets.length === 0; + + for (const flag of flags) { + if (!['--json', '--github', '--update-baseline'].includes(flag)) throw new Error(`Unknown option ${flag}.`); + } + if (!fullScan && flags.has('--update-baseline')) { + throw new Error('--update-baseline needs a full scan; drop the paths.'); + } + + const missing = targets.filter((t) => !fs.existsSync(t)); + if (missing.length) throw new Error(`No such file or directory: ${missing.join(', ')}`); + const { samples, snipsync } = collectSamples(fullScan ? [DOCS_DIR] : targets); + + const requested = new Set(CORE_PACKAGES); + for (const sample of samples) for (const name of importedPackages(sample.code)) requested.add(name); + const packages = await fetchPackages(requested, version, cacheDir); + const packageMap = new Map(packages.map((p) => [p.name, p])); + + // Without these there is nothing to check against, and every sample would + // pass. That's a broken run, not a clean one. + const absent = CORE_PACKAGES.filter((name) => !packageMap.get(name)?.version); + if (absent.length) { + throw new Error(`Could not fetch ${absent.map((name) => `${name}@${version}`).join(', ')} from npm.`); + } + + const findings = [ + ...checkSamples(samples, { root: cacheDir, packages: packageMap }), + ...checkCommentLinks(samples, loadSite()), + ].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line); + + const baseline = loadBaseline(); + + if (flags.has('--update-baseline')) { + const updated = updatedBaseline(findings, baseline); + fs.writeFileSync(BASELINE, `${JSON.stringify(updated, null, 2)}\n`); + console.log(`Wrote ${BASELINE} with ${updated.findings.length} entries. Add a note for any entry that has none.`); + return 0; + } + + const result = applyBaseline(findings, baseline); + if (!fullScan) result.stale = []; + + if (flags.has('--json')) { + console.log( + JSON.stringify( + { + packages: packages.map(({ dependencies, ...p }) => p), + samples: samples.length, + snipsync, + ...result, + }, + null, + 2 + ) + ); + } else { + console.log(report(result, { packages, sampleCount: samples.length, snipsync, fullScan })); + } + if (flags.has('--github')) { + for (const line of annotations(result.remaining)) console.log(line); + } + + return result.remaining.length + result.stale.length; +} + +module.exports = { + AMBIENT, + BASELINE, + extractCodeBlocks, + extractSamples, + importedPackages, + createSampleProgram, + checkSamples, + commentsIn, + findCommentLinks, + anchorsOf, + checkLink, + loadSite, + applyBaseline, + updatedBaseline, + annotations, +}; + +if (require.main === module) { + main().then( + (count) => process.exit(count > 0 ? 2 : 0), + (error) => { + console.error(error.message); + process.exit(1); + } + ); +} diff --git a/bin/check-typescript-samples.test.js b/bin/check-typescript-samples.test.js new file mode 100644 index 0000000000..a68006d04f --- /dev/null +++ b/bin/check-typescript-samples.test.js @@ -0,0 +1,576 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const { compileRedirects } = require('./redirect-utils'); +const { + BASELINE, + extractCodeBlocks, + extractSamples, + importedPackages, + checkSamples, + commentsIn, + findCommentLinks, + anchorsOf, + checkLink, + applyBaseline, + updatedBaseline, + annotations, +} = require('./check-typescript-samples.js'); + +const fence = '```'; + +describe('extractCodeBlocks', () => { + it('reports the line of the first line of code', () => { + const page = ['# Title', '', `${fence}ts`, 'const a = 1;', 'const b = 2;', fence].join('\n'); + const [block] = extractCodeBlocks(page); + assert.strictEqual(block.lang, 'ts'); + assert.strictEqual(block.line, 4); + assert.strictEqual(block.code, 'const a = 1;\nconst b = 2;'); + }); + + it('reads the language out of an info string with a title or highlighted lines', () => { + const page = [`${fence}typescript title="client.ts" {1,3}`, 'x', fence, `${fence}ts{2}`, 'y', fence].join('\n'); + assert.deepStrictEqual( + extractCodeBlocks(page).map((b) => b.lang), + ['typescript', 'ts'] + ); + }); + + it('removes the indentation of a fence inside a list item or component', () => { + const page = ['- Step one:', '', ` ${fence}ts`, ' if (a) {', ' b();', ' }', ` ${fence}`].join('\n'); + assert.strictEqual(extractCodeBlocks(page)[0].code, 'if (a) {\n b();\n}'); + }); + + it('keeps a shorter fence inside a longer one as code', () => { + const page = ['````md', `${fence}ts`, 'nested();', fence, '````'].join('\n'); + const blocks = extractCodeBlocks(page); + assert.strictEqual(blocks.length, 1); + assert.strictEqual(blocks[0].lang, 'md'); + }); + + it('accepts tilde fences', () => { + const page = ['~~~js', 'run();', '~~~'].join('\n'); + assert.strictEqual(extractCodeBlocks(page)[0].code, 'run();'); + }); + + it('marks blocks inside either form of Snipsync wrapper', () => { + const page = [ + '', + `${fence}ts`, + 'synced();', + fence, + '', + `${fence}ts`, + 'handWritten();', + fence, + '{/* SNIPSTART typescript-env-config {"highlightedLines": "1-2"} */}', + `${fence}ts`, + 'alsoSynced();', + fence, + '{/* SNIPEND */}', + ].join('\n'); + assert.deepStrictEqual( + extractCodeBlocks(page).map((b) => [b.code, b.snipsync]), + [ + ['synced();', true], + ['handWritten();', false], + ['alsoSynced();', true], + ] + ); + }); + + it('skips code inside a comment, which is never rendered', () => { + const page = [ + '', + '{/*', + `${fence}ts`, + 'hiddenMdx();', + fence, + '*/}', + '', + `${fence}ts`, + 'shown();', + fence, + ].join('\n'); + assert.deepStrictEqual( + extractCodeBlocks(page).map((b) => b.code), + ['shown();'] + ); + }); +}); + +describe('extractSamples', () => { + it('keeps hand-written TypeScript and JavaScript only', () => { + const page = [ + `${fence}ts`, + 'a();', + fence, + `${fence}javascript`, + 'b();', + fence, + `${fence}python`, + 'c()', + fence, + '', + `${fence}typescript`, + 'd();', + fence, + '', + ].join('\n'); + assert.deepStrictEqual( + extractSamples(page).map((s) => s.code), + ['a();', 'b();'] + ); + }); +}); + +describe('importedPackages', () => { + it('names the package behind an import, a subpath import, and a require', () => { + const code = [ + "import { proxyActivities } from '@temporalio/workflow';", + "import { WorkflowStream } from '@temporalio/workflow-streams/workflow';", + "const { Client } = require('@temporalio/client');", + "import { openai } from '@ai-sdk/openai';", + "import * as activities from './activities';", + ].join('\n'); + assert.deepStrictEqual([...importedPackages(code)].sort(), [ + '@temporalio/client', + '@temporalio/workflow', + '@temporalio/workflow-streams', + ]); + }); +}); + +// Fake packages, trimmed to the shapes the matching has to get right. The +// real ones are fetched from npm, which a unit test shouldn't depend on. +const ROOT = '/virtual'; +const fakePackage = (name, declarations) => [ + [`${ROOT}/node_modules/${name}/package.json`, JSON.stringify({ name, version: '9.9.9', types: 'index.d.ts' })], + [`${ROOT}/node_modules/${name}/index.d.ts`, declarations], +]; +const FILES = new Map([ + ...fakePackage( + '@temporalio/client', + ` +export declare class Connection { + static connect(): Promise; + static lazy(): Connection; + protected static createCtorOptions(): unknown; + close(): Promise; +} +export declare class WorkflowClient { + start(workflow: unknown, options: unknown): Promise; + getHandle(workflowId: string): unknown; +} +export declare class Client { + constructor(options?: unknown); + readonly workflow: WorkflowClient; +} +` + ), + [ + `${ROOT}/node_modules/@temporalio/client/lib/errors.d.ts`, + 'export declare class WorkflowFailedError extends Error {}', + ], + ...fakePackage( + '@temporalio/workflow', + ` +export declare function proxyActivities(options?: unknown): A; +export declare function sleep(ms: number): Promise; +` + ), + ...fakePackage( + '@temporalio/testing', + ` +import { EventEmitter } from 'a-package-that-was-not-fetched'; +export declare class MockActivityEnvironment extends EventEmitter { + run(fn: unknown): Promise; +} +` + ), +]); +const PACKAGES = new Map( + ['@temporalio/client', '@temporalio/workflow', '@temporalio/testing'].map((name) => [ + name, + { name, version: '9.9.9' }, + ]) +); + +// Samples are checked together, the way the real run checks them. +const check = (...codes) => + checkSamples( + codes.map((code, i) => ({ file: `docs/page-${i}.mdx`, line: 10, code })), + { root: ROOT, files: FILES, packages: PACKAGES } + ); + +describe('checkSamples', () => { + it('flags a static method the class does not have, at the line it is on in the page', () => { + const findings = check( + [ + "import { Client, Connection } from '@temporalio/client';", + 'const connection = await Connection.create();', + 'const client = new Client({ connection });', + ].join('\n') + ); + assert.strictEqual(findings.length, 1); + assert.deepStrictEqual( + { file: findings[0].file, line: findings[0].line, kind: findings[0].kind, subject: findings[0].subject }, + { file: 'docs/page-0.mdx', line: 11, kind: 'missing-member', subject: 'Connection.create' } + ); + assert.match(findings[0].message, /^Connection\.create: Property 'create' does not exist/); + assert.match(findings[0].message, /@temporalio\/client@9\.9\.9/); + }); + + it('accepts the methods the class does have', () => { + const findings = check( + [ + "import { Connection } from '@temporalio/client';", + 'const a = await Connection.connect();', + 'const b = Connection.lazy();', + 'await a.close();', + ].join('\n') + ); + assert.deepStrictEqual(findings, []); + }); + + it('follows the type through an instance and a property', () => { + const findings = check( + [ + "import { Client } from '@temporalio/client';", + 'const client = new Client();', + "await client.workflow.start(example, { taskQueue: 'q' });", + "await client.workflow.execute(example, { taskQueue: 'q' });", + ].join('\n') + ); + assert.deepStrictEqual( + findings.map((f) => f.subject), + ['WorkflowClient.execute'] + ); + }); + + it('treats an undeclared client as a Client and an undeclared Connection as the SDK class', () => { + const findings = check("const handle = client.getHandle('id');", 'await Connection.create();'); + assert.deepStrictEqual( + findings.map((f) => f.subject), + ['Client.getHandle', 'Connection.create'] + ); + }); + + it("lets a sample's own declaration shadow the ambient one", () => { + const findings = check('const client = { getHandle(id: string) {} };\nclient.getHandle("id");'); + assert.deepStrictEqual(findings, []); + }); + + it('keeps each sample in its own scope', () => { + // Neither sample imports anything. If the first one's declaration leaked + // into the second, `connection` there would be a Connection and + // `connection.missing` a finding. + const findings = check('const connection = Connection.lazy();', 'connection.missing();'); + assert.deepStrictEqual(findings, []); + }); + + it('ignores the noise a fragment produces', () => { + const findings = check( + [ + "import { thing } from './activities';", + "import { openai } from '@ai-sdk/openai';", + 'const result = await undeclaredHandle.result();', + 'const local = {};', + 'local.missing;', + 'openai.anything();', + ].join('\n') + ); + assert.deepStrictEqual(findings, []); + }); + + it('flags a named import the package does not export', () => { + const findings = check("import { executeActivity, sleep } from '@temporalio/workflow';"); + assert.deepStrictEqual( + findings.map((f) => [f.kind, f.subject]), + [['missing-export', '@temporalio/workflow:executeActivity']] + ); + }); + + it('flags a missing export reached through a namespace import, declared or not', () => { + const findings = check( + "import * as workflow from '@temporalio/workflow';\nworkflow.RetryState.TIMEOUT;", + 'wf.TimeoutType.START_TO_CLOSE;' + ); + assert.deepStrictEqual( + findings.map((f) => [f.kind, f.subject]), + [ + ['missing-export', '@temporalio/workflow:RetryState'], + ['missing-export', '@temporalio/workflow:TimeoutType'], + ] + ); + assert.match(findings[0].message, /^workflow\.RetryState: @temporalio\/workflow has no export named 'RetryState'/); + }); + + it('flags a subpath the package does not have, and accepts one it does', () => { + const findings = check( + "import { WorkflowFailedError } from '@temporalio/client/lib/errors';\nimport { x } from '@temporalio/client/lib/moved';" + ); + assert.deepStrictEqual( + findings.map((f) => [f.kind, f.subject]), + [['missing-module', '@temporalio/client/lib/moved']] + ); + }); + + it('flags a package that is not published, but not one that was skipped', () => { + const packages = new Map([ + ...PACKAGES, + ['@temporalio/renamed', { name: '@temporalio/renamed', missing: true }], + ['@temporalio/too-new', { name: '@temporalio/too-new', noSuchVersion: true }], + ]); + const findings = checkSamples( + [ + { + file: 'docs/page.mdx', + line: 1, + code: [ + "import { a } from '@temporalio/renamed';", + "import { b } from '@temporalio/too-new';", + "import { c } from '@temporalio/core-bridge';", + ].join('\n'), + }, + ], + { root: ROOT, files: FILES, packages } + ); + assert.deepStrictEqual( + findings.map((f) => [f.subject, f.message]), + [['@temporalio/renamed', '@temporalio/renamed is not published on npm.']] + ); + }); + + it('does not guess about a class whose base class could not be resolved', () => { + // MockActivityEnvironment really does extend EventEmitter, so `on` exists + // even though the compiler can't see it here. + const findings = check( + "import { MockActivityEnvironment } from '@temporalio/testing';\nconst env = new MockActivityEnvironment();\nenv.on('heartbeat', () => {});" + ); + assert.deepStrictEqual(findings, []); + }); +}); + +describe('commentsIn', () => { + it('finds line and block comments, but not a // inside a string', () => { + const code = [ + "const address = 'http://localhost:7233'; // the dev server", + '/* first', + ' second */', + 'run();', + ].join('\n'); + assert.deepStrictEqual( + commentsIn(code).map((c) => [c.line, c.text]), + [ + [0, '// the dev server'], + [1, '/* first\n second */'], + ] + ); + }); + + it('finds a comment just before a closing brace', () => { + const code = 'function f() {\n work();\n // see /develop/typescript\n}'; + assert.deepStrictEqual( + commentsIn(code).map((c) => c.line), + [2] + ); + }); +}); + +describe('findCommentLinks', () => { + const isSitePath = (p) => ['develop', 'typescript'].includes(p.split(/[/#]/)[1]); + + it('finds docs links in comments, absolute or site-relative', () => { + const code = [ + 'async function run() {', + ' // https://docs.temporal.io/develop/typescript/client#connect.', + ' // If you need mTLS, see docs:', + ' // /typescript/security#encryption-in-transit-with-mtls', + " const url = '/develop/not-in-a-comment';", + '}', + ].join('\n'); + assert.deepStrictEqual(findCommentLinks(code, isSitePath), [ + { line: 1, href: '/develop/typescript/client#connect' }, + { line: 3, href: '/typescript/security#encryption-in-transit-with-mtls' }, + ]); + }); + + it('leaves alone a path that is not on the site, and links to other sites', () => { + const code = [ + '// Certificates live in /etc/temporal/certs', + '// https://typescript.temporal.io/api/classes/client.Connection', + ].join('\n'); + assert.deepStrictEqual(findCommentLinks(code, isSitePath), []); + }); +}); + +describe('anchorsOf', () => { + const utils = require('@docusaurus/utils'); + + it('slugs headings the way Docusaurus does', () => { + const page = [ + '## Connect to a Temporal Service', + '### How to use `Connection.connect`', + '## [Linked](/somewhere) heading', + '## Explicit {#custom-id}', + '## Repeated', + '## Repeated', + `${fence}bash`, + '# not a heading', + fence, + '', + ].join('\n'); + assert.deepStrictEqual([...anchorsOf(page, utils)].sort(), [ + 'connect-to-a-temporal-service', + 'custom-id', + 'how-to-use-connectionconnect', + 'linked-heading', + 'manual-anchor', + 'repeated', + 'repeated-1', + ]); + }); +}); + +describe('checkLink', () => { + const pages = { + '/develop/typescript': ['set-up'], + '/develop/typescript/client': ['connect-to-a-temporal-service'], + }; + const site = { + has: (p) => p in pages, + maybe: (p) => p in pages || p.startsWith('/ai/cookbook'), + redirects: compileRedirects([ + { source: '/typescript/:path*', destination: '/develop/typescript' }, + { source: '/old-client', destination: '/develop/typescript/client' }, + { source: '/gone', destination: '/also-gone' }, + { source: '/elsewhere', destination: 'https://example.com' }, + ]), + anchors: (p) => (p in pages ? new Set(pages[p]) : null), + }; + + it('accepts a page and an anchor that exist', () => { + assert.strictEqual(checkLink('/develop/typescript/client#connect-to-a-temporal-service', site), null); + assert.strictEqual(checkLink('/develop/typescript/client/', site), null); + }); + + it('flags an anchor the page does not have', () => { + assert.strictEqual( + checkLink('/develop/typescript/client#nope', site), + '/develop/typescript/client has no #nope heading.' + ); + }); + + it('accepts a redirect that lands on the page and the anchor', () => { + assert.strictEqual(checkLink('/old-client#connect-to-a-temporal-service', site), null); + }); + + it('flags a catch-all redirect that drops the anchor', () => { + assert.strictEqual( + checkLink('/typescript/security#encryption-in-transit-with-mtls', site), + '/develop/typescript has no #encryption-in-transit-with-mtls heading (/typescript/security redirects there).' + ); + }); + + it('flags a path that is not served and a redirect to one', () => { + assert.strictEqual(checkLink('/develop/nope', site), '/develop/nope is not a page on the site.'); + assert.strictEqual(checkLink('/gone', site), '/gone redirects to /also-gone, which is not a page on the site.'); + }); + + it('leaves alone what it cannot verify', () => { + assert.strictEqual(checkLink('/ai/cookbook/some-recipe', site), null); + assert.strictEqual(checkLink('/elsewhere#x', site), null); + }); +}); + +describe('applyBaseline', () => { + const finding = (line, subject) => ({ file: 'docs/a.mdx', line, kind: 'missing-member', subject, message: '' }); + const baseline = { + comment: '', + findings: [ + { file: 'docs/a.mdx', kind: 'missing-member', subject: 'Client.getHandle', note: 'Deliberate.' }, + { file: 'docs/a.mdx', kind: 'missing-member', subject: 'Worker.gone', note: '' }, + ], + }; + + it('suppresses every occurrence of an accepted subject, whatever its line', () => { + const result = applyBaseline( + [finding(10, 'Client.getHandle'), finding(90, 'Client.getHandle'), finding(12, 'Connection.create')], + baseline + ); + assert.deepStrictEqual( + result.remaining.map((f) => f.subject), + ['Connection.create'] + ); + assert.strictEqual(result.baselined, 2); + }); + + it('reports an entry that no longer matches anything', () => { + const result = applyBaseline([finding(10, 'Client.getHandle')], baseline); + assert.deepStrictEqual( + result.stale.map((e) => e.subject), + ['Worker.gone'] + ); + }); + + it('does not let an entry for one page suppress the same subject on another', () => { + const other = { ...finding(10, 'Client.getHandle'), file: 'docs/b.mdx' }; + assert.strictEqual(applyBaseline([other], baseline).remaining.length, 1); + }); +}); + +describe('updatedBaseline', () => { + it('records each subject once, sorted, and keeps existing notes', () => { + const findings = [ + { file: 'docs/b.mdx', line: 3, kind: 'missing-member', subject: 'Client.getHandle' }, + { file: 'docs/a.mdx', line: 9, kind: 'comment-link', subject: '/x#y' }, + { file: 'docs/b.mdx', line: 7, kind: 'missing-member', subject: 'Client.getHandle' }, + ]; + const previous = { + comment: 'Kept.', + findings: [{ file: 'docs/b.mdx', kind: 'missing-member', subject: 'Client.getHandle', note: 'Reviewed.' }], + }; + assert.deepStrictEqual(updatedBaseline(findings, previous), { + comment: 'Kept.', + findings: [ + { file: 'docs/a.mdx', kind: 'comment-link', subject: '/x#y', note: '' }, + { file: 'docs/b.mdx', kind: 'missing-member', subject: 'Client.getHandle', note: 'Reviewed.' }, + ], + }); + }); +}); + +describe('annotations', () => { + it('writes one warning per finding, escaping what workflow commands require', () => { + assert.deepStrictEqual( + annotations([{ file: 'docs/a.mdx', line: 4, kind: 'missing-member', message: '100% wrong\nreally' }]), + ['::warning file=docs/a.mdx,line=4,title=TypeScript sample (missing-member)::100%25 wrong%0Areally'] + ); + }); +}); + +describe('the checked-in baseline', () => { + const baseline = JSON.parse(fs.readFileSync(path.join(__dirname, '..', BASELINE), 'utf8')); + + it('stays sorted and free of duplicates, the way --update-baseline writes it', () => { + assert.deepStrictEqual(baseline, updatedBaseline(baseline.findings, baseline)); + }); + + it('explains every accepted finding', () => { + for (const entry of baseline.findings) { + assert.ok(entry.note, `${entry.file} ${entry.subject} has no note`); + } + }); + + it('points at pages that exist', () => { + for (const entry of baseline.findings) { + assert.ok(fs.existsSync(path.join(__dirname, '..', entry.file)), `${entry.file} does not exist`); + } + }); +}); diff --git a/bin/typescript-samples-baseline.json b/bin/typescript-samples-baseline.json new file mode 100644 index 0000000000..eb35c45a77 --- /dev/null +++ b/bin/typescript-samples-baseline.json @@ -0,0 +1,11 @@ +{ + "comment": "Findings from bin/check-typescript-samples.js that we have decided to leave as they are, for example a sample that deliberately shows an older API. Each entry is a page, a kind, and a subject; line numbers are left out so an entry survives edits elsewhere on the page. An empty note means the entry has not been reviewed yet; either fix the sample or fill in the note explaining why it stays. Regenerate with: node bin/check-typescript-samples.js --update-baseline", + "findings": [ + { + "file": "docs/develop/typescript/nexus/feature-guide.mdx", + "kind": "missing-member", + "subject": "Client.startWorkflow", + "note": "The sample continues the Operation handler shown just above it, where `client` is the handler's TemporalNexusClient parameter, which has startWorkflow. The checker assumes an undeclared `client` is a Client." + } + ] +} diff --git a/package.json b/package.json index 888e733227..a6637e1413 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "check:metrics:sdks": "node ./bin/check-metrics-against-sdks.js", "check:orphans": "node ./bin/check-orphan-pages.js", "check:redirects": "node ./bin/validate-redirects.js", + "check:ts-samples": "node ./bin/check-typescript-samples.js", "test": "node --test", "lint": "vale --output=JSON docs/**/**/*.mdx > vale-output.json; vale --output=JSON docs/**/**/*.md > vale-md-output.json", "lint:go": "vale docs/develop/go/*.mdx", @@ -105,6 +106,7 @@ "global-jsdom": "^30.0.0", "husky": "^9.1.7", "hyperlink": "^5.0.4", + "typescript": "^6.0.3", "vercel-path-to-regexp": "npm:path-to-regexp@6.1.0" } } diff --git a/readme/AUTOMATIONS.md b/readme/AUTOMATIONS.md index de1a421d51..8c94a4334a 100644 --- a/readme/AUTOMATIONS.md +++ b/readme/AUTOMATIONS.md @@ -35,6 +35,7 @@ a 404 rather than a 403, so a scope mistake looks like a missing file. | Vale CI | Changes under `docs/` | No | `vale-ci.yml`, `.vale-ci.ini` | Runs only the rules in `.vale-ci.ini`, with `fail_on_error: false` and results limited to changed context. Treat those rules as the bar, not the full style set. See [AGENTS.md](../AGENTS.md#commands). | | Check Orphan Pages | Changes to docs or `sidebars.js` | No | `check-orphan-pages.yml`, `bin/check-orphan-pages.js` | Never fails; comments and annotates only. Accepted exceptions belong in `bin/orphan-pages-baseline.json` with a note. See [UTILITIES.md](./UTILITIES.md#check-orphan-pages). | | Check Metrics Against SDKs | Changes to the metrics page or its checker | No | `check-metrics-against-sdks.yml` | Drift reports but does not fail. On its scheduled runs it manages a tracking issue instead. | +| Check TypeScript Samples | Changes under `docs/develop/typescript/` | No | `check-typescript-samples.yml`, `bin/check-typescript-samples.js` | Type-checks hand-written TypeScript and JavaScript samples against the latest `@temporalio` packages, and checks docs links in code comments. Annotates only the pages the pull request changes. Findings never fail the job. | | Docs Preview Links | Every pull request | No | `docs-preview-links.yml`, `bin/generate-docs-preview-list.js` | Reads the preview URL out of Vercel's own comment, then upserts a single comment listing the pages the pull request changes. | | Visual Comparison | Pull requests labeled `visual-comparison` | No | `visual-comparison.yml` | Compares against baselines captured weekly by Screenshot Capture, and publishes its HTML report to a throwaway Vercel deployment linked from a pull request comment. Takes about 15 minutes. See [UTILITIES.md](./UTILITIES.md#visual-comparison). | | Dependabot | Weekly, for npm and GitHub Actions | No | `.github/dependabot.yml` | 14-day cooldown on new versions. | @@ -50,6 +51,7 @@ the `Vercel` check, so every job in this table can be merged past. | Update SDK Versions | Daily, 09:00 UTC | Refreshes the version chips shown on `/develop`. | | Update Custom Role Permissions | Mondays, 09:00 UTC | Regenerates the Cloud permissions table. | | Check Metrics Against SDKs | Mondays, 10:00 UTC | Compares the metrics reference with the SDK default branches. Advisory by design: the SDK default branches run ahead of released versions, so instead of failing it opens a tracking issue, updates that issue while the drift lasts, and closes it once the page and the sources agree. Record a deliberately undocumented metric in `bin/metrics-baseline.json`. | +| Check TypeScript Samples | Mondays, 11:00 UTC | Type-checks every hand-written TypeScript and JavaScript sample in `docs/` (not Snipsync samples, which compile in their source repositories) against the latest `@temporalio` packages from npm, and reports SDK methods, properties, and exports that don't exist. Also reports docs links in code comments that don't resolve, including through a catch-all redirect that drops the anchor. Advisory: manages a tracking issue the same way Check Metrics Against SDKs does. Record a deliberate exception in `bin/typescript-samples-baseline.json`. | | Environment config drift | Mondays, 15:00 UTC | Compares the environment variable table with five SDK and CLI repositories. | | Screenshot Capture | Sundays, 00:00 UTC | Captures Playwright baselines for Visual Comparison, sharded four ways, retained 14 days. | | Warm Build Cache | Every push to `main` | Rebuilds so dependency and build caches stay warm, so a new pull request's first Docs Build Check isn't a cold install. GitHub falls back from the current ref to the base branch to the default branch when restoring a cache, so pull request runs restore what merges to `main` saved. Docs Build Check restores these caches but never saves them, because the OG image and rspack caches are large and keyed per run. | diff --git a/yarn.lock b/yarn.lock index eecad784c9..7b2a7cdbfb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13699,6 +13699,11 @@ typedarray-to-buffer@^3.1.5: dependencies: is-typedarray "^1.0.0" +typescript@^6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-6.0.3.tgz#90251dc007916e972786cb94d74d15b185577d21" + integrity sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw== + uglify-js@^3.5.1: version "3.19.3" resolved "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz" From 5128d64026e230149e1d405fa1445a903bbe404b Mon Sep 17 00:00:00 2001 From: Duncan Mackenzie Date: Wed, 7 Oct 2026 09:49:42 -0700 Subject: [PATCH 2/2] Add advisory checker for hand-written Python samples (#5413) Type-checks the Python code blocks in docs/ that aren't synced by Snipsync with Pyright, against the latest temporalio release from PyPI plus any companion distribution a sample imports from (temporalio-openai-agents). Reports SDK classes, functions, and modules that don't exist, keywords the SDK doesn't take, too many positional arguments, and a positional argument after a keyword argument. Missing arguments aren't reported, because samples leave them out on purpose. Pyright installs from npm, so CI needs no Python. Moves what the checkers share into bin/code-samples.js (extraction, baseline, reporting, command line) and the reusable workflow check-code-samples.yml, so another language needs only its checker and a short caller workflow. The TypeScript checker's behavior is unchanged. Runs weekly and on pull requests that touch docs/develop/python/. Findings never fail the job. --- .github/workflows/check-code-samples.yml | 183 +++++ .github/workflows/check-python-samples.yml | 42 ++ .../workflows/check-typescript-samples.yml | 156 +--- .../workflows/notify-automation-failures.yml | 13 +- AGENTS.md | 10 +- bin/check-python-samples.js | 697 ++++++++++++++++++ bin/check-python-samples.test.js | 308 ++++++++ bin/check-typescript-samples.js | 316 +------- bin/check-typescript-samples.test.js | 203 ----- bin/code-samples.js | 337 +++++++++ bin/code-samples.test.js | 213 ++++++ bin/python-samples-baseline.json | 4 + package.json | 2 + readme/AUTOMATIONS.md | 4 +- yarn.lock | 9 +- 15 files changed, 1846 insertions(+), 651 deletions(-) create mode 100644 .github/workflows/check-code-samples.yml create mode 100644 .github/workflows/check-python-samples.yml create mode 100644 bin/check-python-samples.js create mode 100644 bin/check-python-samples.test.js create mode 100644 bin/code-samples.js create mode 100644 bin/code-samples.test.js create mode 100644 bin/python-samples-baseline.json diff --git a/.github/workflows/check-code-samples.yml b/.github/workflows/check-code-samples.yml new file mode 100644 index 0000000000..b9160a00d6 --- /dev/null +++ b/.github/workflows/check-code-samples.yml @@ -0,0 +1,183 @@ +name: Check Code Samples + +# Shared by the per-language sample checks, such as Check TypeScript Samples +# and Check Python Samples. Each of those sets its own triggers and calls this +# with its checker. See bin/code-samples.js for what the checkers share. +# +# This is advisory. Findings never fail the job: the SDK is fetched at its +# latest release, so a release can break a sample without any docs change, +# and a page may deliberately show an older API. On pull requests it annotates +# the changed pages. On the default branch it opens a tracking issue per +# language, updates it while findings remain, and closes it once they're gone. +# The job fails only when the check can't run at all, for example when the +# package registry is unreachable. + +on: + workflow_call: + inputs: + language: + description: The language name used in the summary and the tracking issue, such as Python. + required: true + type: string + checker: + description: The checker script, such as bin/check-python-samples.js. Its tests are the same path with .test.js. + required: true + type: string + baseline: + description: The checker's baseline file. + required: true + type: string + yarn-script: + description: The package.json script that runs the checker, named in the tracking issue. + required: true + type: string + +permissions: + contents: read + +jobs: + check: + name: Check samples against the SDK + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + env: + LANGUAGE: ${{ inputs.language }} + CHECKER: ${{ inputs.checker }} + BASELINE: ${{ inputs.baseline }} + YARN_SCRIPT: ${{ inputs.yarn-script }} + ISSUE_TITLE: Hand-written ${{ inputs.language }} samples need fixes + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # Pull requests diff against their base to find the changed pages. + # (0 is falsy in an expression, so the condition is written this way.) + fetch-depth: ${{ github.event_name != 'pull_request' && 1 || 0 }} + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + cache: yarn + + - name: Install dependencies + run: yarn install --frozen-lockfile --ignore-scripts + + - name: Test the checker + run: node --test bin/code-samples.test.js "${CHECKER%.js}.test.js" + + # A pull request that changes the checker gets a full scan. Otherwise it + # gets only the pages it changes, so it isn't annotated with findings on + # pages it didn't touch. + - name: Choose the pages to check + id: scope + env: + EVENT: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + if [ "$EVENT" != "pull_request" ]; then + echo "mode=full" >> "$GITHUB_OUTPUT" + exit 0 + fi + + changed=$(git diff --name-only --diff-filter=d "$BASE_SHA"...HEAD) + if echo "$changed" | grep -qxF -e "$CHECKER" -e "${CHECKER%.js}.test.js" -e "$BASELINE" \ + -e bin/code-samples.js -e bin/code-samples.test.js \ + || echo "$changed" | grep -qE '^\.github/workflows/check-[a-z-]*samples\.yml$'; then + echo "mode=full" >> "$GITHUB_OUTPUT" + exit 0 + fi + + pages=$(echo "$changed" | grep -E '^docs/.*\.mdx$' | tr '\n' ' ' || true) + if [ -z "$pages" ]; then + echo "mode=none" >> "$GITHUB_OUTPUT" + else + echo "mode=pages" >> "$GITHUB_OUTPUT" + echo "pages=$pages" >> "$GITHUB_OUTPUT" + fi + + - name: Check the samples + id: check + if: steps.scope.outputs.mode != 'none' + env: + PAGES: ${{ steps.scope.outputs.pages }} + run: | + set -f + set +e + # --github adds a workflow command per finding, which annotates the + # page as tee echoes it. The report proper is everything else. + node "$CHECKER" --github $PAGES 2>&1 | tee output.txt + status=${PIPESTATUS[0]} + set -e + + grep -v '^::' output.txt > report.txt || true + + # 0 is clean and 2 is findings. Anything else means the check could + # not run, usually because a package download failed. + if [ "$status" -ne 0 ] && [ "$status" -ne 2 ]; then + echo "::error::The $LANGUAGE sample check could not run. See the log above." + exit "$status" + fi + + if [ "$status" -eq 2 ]; then + echo "findings=true" >> "$GITHUB_OUTPUT" + else + echo "findings=false" >> "$GITHUB_OUTPUT" + fi + + { + echo "### $LANGUAGE samples (informational; does not block merge)" + echo + echo '```' + cat report.txt + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Open or update the tracking issue + if: >- + github.event_name != 'pull_request' && github.ref_name == github.event.repository.default_branch && + steps.check.outputs.findings == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + { + echo "\`$CHECKER\` found hand-written $LANGUAGE samples in \`docs/\` that" + echo "use SDK APIs the latest SDK release doesn't have." + echo + echo '```' + cat report.txt + echo '```' + echo + echo "To resolve, fix the sample, or record the finding with a note in" + echo "\`$BASELINE\` if the page shows that API on purpose." + echo "Reproduce locally with \`yarn $YARN_SCRIPT\`." + echo + echo "_Updated by [${GITHUB_WORKFLOW}](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})._" + } > issue-body.md + + number=$(gh issue list --state open --search "in:title \"$ISSUE_TITLE\"" --json number --jq '.[0].number // empty') + + if [ -n "$number" ]; then + gh issue edit "$number" --body-file issue-body.md + echo "Updated issue #$number." + else + gh issue create --title "$ISSUE_TITLE" --body-file issue-body.md + fi + + - name: Close the tracking issue + if: >- + github.event_name != 'pull_request' && github.ref_name == github.event.repository.default_branch && + steps.check.outputs.findings == 'false' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + number=$(gh issue list --state open --search "in:title \"$ISSUE_TITLE\"" --json number --jq '.[0].number // empty') + + if [ -n "$number" ]; then + gh issue close "$number" --comment "The checker no longer finds anything to fix." + fi diff --git a/.github/workflows/check-python-samples.yml b/.github/workflows/check-python-samples.yml new file mode 100644 index 0000000000..01d04d5ec3 --- /dev/null +++ b/.github/workflows/check-python-samples.yml @@ -0,0 +1,42 @@ +name: Check Python Samples + +# Type-checks the hand-written Python samples in docs/ with Pyright against +# the latest temporalio release on PyPI, and reports SDK classes, functions, +# modules, and arguments that the samples use but the package doesn't have. +# Advisory; the steps are shared with the other languages in +# check-code-samples.yml. + +on: + schedule: + # Mondays at 11:30 UTC. + - cron: '30 11 * * 1' + workflow_dispatch: + pull_request: + paths: + - 'docs/develop/python/**' + - 'bin/check-python-samples.js' + - 'bin/check-python-samples.test.js' + - 'bin/python-samples-baseline.json' + - 'bin/code-samples.js' + - 'bin/code-samples.test.js' + - '.github/workflows/check-python-samples.yml' + - '.github/workflows/check-code-samples.yml' + +permissions: + contents: read + +concurrency: + group: check-python-samples-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + permissions: + contents: read + issues: write + uses: ./.github/workflows/check-code-samples.yml + with: + language: Python + checker: bin/check-python-samples.js + baseline: bin/python-samples-baseline.json + yarn-script: check:py-samples diff --git a/.github/workflows/check-typescript-samples.yml b/.github/workflows/check-typescript-samples.yml index ee8710057e..20db0086b5 100644 --- a/.github/workflows/check-typescript-samples.yml +++ b/.github/workflows/check-typescript-samples.yml @@ -3,15 +3,8 @@ name: Check TypeScript Samples # Type-checks the hand-written TypeScript and JavaScript samples in docs/ # against the latest published @temporalio packages, and reports SDK methods, # properties, and exports that the samples use but the packages don't have. -# The same pass checks docs links written in code comments. -# -# This is advisory. Findings never fail the job: the packages are fetched at -# their latest version, so an SDK release can break a sample without any docs -# change, and a page may deliberately show an older API. On pull requests it -# annotates the changed pages. On its weekly run it opens a tracking issue, -# updates it while findings remain, and closes it once they're gone. The job -# fails only when the check can't run at all, for example when npm is -# unreachable. +# The same pass checks docs links written in code comments. Advisory; the +# steps are shared with the other languages in check-code-samples.yml. on: schedule: @@ -24,7 +17,10 @@ on: - 'bin/check-typescript-samples.js' - 'bin/check-typescript-samples.test.js' - 'bin/typescript-samples-baseline.json' + - 'bin/code-samples.js' + - 'bin/code-samples.test.js' - '.github/workflows/check-typescript-samples.yml' + - '.github/workflows/check-code-samples.yml' permissions: contents: read @@ -35,142 +31,12 @@ concurrency: jobs: check: - name: Check samples against the SDK packages - runs-on: ubuntu-latest permissions: contents: read issues: write - steps: - - name: Check out repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - # Pull requests diff against their base to find the changed pages. - # (0 is falsy in an expression, so the condition is written this way.) - fetch-depth: ${{ github.event_name != 'pull_request' && 1 || 0 }} - - - name: Set up Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: '24' - cache: yarn - - - name: Install dependencies - run: yarn install --frozen-lockfile --ignore-scripts - - - name: Test the checker - run: node --test bin/check-typescript-samples.test.js - - # A pull request that changes the checker gets a full scan. Otherwise it - # gets only the pages it changes, so it isn't annotated with findings on - # pages it didn't touch. - - name: Choose the pages to check - id: scope - env: - EVENT: ${{ github.event_name }} - BASE_SHA: ${{ github.event.pull_request.base.sha }} - run: | - if [ "$EVENT" != "pull_request" ]; then - echo "mode=full" >> "$GITHUB_OUTPUT" - exit 0 - fi - - changed=$(git diff --name-only --diff-filter=d "$BASE_SHA"...HEAD) - if echo "$changed" | grep -qE '^(bin/check-typescript-samples|bin/typescript-samples-baseline|\.github/workflows/check-typescript-samples)'; then - echo "mode=full" >> "$GITHUB_OUTPUT" - exit 0 - fi - - pages=$(echo "$changed" | grep -E '^docs/.*\.mdx$' | tr '\n' ' ' || true) - if [ -z "$pages" ]; then - echo "mode=none" >> "$GITHUB_OUTPUT" - else - echo "mode=pages" >> "$GITHUB_OUTPUT" - echo "pages=$pages" >> "$GITHUB_OUTPUT" - fi - - - name: Check the samples - id: check - if: steps.scope.outputs.mode != 'none' - env: - PAGES: ${{ steps.scope.outputs.pages }} - run: | - set -f - set +e - # --github adds a workflow command per finding, which annotates the - # page as tee echoes it. The report proper is everything else. - node bin/check-typescript-samples.js --github $PAGES 2>&1 | tee output.txt - status=${PIPESTATUS[0]} - set -e - - grep -v '^::' output.txt > report.txt || true - - # 0 is clean and 2 is findings. Anything else means the check could - # not run, usually because npm or a package download failed. - if [ "$status" -ne 0 ] && [ "$status" -ne 2 ]; then - echo "::error::The TypeScript sample check could not run. See the log above." - exit "$status" - fi - - if [ "$status" -eq 2 ]; then - echo "findings=true" >> "$GITHUB_OUTPUT" - else - echo "findings=false" >> "$GITHUB_OUTPUT" - fi - - { - echo "### TypeScript samples (informational; does not block merge)" - echo - echo '```' - cat report.txt - echo '```' - } >> "$GITHUB_STEP_SUMMARY" - - - name: Open or update the tracking issue - if: >- - github.event_name != 'pull_request' && github.ref_name == github.event.repository.default_branch && - steps.check.outputs.findings == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_REPO: ${{ github.repository }} - TITLE: 'Hand-written TypeScript samples need fixes' - run: | - { - echo "\`bin/check-typescript-samples.js\` found hand-written TypeScript samples in" - echo "\`docs/\` that use SDK APIs the latest @temporalio packages don't have, or" - echo "docs links in code comments that don't resolve." - echo - echo '```' - cat report.txt - echo '```' - echo - echo "To resolve, fix the sample, or record the finding with a note in" - echo "\`bin/typescript-samples-baseline.json\` if the page shows that API on purpose." - echo "Reproduce locally with \`yarn check:ts-samples\`." - echo - echo "_Updated by [${GITHUB_WORKFLOW}](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})._" - } > issue-body.md - - number=$(gh issue list --state open --search "in:title \"$TITLE\"" --json number --jq '.[0].number // empty') - - if [ -n "$number" ]; then - gh issue edit "$number" --body-file issue-body.md - echo "Updated issue #$number." - else - gh issue create --title "$TITLE" --body-file issue-body.md - fi - - - name: Close the tracking issue - if: >- - github.event_name != 'pull_request' && github.ref_name == github.event.repository.default_branch && - steps.check.outputs.findings == 'false' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_REPO: ${{ github.repository }} - TITLE: 'Hand-written TypeScript samples need fixes' - run: | - number=$(gh issue list --state open --search "in:title \"$TITLE\"" --json number --jq '.[0].number // empty') - - if [ -n "$number" ]; then - gh issue close "$number" --comment "The checker no longer finds anything to fix." - fi + uses: ./.github/workflows/check-code-samples.yml + with: + language: TypeScript + checker: bin/check-typescript-samples.js + baseline: bin/typescript-samples-baseline.json + yarn-script: check:ts-samples diff --git a/.github/workflows/notify-automation-failures.yml b/.github/workflows/notify-automation-failures.yml index a7b91ad3a8..40de41ec65 100644 --- a/.github/workflows/notify-automation-failures.yml +++ b/.github/workflows/notify-automation-failures.yml @@ -16,6 +16,7 @@ on: - Update SDK Versions - Check Metrics Against SDKs - Check TypeScript Samples + - Check Python Samples - CLI Docs Update - Delete Visual Tests Reports - Warm Build Cache @@ -29,13 +30,13 @@ permissions: jobs: notify: - # `Check Metrics Against SDKs`, `Check TypeScript Samples`, `Environment - # config drift`, and `Snipsync Coverage` also run on pull_request; exclude - # those runs so this only reports invocations that have nowhere else to - # surface, such as the scheduled ones. + # `Check Metrics Against SDKs`, the sample checks, `Environment config + # drift`, and `Snipsync Coverage` also run on pull_request; exclude those + # runs so this only reports invocations that have nowhere else to surface, + # such as the scheduled ones. if: >- - github.event.workflow_run.event != 'pull_request' && - contains(fromJSON('["failure", "timed_out", "startup_failure"]'), github.event.workflow_run.conclusion) + github.event.workflow_run.event != 'pull_request' && contains(fromJSON('["failure", "timed_out", + "startup_failure"]'), github.event.workflow_run.conclusion) runs-on: ubuntu-latest steps: - name: Post failure to Slack diff --git a/AGENTS.md b/AGENTS.md index 847bfc6fb7..132a3515c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -185,6 +185,7 @@ yarn check:metrics # SDK metrics reference against itself; runs in CI on P yarn check:metrics:sdks # SDK metrics reference against the SDK sources; advisory, clones the SDK repos yarn check:orphans # docs pages Docusaurus renders but no sidebar entry links to; not yet wired into CI yarn check:redirects # vercel.json redirect rules that can't work as written; runs in CI on PRs +yarn check:py-samples # hand-written Python samples against the published SDK package; advisory yarn check:ts-samples # hand-written TypeScript samples against the published SDK packages; advisory ``` @@ -201,10 +202,11 @@ belongs in `bin/orphan-pages-baseline.json` with a note, rather than being silen containing `#`, a `:param` with no `/` before it, a rule shadowed by an earlier one, or a destination the site doesn't serve. Fix the rule. A known, accepted exception belongs in `bin/redirect-baseline.json` with a note. -`yarn check:ts-samples` type-checks the TypeScript and JavaScript code blocks that aren't synced by Snipsync against -the latest `@temporalio` packages, and reports methods, properties, and exports the packages don't have, plus docs -links in code comments that don't resolve. Pass page paths to check only those pages. A sample that shows an API -on purpose belongs in `bin/typescript-samples-baseline.json` with a note. +`yarn check:ts-samples` and `yarn check:py-samples` type-check the TypeScript and Python code blocks that aren't +synced by Snipsync against the latest SDK release, and report classes, methods, exports, and arguments the SDK doesn't +have. The TypeScript check also reports docs links in code comments that don't resolve. Pass page paths to check only +those pages. A sample that shows an API on purpose belongs in that language's `bin/*-samples-baseline.json` with a +note. Vale linting (style). Requires Vale 3.20+ (CI already runs 3.20.0; upgrade a local install with `brew upgrade vale`) — `vale/styles/Std` needs it for its nested rule directories. diff --git a/bin/check-python-samples.js b/bin/check-python-samples.js new file mode 100644 index 0000000000..277a95933b --- /dev/null +++ b/bin/check-python-samples.js @@ -0,0 +1,697 @@ +#!/usr/bin/env node + +// Type-checks the hand-written Python samples in docs/ against the published +// temporalio package, and reports SDK classes, functions, and modules that the +// samples use but the package doesn't have, and calls that pass the SDK an +// argument it doesn't take. +// +// The Python counterpart of bin/check-typescript-samples.js. Nothing compiles +// these samples otherwise, and more than 80 percent of the Python code on the +// site is hand-written rather than synced by Snipsync. +// +// Every hand-written sample is checked by Pyright, alongside the source of the +// latest temporalio release from PyPI, plus any companion distribution a +// sample imports from, such as temporalio-openai-agents. Pyright installs from +// npm, so this runs without a Python interpreter. Samples are fragments, so most of what Pyright +// reports is noise: undefined names, missing imports, placeholder arguments. +// Only diagnostics about something the temporalio package defines are kept. +// Pyright names a class or module in its messages but not where it's defined, +// so "defined by temporalio" means the name is in an index built from the +// package source and the sample doesn't define it itself. +// +// Missing required arguments are never reported, because samples routinely +// leave out arguments that aren't the point of the example. Argument count +// problems are skipped for a call that uses `...` as a placeholder argument. +// +// This is advisory rather than a merge gate, for the same reasons as the +// TypeScript checker. The command line, baseline, and reporting are shared +// with it; see bin/code-samples.js. +// +// node bin/check-python-samples.js # report +// node bin/check-python-samples.js docs/develop/python/workflows +// node bin/check-python-samples.js --sdk-version 1.34.0 # instead of latest + +const { execFile } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const { promisify } = require('util'); +const { main } = require('./code-samples'); + +const execFileAsync = promisify(execFile); + +const BASELINE = path.join('bin', 'python-samples-baseline.json'); + +const LANGUAGES = new Set(['python', 'py']); + +const SDK = 'temporalio'; + +// --------------------------------------------------------------------------- +// Packages +// --------------------------------------------------------------------------- + +const MAX_ATTEMPTS = 4; +const FIRST_RETRY_MS = 1000; +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +// Fetches from PyPI, retrying transient failures. Resolves to null on a 404. +async function fetchWithRetry(url) { + let lastError; + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + const res = await fetch(url); + if (res.status === 404) return null; + if (res.ok) return res; + lastError = new Error(`${url} returned ${res.status}`); + } catch (error) { + lastError = error; + } + if (attempt < MAX_ATTEMPTS) await sleep(FIRST_RETRY_MS * 2 ** (attempt - 1)); + } + throw new Error(`Could not fetch ${url}: ${lastError.message}`); +} + +async function release(name, version) { + const url = `https://pypi.org/pypi/${name}/${version === 'latest' ? '' : `${version}/`}json`; + const res = await fetchWithRetry(url); + return res ? res.json() : null; +} + +// Any wheel has the same Python source, so a pure-Python wheel is taken when +// there is one and the smallest platform wheel otherwise. The temporalio +// wheels are platform wheels because they carry the native bridge, which is +// left out when unpacking. +function chooseWheel(files, { pureOnly = false } = {}) { + const wheels = files.filter((f) => f.packagetype === 'bdist_wheel' && !f.yanked); + const pure = wheels.find((f) => /-py[23.]*-none-any\.whl$/.test(f.filename)); + if (pure || pureOnly) return pure ?? null; + return wheels.sort((a, b) => a.size - b.size)[0] ?? null; +} + +async function unpackWheel(file, cacheDir) { + const wheels = path.join(cacheDir, 'wheels'); + fs.mkdirSync(wheels, { recursive: true }); + const target = path.join(wheels, file.filename); + const res = await fetchWithRetry(file.url); + if (!res) throw new Error(`${file.url} returned 404`); + fs.writeFileSync(target, Buffer.from(await res.arrayBuffer())); + await execFileAsync('unzip', [ + '-q', + '-o', + target, + '-d', + path.join(cacheDir, 'site-packages'), + '-x', + '*.so', + '*.pyd', + '*.dylib', + ]); +} + +// The dependencies a plain install pulls in, skipping extras. An exact pin is +// honored; anything else takes the latest release. +function requirements(requiresDist) { + return (requiresDist ?? []) + .filter((r) => !/extra\s*==/.test(r)) + .map((r) => { + const m = r.match(/^([A-Za-z0-9._-]+)\s*(?:\[[^\]]*\])?\s*(?:\(?\s*==\s*([^,;)\s]+))?/); + return { name: m[1], version: m[2] ?? 'latest' }; + }); +} + +// Downloads temporalio and its pure-Python dependencies into +// /site-packages. Dependencies only sharpen the types; one that +// can't be found is skipped rather than failing the run. +async function fetchPackages(version, cacheDir) { + fs.rmSync(path.join(cacheDir, 'site-packages'), { recursive: true, force: true }); + + const sdk = await release(SDK, version); + if (!sdk) throw new Error(`Could not find ${SDK} ${version} on PyPI.`); + const wheel = chooseWheel(sdk.urls); + if (!wheel) throw new Error(`${SDK} ${sdk.info.version} has no wheel on PyPI.`); + await unpackWheel(wheel, cacheDir); + + const packages = [{ name: SDK, version: sdk.info.version }]; + await Promise.all( + requirements(sdk.info.requires_dist).map(async (req) => { + const dep = await release(req.name, req.version); + const depWheel = dep && chooseWheel(dep.urls, { pureOnly: true }); + if (!depWheel) return; + await unpackWheel(depWheel, cacheDir); + packages.push({ name: req.name, version: dep.info.version }); + }) + ); + return packages.sort((a, b) => (a.name === SDK ? -1 : b.name === SDK ? 1 : a.name.localeCompare(b.name))); +} + +// Subpackages of temporalio that the samples import but the temporalio +// wheel doesn't have. Some integrations ship as their own distribution that +// adds a subpackage: temporalio-openai-agents adds temporalio.openai_agents. +function missingSubpackages(codes, sitePackages) { + const names = new Set(); + for (const code of codes) { + for (const m of code.matchAll(/^[ \t]*(?:from|import)[ \t]+temporalio\.(\w+)/gm)) names.add(m[1]); + } + const present = (name) => + fs.existsSync(path.join(sitePackages, SDK, name)) || fs.existsSync(path.join(sitePackages, SDK, `${name}.py`)); + return [...names].filter((name) => !present(name)).sort(); +} + +// The distribution that would add a subpackage, by the naming those +// distributions follow. +const companionOf = (subpackage) => `${SDK}-${subpackage.replace(/_/g, '-')}`; + +// Fetches the companion distribution for each missing subpackage, at its +// latest release: companions are versioned separately from temporalio. A +// subpackage with no companion is left missing, and reported as such. +async function fetchCompanions(samples, cacheDir) { + const companions = []; + for (const subpackage of missingSubpackages( + samples.map((s) => s.code), + path.join(cacheDir, 'site-packages') + )) { + const name = companionOf(subpackage); + const dist = await release(name, 'latest'); + const wheel = dist && chooseWheel(dist.urls); + if (!wheel) continue; + await unpackWheel(wheel, cacheDir); + companions.push({ name, version: dist.info.version, companion: true }); + } + return companions; +} + +// --------------------------------------------------------------------------- +// The SDK index +// --------------------------------------------------------------------------- + +function walkPython(dir) { + if (!fs.existsSync(dir)) return []; + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return walkPython(full); + return /\.pyi?$/.test(entry.name) ? [full] : []; + }); +} + +// Names the package defines, read from its source. `types` are classes and +// module-level type aliases, `callables` are functions, methods, and classes, +// and `modules` are dotted module names. +function indexPackage(sitePackages, pkg = SDK) { + const types = new Set(); + const callables = new Set(); + const modules = new Set(); + const definedIn = new Map(); + for (const file of walkPython(path.join(sitePackages, pkg))) { + const rel = path.relative(sitePackages, file).split(path.sep); + const last = rel.pop().replace(/\.pyi?$/, ''); + modules.add([...rel, ...(last === '__init__' ? [] : [last])].join('.')); + + const source = fs.readFileSync(file, 'utf8'); + for (const m of source.matchAll(/^[ \t]*class[ \t]+([A-Za-z_]\w*)/gm)) { + types.add(m[1]); + callables.add(m[1]); + } + for (const m of source.matchAll(/^([A-Z]\w*)[ \t]*(?::[^=\n]*)?=/gm)) types.add(m[1]); + for (const m of source.matchAll(/^[ \t]*(?:async[ \t]+)?def[ \t]+([A-Za-z_]\w*)/gm)) { + callables.add(m[1]); + if (!definedIn.has(m[1])) definedIn.set(m[1], new Set()); + definedIn.get(m[1]).add(file); + } + } + return { types, callables, modules, definedIn }; +} + +// What every SDK function named `name` accepts, merged across overloads and +// across classes that define a method of that name: the keyword names, and +// the most positional arguments. Merging makes this permissive, which is the +// safe direction: it only decides whether to report, never what to report. +function signatureOf(name, index) { + const keywords = new Set(); + let positional = 0; + let anyKeyword = false; + let found = false; + for (const file of index.definedIn.get(name) ?? []) { + const source = fs.readFileSync(file, 'utf8'); + for (const m of source.matchAll(new RegExp(`^[ \\t]*(?:async[ \\t]+)?def[ \\t]+${name}[ \\t]*\\(`, 'gm'))) { + const open = m.index + m[0].length - 1; + const pair = bracketPairs(source.slice(open)).find((p) => p.open === 0); + if (!pair) continue; + found = true; + const params = callArguments(source.slice(open, open + pair.close + 1), { open: 0, close: pair.close }); + let count = 0; + let keywordOnly = false; + let positionalOnly = params.includes('/'); + params.forEach((param, i) => { + const p = param.match(/^(\*{0,2})([A-Za-z_]\w*)?/); + if (param === '/') { + positionalOnly = false; + return; + } + if (p[1] === '**') { + anyKeyword = true; + return; + } + if (p[1] === '*') { + if (p[2]) count = Infinity; + keywordOnly = true; + return; + } + if (i === 0 && (p[2] === 'self' || p[2] === 'cls')) return; + if (!positionalOnly) keywords.add(p[2]); + if (!keywordOnly) count++; + }); + positional = Math.max(positional, count); + } + } + return found ? { keywords, positional, anyKeyword } : null; +} + +// --------------------------------------------------------------------------- +// Reading a sample +// --------------------------------------------------------------------------- + +// Calls `visit(index, char)` for every character outside strings and +// comments, and `comment(start, end)` for every comment. +function scan(code, visit, comment = () => {}) { + let i = 0; + while (i < code.length) { + const c = code[i]; + if (c === '#') { + const end = code.indexOf('\n', i); + comment(i, end === -1 ? code.length : end); + i = end === -1 ? code.length : end; + continue; + } + if (c === '"' || c === "'") { + const quote = code.startsWith(c.repeat(3), i) ? c.repeat(3) : c; + i += quote.length; + while (i < code.length && !code.startsWith(quote, i)) { + if (code[i] === '\\') i++; + else if (quote.length === 1 && code[i] === '\n') break; + i++; + } + i += quote.length; + continue; + } + visit(i, c); + i++; + } +} + +// Every bracket pair, as { open, close, char }. A bracket left open at the +// end of a fragment closes at the end of the code. +function bracketPairs(code) { + const pairs = []; + const stack = []; + scan(code, (i, c) => { + if ('([{'.includes(c)) stack.push({ open: i, char: c }); + else if (')]}'.includes(c) && stack.length) pairs.push({ ...stack.pop(), close: i }); + }); + for (const left of stack) pairs.push({ ...left, close: code.length }); + return pairs; +} + +const CALLEE = /([A-Za-z_][\w]*(?:\s*\.\s*[A-Za-z_]\w*)*)\s*$/; + +function calleeBefore(code, open) { + const m = code.slice(Math.max(0, open - 200), open).match(CALLEE); + return m ? m[1].replace(/\s+/g, '') : null; +} + +// The innermost call whose parentheses contain `offset`, as { callee, open, +// close }, or null. +function enclosingCall(code, offset) { + const calls = bracketPairs(code) + .filter((p) => p.char === '(' && p.open < offset && offset <= p.close) + .sort((a, b) => b.open - a.open); + for (const pair of calls) { + const callee = calleeBefore(code, pair.open); + if (callee) return { callee, ...pair }; + } + return null; +} + +// The call whose callee starts at `offset`. +function callAt(code, offset) { + const m = code.slice(offset).match(/^([A-Za-z_][\w]*(?:\s*\.\s*[A-Za-z_]\w*)*)\s*\(/); + if (!m) return null; + const open = offset + m[0].length - 1; + const pair = bracketPairs(code).find((p) => p.open === open); + return pair ? { callee: m[1].replace(/\s+/g, ''), ...pair } : null; +} + +// The code with every comment blanked out, keeping offsets the same. +function withoutComments(code) { + // Split by UTF-16 unit, not code point, to match string offsets. + const chars = code.split(''); + scan( + code, + () => {}, + (start, end) => chars.fill(' ', start, end) + ); + return chars.join(''); +} + +// The top-level arguments of a call, as trimmed source text without comments. +function callArguments(code, call) { + const inner = withoutComments(code.slice(call.open + 1, call.close)); + const args = []; + let depth = 0; + let start = 0; + scan(inner, (i, c) => { + if ('([{'.includes(c)) depth++; + else if (')]}'.includes(c)) depth--; + else if (c === ',' && depth === 0) { + args.push(inner.slice(start, i)); + start = i + 1; + } + }); + args.push(inner.slice(start)); + return args.map((a) => a.trim()).filter(Boolean); +} + +// `...` standing in for arguments the sample leaves out. +const hasPlaceholder = (code, call) => callArguments(code, call).includes('...'); + +// The module each imported name came from: `from temporalio import workflow` +// maps workflow to temporalio, `import asyncio` maps asyncio to asyncio. +// Each statement is also returned with its span, to find the module of an +// imported name at a position. +function imports(code) { + const bindings = new Map(); + const statements = []; + for (const m of code.matchAll(/^[ \t]*from[ \t]+([\w.]+)[ \t]+import[ \t]+(\([^)]*\)|[^\n]*)/gm)) { + statements.push({ module: m[1], start: m.index, end: m.index + m[0].length }); + for (const part of m[2] + .replace(/[()\\]/g, ' ') + .replace(/#.*$/gm, '') + .split(',')) { + const name = part.trim().split(/\s+as\s+/); + if (name[0]) bindings.set((name[1] ?? name[0]).trim(), m[1]); + } + } + for (const m of code.matchAll(/^[ \t]*import[ \t]+([^\n#]+)/gm)) { + for (const part of m[1].split(',')) { + const [module, alias] = part.trim().split(/\s+as\s+/); + if (module) bindings.set(alias ?? module.split('.')[0], module); + } + } + return { bindings, statements }; +} + +// Classes and functions the sample defines itself, at any depth. +function definedNames(code) { + return new Set([...code.matchAll(/^[ \t]*(?:async[ \t]+)?(?:class|def)[ \t]+([A-Za-z_]\w*)/gm)].map((m) => m[1])); +} + +const isSdkModule = (module) => module === SDK || module.startsWith(`${SDK}.`); + +function offsetOf(code, { line, character }) { + let offset = 0; + for (let i = 0; i < line; i++) offset = code.indexOf('\n', offset) + 1; + return offset + character; +} + +// A short form of the expression a diagnostic is about, such as +// workflow.RetryPolicy, from the dotted name just before `offset`. +function expressionBefore(code, offset, name) { + const before = code.slice(Math.max(0, offset - 200), offset); + const m = before.match(/([A-Za-z_][\w.]*(?:\([^()]*\))?)\.\s*$/); + return m ? `${m[1]}.${name}` : name; +} + +// --------------------------------------------------------------------------- +// Classification +// --------------------------------------------------------------------------- + +// The class named in "for class ...": type[Client] is Client, and +// WorkflowHandle[Any, Any] is WorkflowHandle. Pyright marks a partially +// inferred class with a trailing *. +function ownerClass(text) { + const inner = text.replace(/^type\[(.*)\]$/, '$1'); + const m = inner.match(/^[A-Za-z_]\w*/); + return m ? m[0] : null; +} + +// Turns one Pyright diagnostic into a finding, or null when it isn't about +// something temporalio defines. `index` is the SDK index; `sitePackages` is +// where the SDK source is, for telling a submodule from a missing attribute. +function classify(diagnostic, code, { index }) { + let message = diagnostic.message.split('\n')[0]; + const offset = offsetOf(code, diagnostic.range.start); + let m; + + if (diagnostic.rule === 'reportAttributeAccessIssue') { + if ((m = message.match(/^"(\w+)" is not a known attribute of module "([\w.]+)"/))) { + const [, name, module] = m; + if (!isSdkModule(module)) return null; + // `import temporalio` followed by temporalio.common works at runtime + // whenever something else has imported the submodule, which is almost + // always. Pyright still reports it; that's not what this check is for. + if (index.modules.has(`${module}.${name}`)) return null; + return { + kind: 'missing-export', + subject: `${module}:${name}`, + message: `${expressionBefore(code, offset, name)}: ${module} has no attribute '${name}'.`, + }; + } + + if ((m = message.match(/^"(\w+)" is unknown import symbol/))) { + const statement = imports(code).statements.find((s) => s.start <= offset && offset < s.end); + if (!statement || !isSdkModule(statement.module)) return null; + return { + kind: 'missing-export', + subject: `${statement.module}:${m[1]}`, + message: `${statement.module} has no export named '${m[1]}'.`, + }; + } + + if ((m = message.match(/^Cannot access attribute "(\w+)" for class "(.+)"$/))) { + const owner = ownerClass(m[2]); + if (!owner || !index.types.has(owner) || definedNames(code).has(owner)) return null; + return { + kind: 'missing-member', + subject: `${owner}.${m[1]}`, + message: `${expressionBefore(code, offset, m[1])}: ${message}`, + }; + } + return null; + } + + // Most syntax errors in a fragment come from it being a fragment, such as + // `await` outside a function. This one never does. + if (!diagnostic.rule && message === 'Positional argument cannot appear after keyword arguments') { + // Except when the positional argument is a `...` placeholder. + if (code.slice(offset, offsetOf(code, diagnostic.range.end)).trim() === '...') return null; + const call = enclosingCall(code, offset); + return { + kind: 'syntax-error', + subject: call?.callee ?? 'call', + message: `${call?.callee ?? 'A call'}(): ${message}`, + }; + } + + if (diagnostic.rule === 'reportMissingImports') { + if (!(m = message.match(/^Import "([\w.]+)" could not be resolved$/)) || !isSdkModule(m[1])) return null; + return { kind: 'missing-module', subject: m[1], message: `No module named '${m[1]}'.` }; + } + + if (diagnostic.rule === 'reportCallIssue') { + let call; + let subject; + if ((m = message.match(/^No parameter named "(\w+)"/))) { + call = enclosingCall(code, offset); + subject = call && `${call.callee}(${m[1]}=)`; + } else if (/^Expected \d+ positional argument/.test(message)) { + call = enclosingCall(code, offset); + if (call && hasPlaceholder(code, call)) return null; + subject = call?.callee; + } else if ((m = message.match(/^No overloads for "(\w+)" match/))) { + // Pyright doesn't say why no overload matched, and the usual reason in + // a sample is a missing argument. Report only a keyword no overload + // takes, or more positional arguments than any overload takes. + call = callAt(code, offset); + if (!call || hasPlaceholder(code, call)) return null; + const signature = signatureOf(m[1], index); + if (!signature) return null; + const args = callArguments(code, call).filter((a) => !a.startsWith('*')); + const keywords = args.map((a) => a.match(/^([A-Za-z_]\w*)\s*=(?!=)/)?.[1]).filter(Boolean); + const unknown = signature.anyKeyword ? undefined : keywords.find((k) => !signature.keywords.has(k)); + const positional = args.length - keywords.length; + if (unknown) { + if (!isSdkCallee(call.callee, code, index)) return null; + return { + kind: 'bad-call', + subject: `${call.callee}(${unknown}=)`, + message: `${call.callee}(): No parameter named "${unknown}"`, + }; + } + if (positional <= signature.positional) return null; + subject = call.callee; + message = `Expected ${signature.positional} positional argument${signature.positional === 1 ? '' : 's'}`; + } else { + // Missing arguments, mostly: samples leave those out on purpose. + return null; + } + if (!call || !isSdkCallee(call.callee, code, index)) return null; + return { kind: 'bad-call', subject, message: `${call.callee}(): ${message}` }; + } + + return null; +} + +// Whether a call like workflow.execute_activity(...) or Worker(...) is a call +// into the SDK. An imported root decides it; otherwise the called name has to +// be one the SDK defines and the sample doesn't. +function isSdkCallee(callee, code, index) { + const segments = callee.split('.'); + const name = segments[segments.length - 1]; + if (definedNames(code).has(name)) return false; + const root = imports(code).bindings.get(segments[0]); + if (root !== undefined) return isSdkModule(root); + return index.callables.has(name); +} + +// --------------------------------------------------------------------------- +// Running Pyright +// --------------------------------------------------------------------------- + +// Names a sample often uses without importing, because the import is in an +// earlier sample on the page. Pyright treats a __builtins__.pyi at the project +// root as extra builtins, so a sample's own import or assignment of the same +// name shadows them. +// +// Unlike the TypeScript checker, an undeclared `client` is not assumed to be +// a Client: the Python pages also use that name for the temporalio.client +// module and for other clients, such as a Workflow Streams client. A bare +// `temporalio` can't be declared here either, because Pyright doesn't expose +// modules imported by the builtins file. +const AMBIENT = `from temporalio import activity as activity, nexus as nexus, workflow as workflow +from temporalio.client import Client as Client +from temporalio.common import RetryPolicy as RetryPolicy +from temporalio.exceptions import ApplicationError as ApplicationError +from temporalio.worker import Worker as Worker +`; + +function dedentBlock(code) { + const lines = code.split('\n'); + const indents = lines.filter((l) => l.trim()).map((l) => l.match(/^[ \t]*/)[0].length); + const common = indents.length ? Math.min(...indents) : 0; + return lines.map((l) => l.slice(Math.min(common, l.match(/^[ \t]*/)[0].length))).join('\n'); +} + +// Writes the samples into a Pyright project and returns its diagnostics, +// keyed by sample index. `sitePackages` holds the SDK source. +async function runPyright(samples, { projectDir, sitePackages }) { + fs.rmSync(projectDir, { recursive: true, force: true }); + fs.mkdirSync(path.join(projectDir, 'samples'), { recursive: true }); + fs.writeFileSync(path.join(projectDir, '__builtins__.pyi'), AMBIENT); + fs.writeFileSync( + path.join(projectDir, 'pyrightconfig.json'), + JSON.stringify({ + include: ['samples'], + extraPaths: [path.relative(projectDir, sitePackages)], + pythonVersion: '3.13', + typeCheckingMode: 'standard', + reportMissingModuleSource: 'none', + }) + ); + const codes = samples.map((sample, i) => { + const code = dedentBlock(sample.code); + fs.writeFileSync(path.join(projectDir, 'samples', `sample_${i}.py`), code); + return code; + }); + + const pyright = require.resolve('pyright/index.js'); + let stdout; + try { + ({ stdout } = await execFileAsync( + process.execPath, + [pyright, '--outputjson', '-p', path.join(projectDir, 'pyrightconfig.json')], + { maxBuffer: 1024 * 1024 * 256 } + )); + } catch (error) { + // Pyright exits 1 when it reports errors, which is the normal case here. + if (error.code !== 1 || !error.stdout) throw new Error(`Pyright failed:\n${error.stderr || error.message}`); + stdout = error.stdout; + } + + const byIndex = new Map(); + for (const diagnostic of JSON.parse(stdout).generalDiagnostics) { + const m = diagnostic.file.match(/sample_(\d+)\.py$/); + if (!m) continue; + const i = Number(m[1]); + if (!byIndex.has(i)) byIndex.set(i, []); + byIndex.get(i).push(diagnostic); + } + return { codes, byIndex }; +} + +// Type-checks the samples against the SDK in `sitePackages` and returns the +// findings. `version` labels the messages. +async function checkSamples(samples, { sitePackages, projectDir, version }) { + const index = indexPackage(sitePackages); + const { codes, byIndex } = await runPyright(samples, { projectDir, sitePackages }); + + const findings = []; + for (const [i, diagnostics] of byIndex) { + for (const diagnostic of diagnostics) { + const finding = classify(diagnostic, codes[i], { index }); + if (!finding) continue; + findings.push({ + file: samples[i].file, + line: samples[i].line + diagnostic.range.start.line, + ...finding, + message: version ? `${finding.message} (${SDK} ${version})` : finding.message, + }); + } + } + return findings; +} + +async function check(samples, { version, cacheDir }) { + const [sdk, ...dependencies] = await fetchPackages(version, cacheDir); + const companions = await fetchCompanions(samples, cacheDir); + const findings = await checkSamples(samples, { + sitePackages: path.join(cacheDir, 'site-packages'), + projectDir: path.join(cacheDir, 'project'), + version: sdk.version, + }); + const list = (packages) => packages.map((p) => `${p.name} ${p.version}`).join(', '); + const sources = [`Checked against ${SDK} ${sdk.version} from PyPI.`]; + if (dependencies.length) sources.push(` Dependencies: ${list(dependencies)}`); + if (companions.length) sources.push(` Companion distributions: ${list(companions)}`); + return { findings, sources, details: { packages: [sdk, ...dependencies, ...companions] } }; +} + +const CHECKER = { + script: 'bin/check-python-samples.js', + languages: LANGUAGES, + baseline: BASELINE, + title: 'Python sample', + check, +}; + +module.exports = { + AMBIENT, + BASELINE, + LANGUAGES, + chooseWheel, + requirements, + missingSubpackages, + companionOf, + indexPackage, + bracketPairs, + enclosingCall, + callAt, + callArguments, + imports, + definedNames, + ownerClass, + signatureOf, + classify, + isSdkCallee, + dedentBlock, + checkSamples, +}; + +if (require.main === module) main(CHECKER); diff --git a/bin/check-python-samples.test.js b/bin/check-python-samples.test.js new file mode 100644 index 0000000000..d65ccd0bed --- /dev/null +++ b/bin/check-python-samples.test.js @@ -0,0 +1,308 @@ +const { describe, it, before, after } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { + chooseWheel, + requirements, + missingSubpackages, + companionOf, + bracketPairs, + enclosingCall, + callAt, + callArguments, + imports, + definedNames, + ownerClass, + dedentBlock, + checkSamples, +} = require('./check-python-samples.js'); + +describe('requirements', () => { + it('keeps what a plain install pulls in, honoring an exact pin', () => { + assert.deepStrictEqual( + requirements([ + 'nexus-rpc==1.4.0', + 'protobuf<7,>=3.20', + 'python-dateutil<3,>=2.8.2; python_version < "3.11"', + 'grpcio<2,>=1.48.2; extra == "grpc"', + 'opentelemetry-api[sdk]<2; extra == "opentelemetry"', + ]), + [ + { name: 'nexus-rpc', version: '1.4.0' }, + { name: 'protobuf', version: 'latest' }, + { name: 'python-dateutil', version: 'latest' }, + ] + ); + }); +}); + +describe('chooseWheel', () => { + const wheel = (filename, size) => ({ packagetype: 'bdist_wheel', filename, size, url: filename }); + const files = [ + { packagetype: 'sdist', filename: 'temporalio-1.0.0.tar.gz', size: 1 }, + wheel('temporalio-1.0.0-cp310-abi3-manylinux_2_17_x86_64.whl', 30), + wheel('temporalio-1.0.0-cp310-abi3-macosx_11_0_arm64.whl', 20), + ]; + + it('takes the smallest wheel when none is pure Python', () => { + assert.strictEqual(chooseWheel(files).filename, 'temporalio-1.0.0-cp310-abi3-macosx_11_0_arm64.whl'); + }); + + it('prefers a pure-Python wheel, and can insist on one', () => { + assert.strictEqual(chooseWheel([...files, wheel('x-1.0-py3-none-any.whl', 50)]).filename, 'x-1.0-py3-none-any.whl'); + assert.strictEqual(chooseWheel(files, { pureOnly: true }), null); + }); +}); + +describe('reading a sample', () => { + it('ignores brackets inside strings and comments', () => { + const code = 'f("(", \'[\', """{""") # ) ]\ng()'; + assert.deepStrictEqual( + bracketPairs(code).map((p) => code.slice(p.open, p.close + 1)), + ['("(", \'[\', """{""")', '()'] + ); + }); + + it('finds the innermost call around a position, skipping grouping parentheses', () => { + const code = 'await client.start_workflow(\n Wf.run,\n (1 + 2),\n id=make_id(x),\n bogus=True,\n)'; + assert.strictEqual(enclosingCall(code, code.indexOf('bogus')).callee, 'client.start_workflow'); + assert.strictEqual(enclosingCall(code, code.indexOf('x),')).callee, 'make_id'); + assert.strictEqual(enclosingCall(code, code.indexOf('2)')).callee, 'client.start_workflow'); + }); + + it('closes a call the fragment leaves open at the end of the code', () => { + const code = 'Worker(\n client,\n task_queue="q",'; + assert.strictEqual(enclosingCall(code, code.indexOf('task_queue')).callee, 'Worker'); + }); + + it('reads the call that starts at a position', () => { + const code = 'handle = await workflow.execute_activity(fn, 1)'; + const call = callAt(code, code.indexOf('workflow')); + assert.strictEqual(call.callee, 'workflow.execute_activity'); + assert.deepStrictEqual(callArguments(code, call), ['fn', '1']); + }); + + it('splits arguments at top-level commas only, without comments', () => { + const code = 'f(a, g(b, c), "d, e", [1, 2],\n # a comment, with a comma\n key=value,\n ...)'; + assert.deepStrictEqual(callArguments(code, callAt(code, 0)), [ + 'a', + 'g(b, c)', + '"d, e"', + '[1, 2]', + 'key=value', + '...', + ]); + }); + + it('maps imported names to the module they came from', () => { + const code = [ + 'import asyncio', + 'import temporalio.client as tc', + 'from temporalio import activity, workflow as wf', + 'from temporalio.worker import (', + ' Worker, # the worker', + ' UnsandboxedWorkflowRunner,', + ')', + ].join('\n'); + assert.deepStrictEqual(Object.fromEntries(imports(code).bindings), { + activity: 'temporalio', + wf: 'temporalio', + Worker: 'temporalio.worker', + UnsandboxedWorkflowRunner: 'temporalio.worker', + asyncio: 'asyncio', + tc: 'temporalio.client', + }); + }); + + it('collects the classes and functions a sample defines', () => { + const code = + '@workflow.defn\nclass Greeting:\n @workflow.run\n async def run(self):\n pass\ndef helper(): pass'; + assert.deepStrictEqual([...definedNames(code)].sort(), ['Greeting', 'helper', 'run']); + }); + + it('reads the class out of a Pyright type', () => { + assert.strictEqual(ownerClass('type[SearchAttributes]'), 'SearchAttributes'); + assert.strictEqual(ownerClass('WorkflowHandle[Any, Any]'), 'WorkflowHandle'); + assert.strictEqual(ownerClass('GreetingWorkflow*'), 'GreetingWorkflow'); + }); + + it('removes the indentation a sample shares, so a method excerpt parses', () => { + assert.strictEqual( + dedentBlock(' @workflow.run\n async def run(self):\n pass'), + '@workflow.run\nasync def run(self):\n pass' + ); + }); +}); + +// A fake temporalio, trimmed to the shapes the matching has to get right. The +// real one is fetched from PyPI, which a unit test shouldn't depend on. +const FAKE_SDK = { + '__init__.py': '', + 'activity.py': '', + 'nexus.py': '', + 'common.py': 'class RetryPolicy:\n maximum_attempts: int\n', + 'exceptions.py': 'class ApplicationError(Exception): ...\n', + 'client.py': ` +from typing import Any, overload + +class Client: + @staticmethod + async def connect(target_host: str, *, namespace: str = "default") -> "Client": ... + + @overload + async def start_workflow(self, workflow: str, arg: Any = None, *, id: str, task_queue: str) -> None: ... + @overload + async def start_workflow(self, workflow: Any, arg: Any = None, *, id: str, task_queue: str, start_delay: Any = None) -> None: ... + async def start_workflow(self, workflow: Any, arg: Any = None, *, id: str, task_queue: str, start_delay: Any = None) -> None: ... +`, + 'worker.py': ` +from typing import Any + +class Worker: + def __init__(self, client: Any, *, task_queue: str, workflows: Any = None) -> None: ... + # Shares its name with asyncio.run, which the samples also call. + async def run(self) -> None: ... +`, + 'workflow.py': ` +from typing import Any + +class Info: + workflow_id: str + +def info() -> Info: ... + +def execute_activity(activity: Any, arg: Any = None, *, start_to_close_timeout: Any = None) -> Any: ... +`, +}; + +describe('checkSamples', () => { + let dir; + let findings; + + // Every sample is checked in one Pyright run, the way the real run does it. + const SAMPLES = { + staticMethod: + 'from temporalio.client import Client\nclient = await Client.connect("localhost:7233")\nawait Client.create()', + ambientClass: 'Client.nope()', + instanceMember: 'from temporalio import workflow\nworkflow.info().workflow_idd', + moduleAttribute: 'from temporalio import workflow\nworkflow.RetryPolicy(maximum_attempts=3)', + ambientModule: 'workflow.RetryPolicy()', + importedName: 'from temporalio.worker import Worker, WorkerDeploymentOptions', + missingModule: 'import temporalio.openai_agents', + unknownKeyword: + 'from temporalio.worker import Worker\nWorker(client, task_queue="q", worker_deployment_options=None)', + missingArgument: 'Worker(client, workflows=[])', + placeholder: 'Worker(..., workflows=[])', + overloadKeyword: [ + 'from temporalio.client import Client', + 'client = await Client.connect("localhost:7233")', + 'await client.start_workflow(', + ' Wf.run,', + ' "a",', + ' id="x",', + ' task_queue="q",', + ' # Not a start_workflow option', + ' disable_eager_activity_execution=True,', + ')', + ].join('\n'), + overloadMissing: + 'from temporalio.client import Client\nclient = await Client.connect("x")\nawait client.start_workflow(Wf.run, "a", task_queue="q")', + tooManyPositional: 'workflow.execute_activity(fn, 1, [2], start_to_close_timeout=None)', + positionalAfterKeyword: 'workflow.execute_activity(activity="fn", name)', + placeholderAfterKeyword: 'Worker(task_queue="q", ...)', + ownClass: + 'class Client:\n pass\nClient.nope()\nclass Greeting:\n def run(self):\n return self.greetings', + submodule: 'import temporalio\ntemporalio.common.RetryPolicy()', + notTheSdk: 'import asyncio\nasyncio.run(main(), bogus=True)', + undefinedNames: 'result = await handle.result()\nmy_helper(x=1)', + }; + const names = Object.keys(SAMPLES); + const of = (name) => findings.filter((f) => f.file === `docs/${name}.mdx`).map((f) => [f.kind, f.subject]); + + before(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'check-python-samples-')); + const sdk = path.join(dir, 'site-packages', 'temporalio'); + fs.mkdirSync(sdk, { recursive: true }); + for (const [file, source] of Object.entries(FAKE_SDK)) fs.writeFileSync(path.join(sdk, file), source); + findings = await checkSamples( + names.map((name) => ({ file: `docs/${name}.mdx`, line: 10, code: SAMPLES[name] })), + { sitePackages: path.join(dir, 'site-packages'), projectDir: path.join(dir, 'project'), version: '9.9.9' } + ); + }); + + after(() => fs.rmSync(dir, { recursive: true, force: true })); + + it('flags a static method the class does not have, at its line on the page', () => { + const [finding] = findings.filter((f) => f.file === 'docs/staticMethod.mdx'); + assert.deepStrictEqual([finding.kind, finding.subject, finding.line], ['missing-member', 'Client.create', 12]); + assert.match( + finding.message, + /^Client\.create: Cannot access attribute "create" for class "type\[Client\]" \(temporalio 9\.9\.9\)$/ + ); + }); + + it('flags a member missing on an instance, and on a class used without importing it', () => { + assert.deepStrictEqual(of('instanceMember'), [['missing-member', 'Info.workflow_idd']]); + assert.deepStrictEqual(of('ambientClass'), [['missing-member', 'Client.nope']]); + }); + + it('flags a name a module does not have, through an import or the ambient workflow module', () => { + assert.deepStrictEqual(of('moduleAttribute'), [['missing-export', 'temporalio.workflow:RetryPolicy']]); + assert.deepStrictEqual(of('ambientModule'), [['missing-export', 'temporalio.workflow:RetryPolicy']]); + assert.deepStrictEqual(of('importedName'), [['missing-export', 'temporalio.worker:WorkerDeploymentOptions']]); + }); + + it('flags a module the package does not have', () => { + assert.deepStrictEqual(of('missingModule'), [['missing-module', 'temporalio.openai_agents']]); + }); + + it('flags a keyword the SDK does not take, even past a comment in an overloaded call', () => { + assert.deepStrictEqual(of('unknownKeyword'), [['bad-call', 'Worker(worker_deployment_options=)']]); + assert.deepStrictEqual(of('overloadKeyword'), [ + ['bad-call', 'client.start_workflow(disable_eager_activity_execution=)'], + ]); + }); + + it('flags more positional arguments than the SDK takes', () => { + assert.deepStrictEqual(of('tooManyPositional'), [['bad-call', 'workflow.execute_activity']]); + }); + + it('flags a positional argument after a keyword argument, unless it is a placeholder', () => { + assert.deepStrictEqual(of('positionalAfterKeyword'), [['syntax-error', 'workflow.execute_activity']]); + assert.deepStrictEqual(of('placeholderAfterKeyword'), []); + }); + + it('does not report arguments a sample leaves out', () => { + assert.deepStrictEqual(of('missingArgument'), []); + assert.deepStrictEqual(of('placeholder'), []); + assert.deepStrictEqual(of('overloadMissing'), []); + }); + + it("ignores the sample's own classes, submodules, other libraries, and undefined names", () => { + assert.deepStrictEqual(of('ownClass'), []); + assert.deepStrictEqual(of('submodule'), []); + assert.deepStrictEqual(of('notTheSdk'), []); + assert.deepStrictEqual(of('undefinedNames'), []); + }); +}); + +describe('missingSubpackages', () => { + it('names the temporalio subpackages a sample imports that the wheel lacks, and their distributions', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'check-python-samples-')); + try { + fs.mkdirSync(path.join(dir, 'temporalio', 'contrib'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'temporalio', 'workflow.py'), ''); + const codes = [ + 'from temporalio import workflow\nfrom temporalio.workflow import defn', + 'from temporalio.openai_agents import OpenAIAgentsPlugin\nimport temporalio.contrib.pydantic', + 'from temporalio.openai_agents.workflow import activity_as_tool', + ]; + assert.deepStrictEqual(missingSubpackages(codes, dir), ['openai_agents']); + assert.strictEqual(companionOf('openai_agents'), 'temporalio-openai-agents'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/bin/check-typescript-samples.js b/bin/check-typescript-samples.js index e34673532c..b7a04090ae 100644 --- a/bin/check-typescript-samples.js +++ b/bin/check-typescript-samples.js @@ -24,157 +24,30 @@ // their latest version, so a sample can start failing when an SDK release // removes something, and a page may deliberately show an older API. // +// The command line, baseline, and reporting are shared with the other sample +// checkers; see bin/code-samples.js. +// // node bin/check-typescript-samples.js # report // node bin/check-typescript-samples.js docs/develop/typescript/client -// node bin/check-typescript-samples.js --json # machine-readable -// node bin/check-typescript-samples.js --github # also print Actions annotations -// node bin/check-typescript-samples.js --update-baseline # accept current findings // node bin/check-typescript-samples.js --sdk-version 1.24.0 # instead of latest -// node bin/check-typescript-samples.js --cache-dir /tmp/ts-sdk -// -// Exit codes: 0 clean, 2 findings (or stale baseline entries), 1 the check -// could not run at all, for example because npm was unreachable. const { execFile } = require('child_process'); const fs = require('fs'); -const os = require('os'); const path = require('path'); const { promisify } = require('util'); const ts = require('typescript'); +const { DOCS_DIR, main } = require('./code-samples'); const execFileAsync = promisify(execFile); -const DOCS_DIR = 'docs'; const BASELINE = path.join('bin', 'typescript-samples-baseline.json'); -const DEFAULT_COMMENT = - 'Findings from bin/check-typescript-samples.js that we have decided to leave as they are, for ' + - 'example a sample that deliberately shows an older API. Each entry is a page, a kind, and a subject; ' + - 'line numbers are left out so an entry survives edits elsewhere on the page. An empty note means the ' + - 'entry has not been reviewed yet; either fix the sample or fill in the note explaining why it stays. ' + - 'Regenerate with: node bin/check-typescript-samples.js --update-baseline'; - const LANGUAGES = new Set(['ts', 'typescript', 'js', 'javascript']); // --------------------------------------------------------------------------- -// Extraction +// Packages // --------------------------------------------------------------------------- -// Snipsync wraps synced code in either an HTML comment or an MDX comment. -const SNIPSTART = /^\s*(?:/g, '').replace(/\{\/\*.*?\*\/\}/g, ''); - if (rest.includes(''; - if (rest.includes('{/*')) return '*/}'; - return null; -} - -function dedent(line, indent) { - let i = 0; - while (i < indent && (line[i] === ' ' || line[i] === '\t')) i++; - return line.slice(i); -} - -// Every fenced code block on a page, with the 1-based line of its first line -// of code and whether Snipsync manages it. Code inside a list item or a JSX -// component is indented to match its fence; that indentation is removed. -function extractCodeBlocks(source) { - const lines = source.split('\n'); - const blocks = []; - let fence = null; - let snipsync = false; - let comment = null; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - - if (fence) { - const close = line.match(FENCE_CLOSE); - if (close && close[1][0] === fence.marker[0] && close[1].length >= fence.marker.length) { - blocks.push({ - lang: fence.lang, - line: fence.line, - code: fence.body.join('\n'), - snipsync: fence.snipsync, - }); - fence = null; - } else { - fence.body.push(dedent(line, fence.indent)); - } - continue; - } - - if (comment) { - if (line.includes(comment)) comment = null; - continue; - } - - if (SNIPSTART.test(line)) { - snipsync = true; - continue; - } - if (SNIPEND.test(line)) { - snipsync = false; - continue; - } - - const open = line.match(FENCE_OPEN); - if (open) { - fence = { - marker: open[2], - indent: open[1].length, - lang: open[3].toLowerCase(), - line: i + 2, - body: [], - snipsync, - }; - continue; - } - - comment = openComment(line); - } - - return blocks; -} - -// The hand-written TypeScript and JavaScript samples on a page. -function extractSamples(source) { - return extractCodeBlocks(source).filter((b) => LANGUAGES.has(b.lang) && !b.snipsync); -} - -function walkMdx(target) { - const stat = fs.statSync(target); - if (stat.isFile()) return target.endsWith('.mdx') ? [target] : []; - return fs - .readdirSync(target, { withFileTypes: true }) - .sort((a, b) => a.name.localeCompare(b.name)) - .flatMap((entry) => walkMdx(path.join(target, entry.name))); -} - -function collectSamples(targets) { - const samples = []; - let snipsync = 0; - for (const file of [...new Set(targets.flatMap(walkMdx))]) { - const blocks = extractCodeBlocks(fs.readFileSync(file, 'utf8')); - for (const block of blocks) { - if (!LANGUAGES.has(block.lang)) continue; - if (block.snipsync) { - snipsync++; - continue; - } - samples.push({ file: file.split(path.sep).join('/'), line: block.line, code: block.code }); - } - } - return { samples, snipsync }; -} - // The @temporalio package names a sample imports or requires. function importedPackages(code) { const names = new Set(); @@ -185,10 +58,6 @@ function importedPackages(code) { return names; } -// --------------------------------------------------------------------------- -// Packages -// --------------------------------------------------------------------------- - // Always fetched, because the ambient declarations below refer to them. const CORE_PACKAGES = ['client', 'worker', 'workflow', 'activity', 'testing', 'common'].map( (name) => `@temporalio/${name}` @@ -759,43 +628,9 @@ function checkCommentLinks(samples, site) { } // --------------------------------------------------------------------------- -// Baseline and reporting +// Running // --------------------------------------------------------------------------- -const keyOf = (f) => `${f.file}\u0000${f.kind}\u0000${f.subject}`; - -// Accepted findings are recorded without line numbers, so one entry covers -// every occurrence of the same subject on a page. -function applyBaseline(findings, baseline) { - const known = new Set(baseline.findings.map(keyOf)); - const found = new Set(findings.map(keyOf)); - return { - remaining: findings.filter((f) => !known.has(keyOf(f))), - baselined: findings.filter((f) => known.has(keyOf(f))).length, - stale: baseline.findings.filter((e) => !found.has(keyOf(e))), - }; -} - -function updatedBaseline(findings, baseline) { - const notes = new Map(baseline.findings.map((e) => [keyOf(e), e.note])); - const entries = new Map(); - for (const f of findings) { - const key = keyOf(f); - if (!entries.has(key)) { - entries.set(key, { file: f.file, kind: f.kind, subject: f.subject, note: notes.get(key) ?? '' }); - } - } - return { - comment: baseline.comment || DEFAULT_COMMENT, - findings: [...entries.values()].sort((a, b) => keyOf(a).localeCompare(keyOf(b))), - }; -} - -function loadBaseline() { - if (!fs.existsSync(BASELINE)) return { comment: DEFAULT_COMMENT, findings: [] }; - return JSON.parse(fs.readFileSync(BASELINE, 'utf8')); -} - function describeSources(packages) { const fetched = packages.filter((p) => p.version); const versions = [...new Set(fetched.map((p) => p.version))]; @@ -811,74 +646,7 @@ function describeSources(packages) { return lines; } -function report({ remaining, baselined, stale }, { packages, sampleCount, snipsync, fullScan }) { - const lines = [...describeSources(packages)]; - lines.push(`${sampleCount} hand-written samples checked; ${snipsync} Snipsync samples skipped.`, ''); - - const byFile = new Map(); - for (const f of remaining) { - if (!byFile.has(f.file)) byFile.set(f.file, []); - byFile.get(f.file).push(f); - } - for (const [file, list] of byFile) { - lines.push(file); - for (const f of list) lines.push(` ${String(f.line).padStart(5)} ${f.kind.padEnd(14)} ${f.message}`); - lines.push(''); - } - - if (fullScan && stale.length) { - lines.push('Baseline entries that no longer match anything (remove them):'); - for (const e of stale) lines.push(` ${e.file} ${e.kind} ${e.subject}`); - lines.push(''); - } - - const accepted = baselined ? ` (${baselined} more accepted in ${BASELINE})` : ''; - lines.push( - remaining.length === 0 - ? `No findings${accepted}.` - : `${remaining.length} finding(s) in ${byFile.size} page(s)${accepted}.` - ); - return lines.join('\n'); -} - -// GitHub Actions workflow commands, one warning per finding. -function annotations(findings) { - const escape = (s) => s.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A'); - return findings.map( - (f) => `::warning file=${f.file},line=${f.line},title=TypeScript sample (${f.kind})::${escape(f.message)}` - ); -} - -function optionValue(args, name) { - const i = args.indexOf(name); - if (i === -1) return null; - const value = args[i + 1]; - if (!value || value.startsWith('--')) throw new Error(`${name} needs a value.`); - args.splice(i, 2); - return value; -} - -async function main() { - const args = process.argv.slice(2); - const version = optionValue(args, '--sdk-version') ?? 'latest'; - const cacheDir = path.resolve( - optionValue(args, '--cache-dir') ?? path.join(os.tmpdir(), 'temporal-typescript-samples') - ); - const flags = new Set(args.filter((a) => a.startsWith('--'))); - const targets = args.filter((a) => !a.startsWith('--')); - const fullScan = targets.length === 0; - - for (const flag of flags) { - if (!['--json', '--github', '--update-baseline'].includes(flag)) throw new Error(`Unknown option ${flag}.`); - } - if (!fullScan && flags.has('--update-baseline')) { - throw new Error('--update-baseline needs a full scan; drop the paths.'); - } - - const missing = targets.filter((t) => !fs.existsSync(t)); - if (missing.length) throw new Error(`No such file or directory: ${missing.join(', ')}`); - const { samples, snipsync } = collectSamples(fullScan ? [DOCS_DIR] : targets); - +async function check(samples, { version, cacheDir }) { const requested = new Set(CORE_PACKAGES); for (const sample of samples) for (const name of importedPackages(sample.code)) requested.add(name); const packages = await fetchPackages(requested, version, cacheDir); @@ -891,51 +659,28 @@ async function main() { throw new Error(`Could not fetch ${absent.map((name) => `${name}@${version}`).join(', ')} from npm.`); } - const findings = [ - ...checkSamples(samples, { root: cacheDir, packages: packageMap }), - ...checkCommentLinks(samples, loadSite()), - ].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line); - - const baseline = loadBaseline(); - - if (flags.has('--update-baseline')) { - const updated = updatedBaseline(findings, baseline); - fs.writeFileSync(BASELINE, `${JSON.stringify(updated, null, 2)}\n`); - console.log(`Wrote ${BASELINE} with ${updated.findings.length} entries. Add a note for any entry that has none.`); - return 0; - } - - const result = applyBaseline(findings, baseline); - if (!fullScan) result.stale = []; - - if (flags.has('--json')) { - console.log( - JSON.stringify( - { - packages: packages.map(({ dependencies, ...p }) => p), - samples: samples.length, - snipsync, - ...result, - }, - null, - 2 - ) - ); - } else { - console.log(report(result, { packages, sampleCount: samples.length, snipsync, fullScan })); - } - if (flags.has('--github')) { - for (const line of annotations(result.remaining)) console.log(line); - } - - return result.remaining.length + result.stale.length; + return { + findings: [ + ...checkSamples(samples, { root: cacheDir, packages: packageMap }), + ...checkCommentLinks(samples, loadSite()), + ], + sources: describeSources(packages), + details: { packages: packages.map(({ dependencies, ...p }) => p) }, + }; } +const CHECKER = { + script: 'bin/check-typescript-samples.js', + languages: LANGUAGES, + baseline: BASELINE, + title: 'TypeScript sample', + check, +}; + module.exports = { AMBIENT, BASELINE, - extractCodeBlocks, - extractSamples, + LANGUAGES, importedPackages, createSampleProgram, checkSamples, @@ -944,17 +689,6 @@ module.exports = { anchorsOf, checkLink, loadSite, - applyBaseline, - updatedBaseline, - annotations, }; -if (require.main === module) { - main().then( - (count) => process.exit(count > 0 ? 2 : 0), - (error) => { - console.error(error.message); - process.exit(1); - } - ); -} +if (require.main === module) main(CHECKER); diff --git a/bin/check-typescript-samples.test.js b/bin/check-typescript-samples.test.js index a68006d04f..408d25920e 100644 --- a/bin/check-typescript-samples.test.js +++ b/bin/check-typescript-samples.test.js @@ -1,134 +1,17 @@ const { describe, it } = require('node:test'); const assert = require('node:assert'); -const fs = require('fs'); -const path = require('path'); const { compileRedirects } = require('./redirect-utils'); const { - BASELINE, - extractCodeBlocks, - extractSamples, importedPackages, checkSamples, commentsIn, findCommentLinks, anchorsOf, checkLink, - applyBaseline, - updatedBaseline, - annotations, } = require('./check-typescript-samples.js'); const fence = '```'; -describe('extractCodeBlocks', () => { - it('reports the line of the first line of code', () => { - const page = ['# Title', '', `${fence}ts`, 'const a = 1;', 'const b = 2;', fence].join('\n'); - const [block] = extractCodeBlocks(page); - assert.strictEqual(block.lang, 'ts'); - assert.strictEqual(block.line, 4); - assert.strictEqual(block.code, 'const a = 1;\nconst b = 2;'); - }); - - it('reads the language out of an info string with a title or highlighted lines', () => { - const page = [`${fence}typescript title="client.ts" {1,3}`, 'x', fence, `${fence}ts{2}`, 'y', fence].join('\n'); - assert.deepStrictEqual( - extractCodeBlocks(page).map((b) => b.lang), - ['typescript', 'ts'] - ); - }); - - it('removes the indentation of a fence inside a list item or component', () => { - const page = ['- Step one:', '', ` ${fence}ts`, ' if (a) {', ' b();', ' }', ` ${fence}`].join('\n'); - assert.strictEqual(extractCodeBlocks(page)[0].code, 'if (a) {\n b();\n}'); - }); - - it('keeps a shorter fence inside a longer one as code', () => { - const page = ['````md', `${fence}ts`, 'nested();', fence, '````'].join('\n'); - const blocks = extractCodeBlocks(page); - assert.strictEqual(blocks.length, 1); - assert.strictEqual(blocks[0].lang, 'md'); - }); - - it('accepts tilde fences', () => { - const page = ['~~~js', 'run();', '~~~'].join('\n'); - assert.strictEqual(extractCodeBlocks(page)[0].code, 'run();'); - }); - - it('marks blocks inside either form of Snipsync wrapper', () => { - const page = [ - '', - `${fence}ts`, - 'synced();', - fence, - '', - `${fence}ts`, - 'handWritten();', - fence, - '{/* SNIPSTART typescript-env-config {"highlightedLines": "1-2"} */}', - `${fence}ts`, - 'alsoSynced();', - fence, - '{/* SNIPEND */}', - ].join('\n'); - assert.deepStrictEqual( - extractCodeBlocks(page).map((b) => [b.code, b.snipsync]), - [ - ['synced();', true], - ['handWritten();', false], - ['alsoSynced();', true], - ] - ); - }); - - it('skips code inside a comment, which is never rendered', () => { - const page = [ - '', - '{/*', - `${fence}ts`, - 'hiddenMdx();', - fence, - '*/}', - '', - `${fence}ts`, - 'shown();', - fence, - ].join('\n'); - assert.deepStrictEqual( - extractCodeBlocks(page).map((b) => b.code), - ['shown();'] - ); - }); -}); - -describe('extractSamples', () => { - it('keeps hand-written TypeScript and JavaScript only', () => { - const page = [ - `${fence}ts`, - 'a();', - fence, - `${fence}javascript`, - 'b();', - fence, - `${fence}python`, - 'c()', - fence, - '', - `${fence}typescript`, - 'd();', - fence, - '', - ].join('\n'); - assert.deepStrictEqual( - extractSamples(page).map((s) => s.code), - ['a();', 'b();'] - ); - }); -}); - describe('importedPackages', () => { it('names the package behind an import, a subpath import, and a require', () => { const code = [ @@ -488,89 +371,3 @@ describe('checkLink', () => { assert.strictEqual(checkLink('/elsewhere#x', site), null); }); }); - -describe('applyBaseline', () => { - const finding = (line, subject) => ({ file: 'docs/a.mdx', line, kind: 'missing-member', subject, message: '' }); - const baseline = { - comment: '', - findings: [ - { file: 'docs/a.mdx', kind: 'missing-member', subject: 'Client.getHandle', note: 'Deliberate.' }, - { file: 'docs/a.mdx', kind: 'missing-member', subject: 'Worker.gone', note: '' }, - ], - }; - - it('suppresses every occurrence of an accepted subject, whatever its line', () => { - const result = applyBaseline( - [finding(10, 'Client.getHandle'), finding(90, 'Client.getHandle'), finding(12, 'Connection.create')], - baseline - ); - assert.deepStrictEqual( - result.remaining.map((f) => f.subject), - ['Connection.create'] - ); - assert.strictEqual(result.baselined, 2); - }); - - it('reports an entry that no longer matches anything', () => { - const result = applyBaseline([finding(10, 'Client.getHandle')], baseline); - assert.deepStrictEqual( - result.stale.map((e) => e.subject), - ['Worker.gone'] - ); - }); - - it('does not let an entry for one page suppress the same subject on another', () => { - const other = { ...finding(10, 'Client.getHandle'), file: 'docs/b.mdx' }; - assert.strictEqual(applyBaseline([other], baseline).remaining.length, 1); - }); -}); - -describe('updatedBaseline', () => { - it('records each subject once, sorted, and keeps existing notes', () => { - const findings = [ - { file: 'docs/b.mdx', line: 3, kind: 'missing-member', subject: 'Client.getHandle' }, - { file: 'docs/a.mdx', line: 9, kind: 'comment-link', subject: '/x#y' }, - { file: 'docs/b.mdx', line: 7, kind: 'missing-member', subject: 'Client.getHandle' }, - ]; - const previous = { - comment: 'Kept.', - findings: [{ file: 'docs/b.mdx', kind: 'missing-member', subject: 'Client.getHandle', note: 'Reviewed.' }], - }; - assert.deepStrictEqual(updatedBaseline(findings, previous), { - comment: 'Kept.', - findings: [ - { file: 'docs/a.mdx', kind: 'comment-link', subject: '/x#y', note: '' }, - { file: 'docs/b.mdx', kind: 'missing-member', subject: 'Client.getHandle', note: 'Reviewed.' }, - ], - }); - }); -}); - -describe('annotations', () => { - it('writes one warning per finding, escaping what workflow commands require', () => { - assert.deepStrictEqual( - annotations([{ file: 'docs/a.mdx', line: 4, kind: 'missing-member', message: '100% wrong\nreally' }]), - ['::warning file=docs/a.mdx,line=4,title=TypeScript sample (missing-member)::100%25 wrong%0Areally'] - ); - }); -}); - -describe('the checked-in baseline', () => { - const baseline = JSON.parse(fs.readFileSync(path.join(__dirname, '..', BASELINE), 'utf8')); - - it('stays sorted and free of duplicates, the way --update-baseline writes it', () => { - assert.deepStrictEqual(baseline, updatedBaseline(baseline.findings, baseline)); - }); - - it('explains every accepted finding', () => { - for (const entry of baseline.findings) { - assert.ok(entry.note, `${entry.file} ${entry.subject} has no note`); - } - }); - - it('points at pages that exist', () => { - for (const entry of baseline.findings) { - assert.ok(fs.existsSync(path.join(__dirname, '..', entry.file)), `${entry.file} does not exist`); - } - }); -}); diff --git a/bin/code-samples.js b/bin/code-samples.js new file mode 100644 index 0000000000..fcfb3be2c0 --- /dev/null +++ b/bin/code-samples.js @@ -0,0 +1,337 @@ +// Shared by the checkers that compile hand-written code samples in docs/ +// against a published SDK: bin/check-typescript-samples.js, +// bin/check-python-samples.js, and any added later. +// +// Each checker supplies the part that depends on its language: fetching the +// SDK, compiling the samples, and deciding which compiler diagnostics are about +// the SDK. This module does the rest: finding the samples, applying the +// baseline of accepted findings, reporting, and the command line. +// +// A checker's command line: +// +// node bin/check--samples.js # every page +// node bin/check--samples.js docs/develop/... # only these pages +// node bin/check--samples.js --json # machine-readable +// node bin/check--samples.js --github # also print Actions annotations +// node bin/check--samples.js --update-baseline # accept current findings +// node bin/check--samples.js --sdk-version X # instead of the latest release +// node bin/check--samples.js --cache-dir DIR # where the SDK is downloaded +// +// Exit codes: 0 clean, 2 findings (or stale baseline entries), 1 the check +// could not run at all, for example because the package registry was +// unreachable. + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const DOCS_DIR = 'docs'; + +// --------------------------------------------------------------------------- +// Extraction +// --------------------------------------------------------------------------- + +// Snipsync wraps synced code in either an HTML comment or an MDX comment. +const SNIPSTART = /^\s*(?:/g, '').replace(/\{\/\*.*?\*\/\}/g, ''); + if (rest.includes(''; + if (rest.includes('{/*')) return '*/}'; + return null; +} + +function dedent(line, indent) { + let i = 0; + while (i < indent && (line[i] === ' ' || line[i] === '\t')) i++; + return line.slice(i); +} + +// Every fenced code block on a page, with the 1-based line of its first line +// of code and whether Snipsync manages it. Code inside a list item or a JSX +// component is indented to match its fence; that indentation is removed. +function extractCodeBlocks(source) { + const lines = source.split('\n'); + const blocks = []; + let fence = null; + let snipsync = false; + let comment = null; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (fence) { + const close = line.match(FENCE_CLOSE); + if (close && close[1][0] === fence.marker[0] && close[1].length >= fence.marker.length) { + blocks.push({ + lang: fence.lang, + line: fence.line, + code: fence.body.join('\n'), + snipsync: fence.snipsync, + }); + fence = null; + } else { + fence.body.push(dedent(line, fence.indent)); + } + continue; + } + + if (comment) { + if (line.includes(comment)) comment = null; + continue; + } + + if (SNIPSTART.test(line)) { + snipsync = true; + continue; + } + if (SNIPEND.test(line)) { + snipsync = false; + continue; + } + + const open = line.match(FENCE_OPEN); + if (open) { + fence = { + marker: open[2], + indent: open[1].length, + lang: open[3].toLowerCase(), + line: i + 2, + body: [], + snipsync, + }; + continue; + } + + comment = openComment(line); + } + + return blocks; +} + +// The hand-written samples on a page in any of `languages`. +function extractSamples(source, languages) { + return extractCodeBlocks(source).filter((b) => languages.has(b.lang) && !b.snipsync); +} + +function walkMdx(target) { + const stat = fs.statSync(target); + if (stat.isFile()) return target.endsWith('.mdx') ? [target] : []; + return fs + .readdirSync(target, { withFileTypes: true }) + .sort((a, b) => a.name.localeCompare(b.name)) + .flatMap((entry) => walkMdx(path.join(target, entry.name))); +} + +function collectSamples(targets, languages) { + const samples = []; + let snipsync = 0; + for (const file of [...new Set(targets.flatMap(walkMdx))]) { + for (const block of extractCodeBlocks(fs.readFileSync(file, 'utf8'))) { + if (!languages.has(block.lang)) continue; + if (block.snipsync) { + snipsync++; + continue; + } + samples.push({ file: file.split(path.sep).join('/'), line: block.line, code: block.code }); + } + } + return { samples, snipsync }; +} + +// --------------------------------------------------------------------------- +// Baseline +// --------------------------------------------------------------------------- + +function defaultComment(script) { + return ( + `Findings from ${script} that we have decided to leave as they are, for example a sample that ` + + 'deliberately shows an older API. Each entry is a page, a kind, and a subject; line numbers are left out so ' + + 'an entry survives edits elsewhere on the page. An empty note means the entry has not been reviewed yet; ' + + 'either fix the sample or fill in the note explaining why it stays. ' + + `Regenerate with: node ${script} --update-baseline` + ); +} + +const keyOf = (f) => `${f.file}\u0000${f.kind}\u0000${f.subject}`; + +// Accepted findings are recorded without line numbers, so one entry covers +// every occurrence of the same subject on a page. +function applyBaseline(findings, baseline) { + const known = new Set(baseline.findings.map(keyOf)); + const found = new Set(findings.map(keyOf)); + return { + remaining: findings.filter((f) => !known.has(keyOf(f))), + baselined: findings.filter((f) => known.has(keyOf(f))).length, + stale: baseline.findings.filter((e) => !found.has(keyOf(e))), + }; +} + +function updatedBaseline(findings, baseline, comment = '') { + const notes = new Map(baseline.findings.map((e) => [keyOf(e), e.note])); + const entries = new Map(); + for (const f of findings) { + const key = keyOf(f); + if (!entries.has(key)) { + entries.set(key, { file: f.file, kind: f.kind, subject: f.subject, note: notes.get(key) ?? '' }); + } + } + return { + comment: baseline.comment || comment, + findings: [...entries.values()].sort((a, b) => keyOf(a).localeCompare(keyOf(b))), + }; +} + +function loadBaseline(file, comment) { + if (!fs.existsSync(file)) return { comment, findings: [] }; + return JSON.parse(fs.readFileSync(file, 'utf8')); +} + +// --------------------------------------------------------------------------- +// Reporting +// --------------------------------------------------------------------------- + +function report({ remaining, baselined, stale }, { sources, sampleCount, snipsync, fullScan, baselineFile }) { + const lines = [...sources]; + lines.push(`${sampleCount} hand-written samples checked; ${snipsync} Snipsync samples skipped.`, ''); + + const byFile = new Map(); + for (const f of remaining) { + if (!byFile.has(f.file)) byFile.set(f.file, []); + byFile.get(f.file).push(f); + } + for (const [file, list] of byFile) { + lines.push(file); + for (const f of list) lines.push(` ${String(f.line).padStart(5)} ${f.kind.padEnd(14)} ${f.message}`); + lines.push(''); + } + + if (fullScan && stale.length) { + lines.push('Baseline entries that no longer match anything (remove them):'); + for (const e of stale) lines.push(` ${e.file} ${e.kind} ${e.subject}`); + lines.push(''); + } + + const accepted = baselined ? ` (${baselined} more accepted in ${baselineFile})` : ''; + lines.push( + remaining.length === 0 + ? `No findings${accepted}.` + : `${remaining.length} finding(s) in ${byFile.size} page(s)${accepted}.` + ); + return lines.join('\n'); +} + +// GitHub Actions workflow commands, one warning per finding. +function annotations(findings, title) { + const escape = (s) => s.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A'); + return findings.map( + (f) => `::warning file=${f.file},line=${f.line},title=${title} (${f.kind})::${escape(f.message)}` + ); +} + +// --------------------------------------------------------------------------- +// Command line +// --------------------------------------------------------------------------- + +function optionValue(args, name) { + const i = args.indexOf(name); + if (i === -1) return null; + const value = args[i + 1]; + if (!value || value.startsWith('--')) throw new Error(`${name} needs a value.`); + args.splice(i, 2); + return value; +} + +// Runs a checker from the command line. `checker` provides: +// +// script the checker's path, for messages +// languages the code fence languages it checks +// baseline the path of its baseline file +// title the annotation title, such as "TypeScript sample" +// check async (samples, { version, cacheDir }) => { findings, sources, details } +// +// `sources` is a list of lines naming what the samples were checked against, +// and `details` is merged into the --json output. `check` throws when it +// can't run, which exits 1. +async function run(checker) { + const args = process.argv.slice(2); + const version = optionValue(args, '--sdk-version') ?? 'latest'; + const cacheDir = path.resolve( + optionValue(args, '--cache-dir') ?? path.join(os.tmpdir(), path.basename(checker.script, '.js')) + ); + const flags = new Set(args.filter((a) => a.startsWith('--'))); + const targets = args.filter((a) => !a.startsWith('--')); + const fullScan = targets.length === 0; + + for (const flag of flags) { + if (!['--json', '--github', '--update-baseline'].includes(flag)) throw new Error(`Unknown option ${flag}.`); + } + if (!fullScan && flags.has('--update-baseline')) { + throw new Error('--update-baseline needs a full scan; drop the paths.'); + } + + const missing = targets.filter((t) => !fs.existsSync(t)); + if (missing.length) throw new Error(`No such file or directory: ${missing.join(', ')}`); + const { samples, snipsync } = collectSamples(fullScan ? [DOCS_DIR] : targets, checker.languages); + + const { findings, sources, details = {} } = await checker.check(samples, { version, cacheDir }); + findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line); + + const comment = defaultComment(checker.script); + const baseline = loadBaseline(checker.baseline, comment); + + if (flags.has('--update-baseline')) { + const updated = updatedBaseline(findings, baseline, comment); + fs.writeFileSync(checker.baseline, `${JSON.stringify(updated, null, 2)}\n`); + console.log( + `Wrote ${checker.baseline} with ${updated.findings.length} entries. Add a note for any entry that has none.` + ); + return 0; + } + + const result = applyBaseline(findings, baseline); + if (!fullScan) result.stale = []; + + if (flags.has('--json')) { + console.log(JSON.stringify({ ...details, samples: samples.length, snipsync, ...result }, null, 2)); + } else { + console.log( + report(result, { sources, sampleCount: samples.length, snipsync, fullScan, baselineFile: checker.baseline }) + ); + } + if (flags.has('--github')) { + for (const line of annotations(result.remaining, checker.title)) console.log(line); + } + + return result.remaining.length + result.stale.length; +} + +// Runs a checker as the main module and exits with its status. +function main(checker) { + run(checker).then( + (count) => process.exit(count > 0 ? 2 : 0), + (error) => { + console.error(error.message); + process.exit(1); + } + ); +} + +module.exports = { + DOCS_DIR, + extractCodeBlocks, + extractSamples, + collectSamples, + defaultComment, + applyBaseline, + updatedBaseline, + report, + annotations, + main, +}; diff --git a/bin/code-samples.test.js b/bin/code-samples.test.js new file mode 100644 index 0000000000..5de1252f32 --- /dev/null +++ b/bin/code-samples.test.js @@ -0,0 +1,213 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const { extractCodeBlocks, extractSamples, applyBaseline, updatedBaseline, annotations } = require('./code-samples.js'); + +const fence = '```'; + +describe('extractCodeBlocks', () => { + it('reports the line of the first line of code', () => { + const page = ['# Title', '', `${fence}ts`, 'const a = 1;', 'const b = 2;', fence].join('\n'); + const [block] = extractCodeBlocks(page); + assert.strictEqual(block.lang, 'ts'); + assert.strictEqual(block.line, 4); + assert.strictEqual(block.code, 'const a = 1;\nconst b = 2;'); + }); + + it('reads the language out of an info string with a title or highlighted lines', () => { + const page = [`${fence}typescript title="client.ts" {1,3}`, 'x', fence, `${fence}ts{2}`, 'y', fence].join('\n'); + assert.deepStrictEqual( + extractCodeBlocks(page).map((b) => b.lang), + ['typescript', 'ts'] + ); + }); + + it('removes the indentation of a fence inside a list item or component', () => { + const page = ['- Step one:', '', ` ${fence}ts`, ' if (a) {', ' b();', ' }', ` ${fence}`].join('\n'); + assert.strictEqual(extractCodeBlocks(page)[0].code, 'if (a) {\n b();\n}'); + }); + + it('keeps a shorter fence inside a longer one as code', () => { + const page = ['````md', `${fence}ts`, 'nested();', fence, '````'].join('\n'); + const blocks = extractCodeBlocks(page); + assert.strictEqual(blocks.length, 1); + assert.strictEqual(blocks[0].lang, 'md'); + }); + + it('accepts tilde fences', () => { + const page = ['~~~js', 'run();', '~~~'].join('\n'); + assert.strictEqual(extractCodeBlocks(page)[0].code, 'run();'); + }); + + it('marks blocks inside either form of Snipsync wrapper', () => { + const page = [ + '', + `${fence}ts`, + 'synced();', + fence, + '', + `${fence}ts`, + 'handWritten();', + fence, + '{/* SNIPSTART typescript-env-config {"highlightedLines": "1-2"} */}', + `${fence}ts`, + 'alsoSynced();', + fence, + '{/* SNIPEND */}', + ].join('\n'); + assert.deepStrictEqual( + extractCodeBlocks(page).map((b) => [b.code, b.snipsync]), + [ + ['synced();', true], + ['handWritten();', false], + ['alsoSynced();', true], + ] + ); + }); + + it('skips code inside a comment, which is never rendered', () => { + const page = [ + '', + '{/*', + `${fence}ts`, + 'hiddenMdx();', + fence, + '*/}', + '', + `${fence}ts`, + 'shown();', + fence, + ].join('\n'); + assert.deepStrictEqual( + extractCodeBlocks(page).map((b) => b.code), + ['shown();'] + ); + }); +}); + +describe('extractSamples', () => { + it('keeps hand-written TypeScript and JavaScript only', () => { + const page = [ + `${fence}ts`, + 'a();', + fence, + `${fence}javascript`, + 'b();', + fence, + `${fence}python`, + 'c()', + fence, + '', + `${fence}typescript`, + 'd();', + fence, + '', + ].join('\n'); + assert.deepStrictEqual( + extractSamples(page, new Set(['ts', 'javascript'])).map((s) => s.code), + ['a();', 'b();'] + ); + }); +}); + +describe('applyBaseline', () => { + const finding = (line, subject) => ({ file: 'docs/a.mdx', line, kind: 'missing-member', subject, message: '' }); + const baseline = { + comment: '', + findings: [ + { file: 'docs/a.mdx', kind: 'missing-member', subject: 'Client.getHandle', note: 'Deliberate.' }, + { file: 'docs/a.mdx', kind: 'missing-member', subject: 'Worker.gone', note: '' }, + ], + }; + + it('suppresses every occurrence of an accepted subject, whatever its line', () => { + const result = applyBaseline( + [finding(10, 'Client.getHandle'), finding(90, 'Client.getHandle'), finding(12, 'Connection.create')], + baseline + ); + assert.deepStrictEqual( + result.remaining.map((f) => f.subject), + ['Connection.create'] + ); + assert.strictEqual(result.baselined, 2); + }); + + it('reports an entry that no longer matches anything', () => { + const result = applyBaseline([finding(10, 'Client.getHandle')], baseline); + assert.deepStrictEqual( + result.stale.map((e) => e.subject), + ['Worker.gone'] + ); + }); + + it('does not let an entry for one page suppress the same subject on another', () => { + const other = { ...finding(10, 'Client.getHandle'), file: 'docs/b.mdx' }; + assert.strictEqual(applyBaseline([other], baseline).remaining.length, 1); + }); +}); + +describe('updatedBaseline', () => { + it('records each subject once, sorted, and keeps existing notes', () => { + const findings = [ + { file: 'docs/b.mdx', line: 3, kind: 'missing-member', subject: 'Client.getHandle' }, + { file: 'docs/a.mdx', line: 9, kind: 'comment-link', subject: '/x#y' }, + { file: 'docs/b.mdx', line: 7, kind: 'missing-member', subject: 'Client.getHandle' }, + ]; + const previous = { + comment: 'Kept.', + findings: [{ file: 'docs/b.mdx', kind: 'missing-member', subject: 'Client.getHandle', note: 'Reviewed.' }], + }; + assert.deepStrictEqual(updatedBaseline(findings, previous), { + comment: 'Kept.', + findings: [ + { file: 'docs/a.mdx', kind: 'comment-link', subject: '/x#y', note: '' }, + { file: 'docs/b.mdx', kind: 'missing-member', subject: 'Client.getHandle', note: 'Reviewed.' }, + ], + }); + }); +}); + +describe('annotations', () => { + it('writes one warning per finding, escaping what workflow commands require', () => { + assert.deepStrictEqual( + annotations( + [{ file: 'docs/a.mdx', line: 4, kind: 'missing-member', message: '100% wrong\nreally' }], + 'TypeScript sample' + ), + ['::warning file=docs/a.mdx,line=4,title=TypeScript sample (missing-member)::100%25 wrong%0Areally'] + ); + }); +}); + +describe('the checked-in baselines', () => { + const files = fs.readdirSync(__dirname).filter((f) => f.endsWith('-samples-baseline.json')); + + it('exist', () => { + assert.ok(files.length > 0); + }); + + for (const file of files) { + const baseline = JSON.parse(fs.readFileSync(path.join(__dirname, file), 'utf8')); + + it(`${file} stays sorted and free of duplicates, the way --update-baseline writes it`, () => { + assert.deepStrictEqual(baseline, updatedBaseline(baseline.findings, baseline)); + }); + + it(`${file} explains every accepted finding`, () => { + for (const entry of baseline.findings) { + assert.ok(entry.note, `${entry.file} ${entry.subject} has no note`); + } + }); + + it(`${file} points at pages that exist`, () => { + for (const entry of baseline.findings) { + assert.ok(fs.existsSync(path.join(__dirname, '..', entry.file)), `${entry.file} does not exist`); + } + }); + } +}); diff --git a/bin/python-samples-baseline.json b/bin/python-samples-baseline.json new file mode 100644 index 0000000000..04204c5b47 --- /dev/null +++ b/bin/python-samples-baseline.json @@ -0,0 +1,4 @@ +{ + "comment": "Findings from bin/check-python-samples.js that we have decided to leave as they are, for example a sample that deliberately shows an older API. Each entry is a page, a kind, and a subject; line numbers are left out so an entry survives edits elsewhere on the page. An empty note means the entry has not been reviewed yet; either fix the sample or fill in the note explaining why it stays. Regenerate with: node bin/check-python-samples.js --update-baseline", + "findings": [] +} diff --git a/package.json b/package.json index a33cf83c80..e9e5992af8 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "check:metrics:sdks": "node ./bin/check-metrics-against-sdks.js", "check:orphans": "node ./bin/check-orphan-pages.js", "check:redirects": "node ./bin/validate-redirects.js", + "check:py-samples": "node ./bin/check-python-samples.js", "check:ts-samples": "node ./bin/check-typescript-samples.js", "report:snipsync-coverage": "node ./bin/report-snipsync-coverage.js", "test": "node --test", @@ -108,6 +109,7 @@ "global-jsdom": "^30.0.0", "husky": "^9.1.7", "hyperlink": "^5.0.4", + "pyright": "^1.1.414", "typescript": "^6.0.3" } } diff --git a/readme/AUTOMATIONS.md b/readme/AUTOMATIONS.md index b10e4e0168..90196c9aeb 100644 --- a/readme/AUTOMATIONS.md +++ b/readme/AUTOMATIONS.md @@ -35,7 +35,8 @@ a 404 rather than a 403, so a scope mistake looks like a missing file. | Vale CI | Changes under `docs/` | No | `vale-ci.yml`, `.vale-ci.ini` | Runs only the rules in `.vale-ci.ini`, with `fail_on_error: false` and results limited to changed context. Treat those rules as the bar, not the full style set. See [AGENTS.md](../AGENTS.md#commands). | | Check Orphan Pages | Changes to docs or `sidebars.js` | No | `check-orphan-pages.yml`, `bin/check-orphan-pages.js` | Never fails; comments and annotates only. Accepted exceptions belong in `bin/orphan-pages-baseline.json` with a note. See [UTILITIES.md](./UTILITIES.md#check-orphan-pages). | | Check Metrics Against SDKs | Changes to the metrics page or its checker | No | `check-metrics-against-sdks.yml` | Drift reports but does not fail. On its scheduled runs it manages a tracking issue instead. | -| Check TypeScript Samples | Changes under `docs/develop/typescript/` | No | `check-typescript-samples.yml`, `bin/check-typescript-samples.js` | Type-checks hand-written TypeScript and JavaScript samples against the latest `@temporalio` packages, and checks docs links in code comments. Annotates only the pages the pull request changes. Findings never fail the job. | +| Check TypeScript Samples | Changes under `docs/develop/typescript/` | No | `check-typescript-samples.yml`, `bin/check-typescript-samples.js` | Type-checks hand-written TypeScript and JavaScript samples against the latest `@temporalio` packages, and checks docs links in code comments. Annotates only the pages the pull request changes. Findings never fail the job. Shares its steps with Check Python Samples through `check-code-samples.yml`. | +| Check Python Samples | Changes under `docs/develop/python/` | No | `check-python-samples.yml`, `bin/check-python-samples.js` | Type-checks hand-written Python samples with Pyright against the latest `temporalio` release. Annotates only the pages the pull request changes. Findings never fail the job. | | Snipsync Coverage | Changes under `docs/`, and pushes to `main` | No | `snipsync-coverage.yml`, `bin/report-snipsync-coverage.js` | Reports the share of SDK code lines that come from Snipsync. Comments on a pull request only when it changes that count, and writes the numbers to the job summary on `main`. Rebuild the trend with `--history`. See [UTILITIES.md](./UTILITIES.md#snipsync-coverage). | | Docs Preview Links | Every pull request | No | `docs-preview-links.yml`, `bin/generate-docs-preview-list.js` | Reads the preview URL out of Vercel's own comment, then upserts a single comment listing the pages the pull request changes. | | Visual Comparison | Pull requests labeled `visual-comparison` | No | `visual-comparison.yml` | Compares against baselines captured weekly by Screenshot Capture, and publishes its HTML report to a throwaway Vercel deployment linked from a pull request comment. Takes about 15 minutes. See [UTILITIES.md](./UTILITIES.md#visual-comparison). | @@ -53,6 +54,7 @@ the `Vercel` check, so every job in this table can be merged past. | Update Custom Role Permissions | Mondays, 09:00 UTC | Regenerates the Cloud permissions table. | | Check Metrics Against SDKs | Mondays, 10:00 UTC | Compares the metrics reference with the SDK default branches. Advisory by design: the SDK default branches run ahead of released versions, so instead of failing it opens a tracking issue, updates that issue while the drift lasts, and closes it once the page and the sources agree. Record a deliberately undocumented metric in `bin/metrics-baseline.json`. | | Check TypeScript Samples | Mondays, 11:00 UTC | Type-checks every hand-written TypeScript and JavaScript sample in `docs/` (not Snipsync samples, which compile in their source repositories) against the latest `@temporalio` packages from npm, and reports SDK methods, properties, and exports that don't exist. Also reports docs links in code comments that don't resolve, including through a catch-all redirect that drops the anchor. Advisory: manages a tracking issue the same way Check Metrics Against SDKs does. Record a deliberate exception in `bin/typescript-samples-baseline.json`. | +| Check Python Samples | Mondays, 11:30 UTC | Type-checks every hand-written Python sample in `docs/` with Pyright against the latest `temporalio` release from PyPI (no Python interpreter needed), and reports SDK classes, functions, and modules that don't exist, keywords the SDK doesn't take, and too many positional arguments. Missing arguments aren't reported, because samples leave them out on purpose. Advisory, with a tracking issue like Check TypeScript Samples. Both run through the reusable `check-code-samples.yml`, with the shared logic in `bin/code-samples.js`. Record a deliberate exception in `bin/python-samples-baseline.json`. | | Environment config drift | Mondays, 15:00 UTC | Compares the environment variable table with five SDK and CLI repositories. | | Screenshot Capture | Sundays, 00:00 UTC | Captures Playwright baselines for Visual Comparison, sharded four ways, retained 14 days. | | Warm Build Cache | Every push to `main` | Rebuilds so dependency and build caches stay warm, so a new pull request's first Docs Build Check isn't a cold install. GitHub falls back from the current ref to the base branch to the default branch when restoring a cache, so pull request runs restore what merges to `main` saved. Docs Build Check restores these caches but never saves them, because the OG image and rspack caches are large and keyed per run. | diff --git a/yarn.lock b/yarn.lock index 7b2a7cdbfb..69c79258b4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7599,7 +7599,7 @@ fs.realpath@^1.0.0: resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== -fsevents@~2.3.2: +fsevents@~2.3.2, fsevents@~2.3.3: version "2.3.3" resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz" integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== @@ -11895,6 +11895,13 @@ pvutils@^1.1.3: resolved "https://registry.yarnpkg.com/pvutils/-/pvutils-1.1.5.tgz#84b0dea4a5d670249aa9800511804ee0b7c2809c" integrity sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA== +pyright@^1.1.414: + version "1.1.414" + resolved "https://registry.yarnpkg.com/pyright/-/pyright-1.1.414.tgz#9ed805acc476faa51ae74df88fae8be1f68f420e" + integrity sha512-FPZZb51jepDX4eP7TEYDeNFtmE3WgwkkEcJpvH3/QmUSsj0EAy3LXu+xB4T/FWDejtsXlCfFr/rbWFjwuwuXww== + optionalDependencies: + fsevents "~2.3.3" + qs@6.14.1, qs@^6.5.1, qs@^6.5.2, qs@~6.14.0, qs@~6.15.1, qs@~6.5.2: version "6.14.1" resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.1.tgz#a41d85b9d3902f31d27861790506294881871159"