From 79b47cf4b0a126bd49933c2d2ac2dbf9498a3305 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Wed, 6 May 2026 12:30:08 -0700 Subject: [PATCH 1/4] feat(sdk-generate): pass node API surface to oagen generate Extract the current API surface from a clean HEAD worktree and pass it via --api-surface so generation stays aware of the existing public surface. Recover tracked manifest files missing from the working tree before extraction so a partially-deleted SDK still produces a faithful surface. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/sdk-generate.sh | 70 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/scripts/sdk-generate.sh b/scripts/sdk-generate.sh index 5597bb9..e9ba412 100755 --- a/scripts/sdk-generate.sh +++ b/scripts/sdk-generate.sh @@ -5,6 +5,9 @@ SPEC="spec/open-api-spec.yaml" LANG="" OUTPUT="" NAMESPACE="" +EXTRA_ARGS=() +TMP_SURFACE="" +TMP_SDK="" while [[ $# -gt 0 ]]; do case "$1" in @@ -34,4 +37,69 @@ if [[ -z "$NAMESPACE" ]]; then fi fi -exec npx oagen generate --lang "$LANG" --spec "$SPEC" --namespace "$NAMESPACE" --output "$OUTPUT" +cleanup() { + if [[ -n "$TMP_SDK" ]]; then + if git -C "$OUTPUT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + git -C "$OUTPUT" worktree remove --force "$TMP_SDK" >/dev/null 2>&1 || true + fi + rm -rf "$TMP_SDK" + fi + if [[ -n "$TMP_SURFACE" && -f "$TMP_SURFACE" ]]; then + rm -f "$TMP_SURFACE" + fi +} +trap cleanup EXIT + +if [[ "$LANG" == "node" && -f "$OUTPUT/package.json" && -d "$OUTPUT/src" ]]; then + TMP_SURFACE="$(mktemp "${TMPDIR:-/tmp}/oagen-node-surface.XXXXXX")" + EXTRACT_SDK="$OUTPUT" + if git -C "$OUTPUT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + TMP_SDK="$(mktemp -d "${TMPDIR:-/tmp}/oagen-node-sdk.XXXXXX")" + git -C "$OUTPUT" worktree add --detach "$TMP_SDK" HEAD >/dev/null 2>&1 + EXTRACT_SDK="$TMP_SDK" + + if [[ -f "$OUTPUT/.oagen-manifest.json" ]]; then + RECOVERY_COUNT="$(python3 - <<'PY' "$OUTPUT" "$TMP_SDK" +import json +import pathlib +import shutil +import subprocess +import sys + +output = pathlib.Path(sys.argv[1]) +head = pathlib.Path(sys.argv[2]) +manifest_path = output / '.oagen-manifest.json' +manifest = json.loads(manifest_path.read_text()).get('files', []) +tracked = set(subprocess.check_output(['git', '-C', str(output), 'ls-files', 'src'], text=True).split()) +manifest_src = [path for path in manifest if path.startswith('src/')] +has_untracked_manifest_paths = any(path not in tracked for path in manifest_src) + +if not has_untracked_manifest_paths: + print(0) + raise SystemExit(0) + +restored = 0 +for rel in manifest_src: + if rel not in tracked: + continue + src = head / rel + dst = output / rel + if not src.exists(): + continue + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(src, dst) + restored += 1 + +print(restored) +PY +)" + if [[ "$RECOVERY_COUNT" != "0" ]]; then + echo "Node SDK recovery: restored $RECOVERY_COUNT tracked manifest files from HEAD" + fi + fi + fi + npx oagen extract --sdk-path "$EXTRACT_SDK" --lang "$LANG" --output "$TMP_SURFACE" >/dev/null + EXTRA_ARGS+=(--api-surface "$TMP_SURFACE") +fi + +exec npx oagen generate --lang "$LANG" --spec "$SPEC" --namespace "$NAMESPACE" --output "$OUTPUT" "${EXTRA_ARGS[@]}" From 009132da8753241b89c9bfb243ae6c6a83cc082c Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Wed, 6 May 2026 14:34:23 -0700 Subject: [PATCH 2/4] feat(generate-prs): surface behavior changes and ground type verdicts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit workos/workos-go#545 surfaced two failure modes in the regen PR builder. (1) The spec dropped `default: desc` from 31 list endpoints' `order` query parameter, but the PR body had no entry for the silent server-default flip and release-please picked a minor bump. (2) Two `feat(…)!: Change email field type in multiple models` headlines were hallucinated by Claude Haiku from struct-tag diffs (`url:"-"` added to existing fields whose Go types did not change), then duplicated across services for the same shared field set. Both root-cause to the classify_changes step over-trusting the raw `git diff` text and ignoring the structured oagen diff. Two fixes in this workflow: Pre-render `diff-report.json` `behaviorChanges` deterministically into a single `feat!: change default for in list operations` override line and a `## BREAKING: Behavior changes` section in the PR body. Force the rollup to feat! whenever any behavior change exists so release-please bumps the major — independent of whether Claude returns soft entries. Tighten the classify_changes tool: type-change verdicts must ground in the structured diff (a Go field gaining `url:"-"` is not a type change), add a `chore` prefix for cosmetic diffs that should not bump the version, and add an explicit dedup rule for cross-service identical change-sets. --- .github/workflows/generate-prs.yml | 113 ++++++++++++++++++++++++----- 1 file changed, 95 insertions(+), 18 deletions(-) diff --git a/.github/workflows/generate-prs.yml b/.github/workflows/generate-prs.yml index 96bbd38..6706035 100644 --- a/.github/workflows/generate-prs.yml +++ b/.github/workflows/generate-prs.yml @@ -169,15 +169,61 @@ jobs: # Pass the structured oagen diff as scope-grouping signal when available SPEC_DIFF="{}" + BEHAVIOR_CHANGES="[]" if [ -f /tmp/diff-report.json ]; then SPEC_DIFF=$(cat /tmp/diff-report.json) + # Behavior changes (e.g. removed query-param defaults) are emitted as + # a separate top-level array by oagen. They are mechanically + # rendered below as feat(scope)!: change default lines so they don't + # depend on Claude inferring them from the raw SDK diff. + BEHAVIOR_CHANGES=$(echo "$SPEC_DIFF" | jq -c '.behaviorChanges // []') fi OVERRIDE_BLOCK="" DESCRIPTIONS="" + BEHAVIOR_OVERRIDE_LINES="" + BEHAVIOR_DESCRIPTIONS="" ROLLUP_PREFIX="" ROLLUP_SUMMARY="" + # Pre-render behavior changes deterministically. We group by + # serviceName so a spec-wide change (like removing the pagination + # `order` default from every list endpoint) collapses to a single + # commit-override line per service rather than 30 duplicates. + BEHAVIOR_COUNT=$(echo "$BEHAVIOR_CHANGES" | jq 'length') + if [ "${BEHAVIOR_COUNT:-0}" -gt 0 ]; then + # Collapse all behavior changes for the same param across services + # into a single commit-override line, e.g. + # feat!: change default for order in list operations (desc → ) + # so release-please bumps the major version once with a clear + # reason rather than 23 near-duplicate entries. The PR body then + # itemizes which services were touched. + BEHAVIOR_OVERRIDE_LINES=$(echo "$BEHAVIOR_CHANGES" | jq -r ' + group_by(.paramName) | + .[] | + (.[0].paramName) as $param | + (.[0].oldDefault // "") as $old | + (.[0].newDefault // "") as $new | + (length) as $count | + if $count > 1 then + "feat!: change default for \($param) in list operations (\($old) → \($new))" + else + (.[0].serviceName | gsub("(?[a-z])(?[A-Z])"; .a + "_" + .b) | ascii_downcase) as $scope | + "feat(\($scope))!: change default for \($param) (\($old) → \($new))" + end + ') + BEHAVIOR_DESCRIPTIONS=$(echo "$BEHAVIOR_CHANGES" | jq -r ' + group_by(.paramName) | + .[] | + (.[0].paramName) as $param | + (.[0].oldDefault // "") as $old | + (.[0].newDefault // "") as $new | + (length) as $count | + ([.[] | (.serviceName | gsub("(?[a-z])(?[A-Z])"; .a + "_" + .b) | ascii_downcase)] | unique) as $scopes | + "### feat!: change default for \($param) (\($old) → \($new))\n\n- Server-side default for `\($param)` changed across \($count) list operation(s) in: \($scopes | map("`" + . + "`") | join(", ")).\n- Callers who did not explicitly set `\($param)` will now receive different server behavior. Set the parameter explicitly to preserve the previous default.\n" + ') + fi + if [ "$DIFF_SIZE" -gt 0 ] && [ -n "$ANTHROPIC_API_KEY" ]; then echo "Sending ${DIFF_SIZE} bytes to Claude for classification..." RESPONSE=$(jq -n \ @@ -189,28 +235,28 @@ jobs: max_tokens: 4096, tools: [{ name: "classify_changes", - description: "Group SDK changes by service/mount point and emit one conventional-commit entry per service for the release-please changelog.", + description: "Group SDK changes by service/mount point and emit one conventional-commit entry per service for the release-please changelog. Type-change and field-removal verdicts MUST be grounded in the structured oagen spec diff — never inferred from the raw SDK diff text alone.", input_schema: { type: "object", properties: { entries: { type: "array", - description: "One entry per service that has user-facing changes. Rules: (1) Group by service (e.g. user_management, directory_sync, authorization) — never emit multiple entries for the same service. (2) Severity collapses upward within a service: any breaking change makes the whole entry feat!; otherwise any addition makes it feat; otherwise fix. (3) Omit services whose changes are purely cosmetic or internal (renames with no public-API effect, doc-only changes, enum value reordering, formatting). The omitted code still ships under the umbrella squash commit; it just does not get its own changelog entry. (4) If every service is noise-only, return a single entry with scope=\"generated\" and prefix=\"fix\".", + description: "One entry per service that has user-facing changes. Rules: (1) Group by service (e.g. user_management, directory_sync, authorization) — never emit multiple entries for the same service. (2) Severity collapses upward within a service: any breaking change makes the whole entry feat!; otherwise any addition makes it feat; otherwise fix. (3) Omit services whose changes are purely cosmetic or internal (renames with no public-API effect, doc-only changes, enum value reordering, formatting). The omitted code still ships under the umbrella squash commit; it just does not get its own changelog entry. (4) If every service is noise-only, return a single entry with scope=\"generated\" and prefix=\"fix\". (5) Behavior changes (changed/removed query-param defaults) are pre-rendered as separate entries by the workflow — do NOT duplicate them here. (6) DEDUPE: if the same set of fields is touched across multiple services in the same way (e.g. struct-tag standardization on shared models), emit ONE entry with scope=\"generated\" rather than per-service duplicates. (7) Do NOT split a single conceptual change into multiple entries that share a scope — collapse them.", items: { type: "object", properties: { prefix: { type: "string", - enum: ["feat!", "feat", "fix"], - description: "feat! for breaking changes (removed/renamed public symbols, changed signatures, removed fields). feat for additive changes (new methods, new classes, new fields). fix for non-breaking modifications visible in the public API." + enum: ["feat!", "feat", "fix", "chore"], + description: "feat! ONLY for source-level breaking changes that are present in the structured oagen spec diff: a `field-type-changed`, `field-removed`, `model-removed`, `enum-removed`, `param-removed`, `param-type-changed`, `operation-removed`, or `param-required-changed` (optional → required) event. feat for additive changes (new methods, new classes, new fields, new optional params). fix for non-breaking observable modifications. chore for changes that do not affect the public API surface — including struct-tag-only changes (adding `url:\"-\"`, toggling `omitempty`), formatting, comment edits, or reordering. CRITICAL: a Go field gaining `url:\"-\"` is NOT a field-type change — its Go type did not change. If the structured oagen diff does NOT list the field in `field-type-changed`, the headline must NOT use \"Change ... field type\" wording, and prefix MUST NOT be feat!." }, scope: { type: "string", - description: "snake_case service/mount-point name (e.g. user_management, directory_sync, authorization, fga, sso, organizations). Use \"generated\" only for the catch-all fallback entry." + description: "snake_case service/mount-point name (e.g. user_management, directory_sync, authorization, fga, sso, organizations). Use \"generated\" for cross-cutting changes that touch multiple services identically (e.g. tag standardization)." }, summary: { type: "string", - description: "Short user-facing summary under 60 chars. No trailing period. Will appear on the changelog line as: (): " + description: "Short user-facing summary under 60 chars. No trailing period. Will appear on the changelog line as: (): . Avoid the phrase \"Change ... field type\" unless the structured diff confirms a field-type-changed event." }, description: { type: "string", @@ -227,7 +273,7 @@ jobs: tool_choice: {type: "tool", name: "classify_changes"}, messages: [{ role: "user", - content: ("Classify this generated " + $lang + " SDK diff into per-service changelog entries. The structured oagen spec diff (with serviceName and classification fields) is the primary scope-grouping signal; the raw SDK diff gives wording for summaries.\n\n## oagen spec diff\n```json\n" + ($spec_diff | tojson) + "\n```\n\n## SDK diff\n" + $diff) + content: ("Classify this generated " + $lang + " SDK diff into per-service changelog entries.\n\nGrounding rules:\n- The structured oagen spec diff is authoritative for what changed at the IR level. If a field/model/operation does not appear in spec_diff.changes, then no source-level type/signature change happened — the SDK-side textual diff is cosmetic (struct tags, docstrings, formatting). Such changes are `chore`, never `feat!`.\n- Behavior changes (spec_diff.behaviorChanges) are pre-rendered as separate commit-override lines by the workflow. Do NOT include them in your entries.\n- For Go struct fields specifically: adding/changing tag content like `url:\"-\"` or `json:\"name,omitempty\"` is a tag change, not a type change. Do not headline these as \"Change … field type\".\n\n## oagen spec diff\n```json\n" + ($spec_diff | tojson) + "\n```\n\n## SDK diff\n" + $diff) }] }' | curl -s https://api.anthropic.com/v1/messages \ -H "content-type: application/json" \ @@ -247,8 +293,10 @@ jobs: if [ "$ENTRY_COUNT" -gt 0 ]; then # Render breaking changes as `feat(scope)!:` (bang after scope, per # Conventional Commits) rather than `feat!(scope):`. - OVERRIDE_LINES=$(echo "$ENTRIES" | jq -r '.[] | (.prefix | sub("!$"; "")) as $type | (if .prefix | endswith("!") then "!" else "" end) as $bang | "\($type)(\(.scope))\($bang): \(.summary)"') - DESCRIPTIONS=$(echo "$ENTRIES" | jq -r '.[] | (.prefix | sub("!$"; "")) as $type | (if .prefix | endswith("!") then "!" else "" end) as $bang | "### \($type)(\(.scope))\($bang): \(.summary)\n\n\(.description)\n"') + # Treat the `chore` prefix as a literal, since release-please does + # not bump versions for it. + CLAUDE_OVERRIDE_LINES=$(echo "$ENTRIES" | jq -r '.[] | (.prefix | sub("!$"; "")) as $type | (if .prefix | endswith("!") then "!" else "" end) as $bang | "\($type)(\(.scope))\($bang): \(.summary)"') + CLAUDE_DESCRIPTIONS=$(echo "$ENTRIES" | jq -r '.[] | (.prefix | sub("!$"; "")) as $type | (if .prefix | endswith("!") then "!" else "" end) as $bang | "### \($type)(\(.scope))\($bang): \(.summary)\n\n\(.description)\n"') if echo "$ENTRIES" | jq -e '[.[] | select(.prefix == "feat!")] | length > 0' > /dev/null; then ROLLUP_TYPE="feat" @@ -260,21 +308,50 @@ jobs: ROLLUP_TYPE="fix" ROLLUP_BANG="" fi + else + CLAUDE_OVERRIDE_LINES="" + CLAUDE_DESCRIPTIONS="" + fi + else + CLAUDE_OVERRIDE_LINES="" + CLAUDE_DESCRIPTIONS="" + echo "Skipping classification: diff is empty or no API key" + fi - if [ "$ENTRY_COUNT" -eq 1 ]; then - ROLLUP_SUMMARY="regenerate from spec (1 change)" - else - ROLLUP_SUMMARY="regenerate from spec (${ENTRY_COUNT} changes)" - fi + # ── Compose final output (behavior changes + Claude entries) ──────── + # Behavior changes (e.g. `feat(authorization)!: change default for + # order`) are always breaking, so they force the rollup to feat! and + # take precedence over any softer rollup Claude inferred. + if [ -n "$BEHAVIOR_OVERRIDE_LINES" ]; then + ROLLUP_TYPE="feat" + ROLLUP_BANG="!" + fi - OVERRIDE_BLOCK=$(printf 'BEGIN_COMMIT_OVERRIDE\n%s\nEND_COMMIT_OVERRIDE' "$OVERRIDE_LINES") + # Combine pre-rendered behavior lines with Claude's per-service + # entries. Behavior lines come first because they are the most + # consequential (forced major-version bumps). + COMBINED_OVERRIDE=$(printf '%s\n%s' "$BEHAVIOR_OVERRIDE_LINES" "$CLAUDE_OVERRIDE_LINES" | sed '/^$/d') + if [ -n "$COMBINED_OVERRIDE" ]; then + OVERRIDE_BLOCK=$(printf 'BEGIN_COMMIT_OVERRIDE\n%s\nEND_COMMIT_OVERRIDE' "$COMBINED_OVERRIDE") + TOTAL_LINES=$(printf '%s\n' "$COMBINED_OVERRIDE" | wc -l | tr -d ' ') + if [ "$TOTAL_LINES" -eq 1 ]; then + ROLLUP_SUMMARY="regenerate from spec (1 change)" + else + ROLLUP_SUMMARY="regenerate from spec (${TOTAL_LINES} changes)" fi + fi + + # Behavior changes get a dedicated section in the PR body so they + # are not buried among scope-level entries. + if [ -n "$BEHAVIOR_DESCRIPTIONS" ]; then + DESCRIPTIONS=$(printf '## BREAKING: Behavior changes\n\n%s\n## Changes\n\n%s' "$BEHAVIOR_DESCRIPTIONS" "$CLAUDE_DESCRIPTIONS") else - echo "Skipping classification: diff is empty or no API key" + DESCRIPTIONS="$CLAUDE_DESCRIPTIONS" fi - # Fallback: Claude unavailable or returned no entries — degrade to a single - # rollup line derived from the oagen diff summary counts. + # Fallback: Claude unavailable or returned no entries AND no + # behavior changes — degrade to a single rollup line derived from + # the oagen diff summary counts. if [ -z "$ROLLUP_TYPE" ]; then BREAKING='${{ steps.diff-report.outputs.breaking }}' ADDED='${{ steps.diff-report.outputs.added }}' From 35903c9b4ac3ca1f5047908e3e546347122b5d53 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Wed, 6 May 2026 14:51:04 -0700 Subject: [PATCH 3/4] logic tweak --- oagen.config.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/oagen.config.ts b/oagen.config.ts index 40f79e7..e574fc6 100644 --- a/oagen.config.ts +++ b/oagen.config.ts @@ -466,6 +466,14 @@ const config: OagenConfig = { // in `workos-python` (e.g. `from workos.user_management.models import User`) // keep resolving. User: 'UserManagement', + // `UserApiKeyOwner` is the auto-named user-variant of `ApiKey.owner`'s + // discriminated union (in the api_keys-tagged ops) AND the inline owner + // of `UserApiKey.owner` (in the user_management-prefixed ops). Without a + // hint, `assignModelsToServices` picks the first service in spec order — + // `ApiKeys` — and the file lands in `lib/workos/api_keys/`, splitting it + // from its sibling user_api_key_*_owner aliases that all live under + // user_management/. Pin it so the family stays together. + UserApiKeyOwner: 'UserManagement', }, transformSpec, }; From 4a3f954b779b1e3cbe6abfc1b1c886f23015c768 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Wed, 6 May 2026 14:53:47 -0700 Subject: [PATCH 4/4] feat(ci): Add kotlin to SDK generation workflows The matrix entry alone is not enough to land kotlin in the generation mix: the workflow_dispatch choice list, the runtime setup action, and the diff-report test classifier each branch per-language and would silently drop kotlin otherwise. - generate-prs.yml: kotlin is selectable when manually triggering a single-language run. - setup-sdk-runtime: detect the JVM version from build.gradle.kts and install temurin + the gradle wrapper cache so the kotlin emitter's post-generate `./gradlew ktlintFormat` succeeds. - build-sdk-diff-report: classify src/test/**, *Test.kt, *Tests.kt as tests so they are not miscounted as code changes. --- .github/actions/setup-sdk-runtime/action.yml | 14 +++++++++++++- .github/sdk-matrix.json | 5 +++++ .github/workflows/generate-prs.yml | 1 + scripts/build-sdk-diff-report.mjs | 1 + 4 files changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/actions/setup-sdk-runtime/action.yml b/.github/actions/setup-sdk-runtime/action.yml index 79917ec..b61148a 100644 --- a/.github/actions/setup-sdk-runtime/action.yml +++ b/.github/actions/setup-sdk-runtime/action.yml @@ -3,7 +3,7 @@ description: Install the language runtime and formatter tools needed by an SDK e inputs: language: - description: Target language (ruby, python, go, dotnet, php) + description: Target language (ruby, python, go, dotnet, php, kotlin) required: true sdk-path: description: Path to the checked-out SDK repo @@ -23,6 +23,7 @@ runs: go) echo "version=$(grep '^go ' go.mod | awk '{print $2}')" >> "$GITHUB_OUTPUT" ;; dotnet) echo "version=$(grep -Po '(?<=net)\d+\.\d+' $(find . -name '*.csproj' -path '*/src/*' | head -1))" >> "$GITHUB_OUTPUT" ;; php) echo "version=$(grep -Po '\"php\":\s*\"\^?\K[\d.]+' composer.json)" >> "$GITHUB_OUTPUT" ;; + kotlin) echo "version=$(grep -Po '(?<=JavaVersion\.VERSION_)\d+' build.gradle.kts | head -1)" >> "$GITHUB_OUTPUT" ;; esac - name: Setup Ruby @@ -64,6 +65,17 @@ runs: with: php-version: ${{ steps.sdk-version.outputs.version }} + - name: Setup Java + if: inputs.language == 'kotlin' + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: temurin + java-version: ${{ steps.sdk-version.outputs.version }} + + - name: Setup Gradle + if: inputs.language == 'kotlin' + uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v6.1.0 + - name: Install SDK formatter tools working-directory: ${{ inputs.sdk-path }} shell: bash diff --git a/.github/sdk-matrix.json b/.github/sdk-matrix.json index a1a29fc..09342fe 100644 --- a/.github/sdk-matrix.json +++ b/.github/sdk-matrix.json @@ -9,6 +9,11 @@ "sdk_repo": "workos/workos-go", "sdk_checkout_path": "backend/workos-go" }, + { + "language": "kotlin", + "sdk_repo": "workos/workos-kotlin", + "sdk_checkout_path": "backend/workos-kotlin" + }, { "language": "php", "sdk_repo": "workos/workos-php", diff --git a/.github/workflows/generate-prs.yml b/.github/workflows/generate-prs.yml index 6706035..22f2e87 100644 --- a/.github/workflows/generate-prs.yml +++ b/.github/workflows/generate-prs.yml @@ -15,6 +15,7 @@ on: - all - dotnet - go + - kotlin - php - python - ruby diff --git a/scripts/build-sdk-diff-report.mjs b/scripts/build-sdk-diff-report.mjs index ddab08c..0f247ca 100644 --- a/scripts/build-sdk-diff-report.mjs +++ b/scripts/build-sdk-diff-report.mjs @@ -10,6 +10,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const TEST_PATTERNS = { dotnet: [/(^|\/)Tests?\//, /Tests?\.cs$/i, /\.Tests?\.csproj$/i], go: [/_test\.go$/], + kotlin: [/(^|\/)src\/test\//, /Test\.kt$/, /Tests\.kt$/], php: [/(^|\/)tests?\//i], python: [/(^|\/)tests?\//, /(^|\/)test_[^/]+\.py$/, /[^/]+_test\.py$/, /(^|\/)conftest\.py$/], ruby: [/(^|\/)spec\//, /(^|\/)test\//, /_spec\.rb$/, /_test\.rb$/],