From e69d491dab7cdaa53e079d199392c9ebb4861db4 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 19 Jun 2026 06:23:02 -0300 Subject: [PATCH 1/9] feat(parsers): add Python public API surface parser Regex-based TypeScript parser that extracts the public API surface from Python SDK source files. Follows the same design as swift-parser.ts. Key behaviors: - Extracts public classes, methods, properties (@property), and top-level functions based on Python's leading-underscore privacy convention - Skips dunder methods (__init__, __repr__, etc.), private names (_*), and property setters/deleters (@name.setter) - Tracks indentation-based context to scope members to their enclosing class and prevent method-body content from leaking as false symbols - Supports src-layout (src/package/) and flat-layout (package/) - Respects sdk-parse-ignore for file/directory exclusions - Skips generated directories (__pycache__, venv, build, dist, etc.) Adds parse-python npm script and wires python as a supported language in the reusable validate-sdk-compliance CI workflow. Linear: SDK-991 --- .github/workflows/validate-sdk-compliance.yml | 7 +- scripts/capability-matrix/package-lock.json | 132 +++---- scripts/capability-matrix/package.json | 1 + scripts/capability-matrix/src/parse-python.ts | 19 + .../capability-matrix/src/python-parser.ts | 177 ++++++++++ .../fixtures/python-sample/supabase/client.py | 58 +++ .../test/python-parser.test.ts | 334 ++++++++++++++++++ 7 files changed, 663 insertions(+), 65 deletions(-) create mode 100644 scripts/capability-matrix/src/parse-python.ts create mode 100644 scripts/capability-matrix/src/python-parser.ts create mode 100644 scripts/capability-matrix/test/fixtures/python-sample/supabase/client.py create mode 100644 scripts/capability-matrix/test/python-parser.test.ts diff --git a/.github/workflows/validate-sdk-compliance.yml b/.github/workflows/validate-sdk-compliance.yml index 57577ab..19b1eaf 100644 --- a/.github/workflows/validate-sdk-compliance.yml +++ b/.github/workflows/validate-sdk-compliance.yml @@ -77,9 +77,10 @@ jobs: id: resolve run: | case "${{ inputs.language }}" in - swift) echo "cmd=parse-swift" >> "$GITHUB_OUTPUT" ;; - javascript) echo "cmd=parse-ts" >> "$GITHUB_OUTPUT" ;; - *) echo "::error::Unsupported language '${{ inputs.language }}'. Supported values: swift, javascript"; exit 1 ;; + swift) echo "cmd=parse-swift" >> "$GITHUB_OUTPUT" ;; + javascript) echo "cmd=parse-ts" >> "$GITHUB_OUTPUT" ;; + python) echo "cmd=parse-python" >> "$GITHUB_OUTPUT" ;; + *) echo "::error::Unsupported language '${{ inputs.language }}'. Supported values: swift, javascript, python"; exit 1 ;; esac - name: Parse PR branch diff --git a/scripts/capability-matrix/package-lock.json b/scripts/capability-matrix/package-lock.json index 79941e5..066e12f 100644 --- a/scripts/capability-matrix/package-lock.json +++ b/scripts/capability-matrix/package-lock.json @@ -505,14 +505,14 @@ "license": "MIT" }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.2" }, "funding": { "type": "github", @@ -1025,9 +1025,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.19.19", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", - "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "version": "22.19.21", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz", + "integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==", "dev": true, "license": "MIT", "dependencies": { @@ -1035,31 +1035,31 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", - "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.0", - "@vitest/utils": "4.1.0", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", "chai": "^6.2.2", - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", - "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.0", + "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1068,7 +1068,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -1080,26 +1080,26 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", - "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", - "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.0", + "@vitest/utils": "4.1.9", "pathe": "^2.0.3" }, "funding": { @@ -1107,14 +1107,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", - "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.0", - "@vitest/utils": "4.1.0", + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1123,9 +1123,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", - "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", "dev": true, "license": "MIT", "funding": { @@ -1133,15 +1133,15 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", - "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.0", + "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -1650,9 +1650,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.13", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.13.tgz", + "integrity": "sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==", "dev": true, "funding": [ { @@ -1669,9 +1669,9 @@ } }, "node_modules/obug": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz", - "integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -1989,19 +1989,19 @@ } }, "node_modules/vitest": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", - "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.0", - "@vitest/mocker": "4.1.0", - "@vitest/pretty-format": "4.1.0", - "@vitest/runner": "4.1.0", - "@vitest/snapshot": "4.1.0", - "@vitest/spy": "4.1.0", - "@vitest/utils": "4.1.0", + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -2012,8 +2012,8 @@ "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { @@ -2029,13 +2029,15 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.0", - "@vitest/browser-preview": "4.1.0", - "@vitest/browser-webdriverio": "4.1.0", - "@vitest/ui": "4.1.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -2056,6 +2058,12 @@ "@vitest/browser-webdriverio": { "optional": true }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, "@vitest/ui": { "optional": true }, diff --git a/scripts/capability-matrix/package.json b/scripts/capability-matrix/package.json index 8859f87..f1393c9 100644 --- a/scripts/capability-matrix/package.json +++ b/scripts/capability-matrix/package.json @@ -10,6 +10,7 @@ "validate-compliance": "tsx src/compliance-cli.ts", "parse-ts": "tsx src/parse-ts.ts", "parse-swift": "tsx src/parse-swift.ts", + "parse-python": "tsx src/parse-python.ts", "check-api-symbols": "tsx src/check-api-symbols.ts", "aggregate": "tsx src/aggregate.ts", "report": "tsx src/cli.ts report", diff --git a/scripts/capability-matrix/src/parse-python.ts b/scripts/capability-matrix/src/parse-python.ts new file mode 100644 index 0000000..0232cac --- /dev/null +++ b/scripts/capability-matrix/src/parse-python.ts @@ -0,0 +1,19 @@ +import { parsePythonProject } from "./python-parser.js"; + +async function main(): Promise { + const projectPath = process.argv[2]; + if (!projectPath) { + console.error("Usage: parse-python "); + process.exit(1); + } + + try { + const result = parsePythonProject(projectPath); + console.log(JSON.stringify(result, null, 2)); + } catch (e) { + console.error(`Error: ${(e as Error).message}`); + process.exit(1); + } +} + +main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/scripts/capability-matrix/src/python-parser.ts b/scripts/capability-matrix/src/python-parser.ts new file mode 100644 index 0000000..4dbf212 --- /dev/null +++ b/scripts/capability-matrix/src/python-parser.ts @@ -0,0 +1,177 @@ +import { readFileSync, readdirSync, existsSync } from "node:fs"; +import { join, resolve, relative } from "node:path"; +import type { ParsedSymbol, ParseResult } from "./ts-parser.js"; +import { loadIgnore, type Ignore } from "./parse-ignore.js"; + +export type { ParsedSymbol, ParseResult }; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +// Directories that are never meaningful Python source +const SKIP_DIRS = new Set([ + "__pycache__", ".git", "node_modules", + "venv", ".venv", "env", ".env", + "build", "dist", ".tox", ".pytest_cache", ".mypy_cache", +]); + +function findPythonFiles(dir: string, root: string, ig: Ignore): string[] { + const results: string[] = []; + try { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name.startsWith(".")) continue; + const full = join(dir, entry.name); + const rel = relative(root, full); + if (entry.isDirectory()) { + if (SKIP_DIRS.has(entry.name)) continue; + if (ig.ignores(rel + "/")) continue; + results.push(...findPythonFiles(full, root, ig)); + } else if (entry.isFile() && entry.name.endsWith(".py")) { + if (ig.ignores(rel)) continue; + results.push(full); + } + } + } catch { /* ignore unreadable dirs */ } + return results; +} + +// --------------------------------------------------------------------------- +// Single-file parser +// --------------------------------------------------------------------------- + +interface Context { + name: string; // possibly dotted: "SupabaseClient.Config" + classIndent: number; // indent of the `class` keyword line itself + memberIndent: number | null; // indent of direct class body members (set on first member) +} + +// Normalise leading whitespace: tabs count as 4 spaces +function getIndent(line: string): number { + let n = 0; + for (const ch of line) { + if (ch === " ") n += 1; + else if (ch === "\t") n += 4; + else break; + } + return n; +} + +// Dunder names like __init__, __repr__ are implementation details, not API surface +const DUNDER_RE = /^__\w+__$/; + +export function extractFromSource(source: string, relPath: string): ParsedSymbol[] { + const symbols: ParsedSymbol[] = []; + const contextStack: Context[] = []; + const pendingDecorators: string[] = []; + + for (const rawLine of source.split("\n")) { + // Strip inline # comments — simplified (doesn't handle # inside strings, which + // is acceptable for the SDK codebases this parser targets) + const commentIdx = rawLine.indexOf("#"); + const line = commentIdx >= 0 ? rawLine.slice(0, commentIdx) : rawLine; + const trimmed = line.trim(); + + if (!trimmed) continue; + + const indent = getIndent(rawLine); + + // Pop contexts that have ended: we're at or before the class indent level + while ( + contextStack.length > 0 && + indent <= contextStack[contextStack.length - 1].classIndent + ) { + contextStack.pop(); + } + + // Collect decorator names without arguments: "@property" → "property", + // "@name.setter" → "name.setter" + if (trimmed.startsWith("@")) { + pendingDecorators.push(trimmed.slice(1).split("(")[0].trim()); + continue; + } + + const topCtx = contextStack.length > 0 ? contextStack[contextStack.length - 1] : null; + + // Determine whether this line sits at the valid direct-member indent for the + // current class context. The member indent is discovered lazily from the first + // declaration line seen inside the class body. + let atMemberLevel: boolean; + if (topCtx === null) { + atMemberLevel = indent === 0; + } else if (topCtx.memberIndent === null) { + topCtx.memberIndent = indent; // first declaration — establish the body indent + atMemberLevel = true; + } else { + atMemberLevel = indent === topCtx.memberIndent; + } + + const decorators = [...pendingDecorators]; + pendingDecorators.length = 0; + + if (!atMemberLevel) continue; + + const currentName = topCtx ? topCtx.name : ""; + + // --- Class declaration --- + const classMatch = trimmed.match(/^class\s+(\w+)/); + if (classMatch) { + const className = classMatch[1]; + if (!className.startsWith("_")) { + const qualifiedName = currentName ? `${currentName}.${className}` : className; + symbols.push({ name: qualifiedName, kind: "class", file: relPath }); + contextStack.push({ name: qualifiedName, classIndent: indent, memberIndent: null }); + } + continue; + } + + // --- Function / method declaration --- + const funcMatch = trimmed.match(/^(?:async\s+)?def\s+(\w+)/); + if (funcMatch) { + const funcName = funcMatch[1]; + // Skip private names (leading _) and dunder methods (__x__) + if (funcName.startsWith("_") || DUNDER_RE.test(funcName)) continue; + + // Skip property setters/deleters — their decorator ends with .setter / .deleter + const isAccessor = decorators.some((d) => /\.\w+$/.test(d)); + if (isAccessor) continue; + + if (currentName) { + const isProperty = decorators.includes("property"); + symbols.push({ + name: `${currentName}.${funcName}`, + kind: isProperty ? "property" : "method", + file: relPath, + }); + } else { + symbols.push({ name: funcName, kind: "function", file: relPath }); + } + continue; + } + } + + return symbols; +} + +// --------------------------------------------------------------------------- +// Project-level entry point +// --------------------------------------------------------------------------- + +export function parsePythonProject(projectRoot: string): ParseResult { + const root = resolve(projectRoot); + const ig = loadIgnore(root); + // Support both src-layout (src/packagename/) and flat layout (packagename/) + const srcDir = join(root, "src"); + const scanRoot = existsSync(srcDir) ? srcDir : root; + + const files = findPythonFiles(scanRoot, root, ig); + const symbols: ParsedSymbol[] = []; + + for (const file of files) { + const source = readFileSync(file, "utf8"); + const relPath = relative(root, file); + symbols.push(...extractFromSource(source, relPath)); + } + + return { symbols }; +} diff --git a/scripts/capability-matrix/test/fixtures/python-sample/supabase/client.py b/scripts/capability-matrix/test/fixtures/python-sample/supabase/client.py new file mode 100644 index 0000000..0fd7901 --- /dev/null +++ b/scripts/capability-matrix/test/fixtures/python-sample/supabase/client.py @@ -0,0 +1,58 @@ +class SupabaseClient: + def __init__(self, url: str, key: str): + self._auth = None + self._storage = None + + @property + def auth(self): + return self._auth + + @auth.setter + def auth(self, value): + self._auth = value + + @property + def storage(self): + return self._storage + + async def rpc(self, fn: str, params: dict = None): + pass + + def from_(self, table: str): + pass + + @staticmethod + def create(url: str, key: str) -> "SupabaseClient": + pass + + @classmethod + def from_url(cls, url: str) -> "SupabaseClient": + pass + + def _private_helper(self): + pass + + def __repr__(self) -> str: + return f"SupabaseClient({self})" + + +class StorageClient: + async def upload(self, path: str, data: bytes) -> None: + pass + + def download(self, path: str) -> bytes: + pass + + +# Private class — must not appear in output +class _InternalCache: + def method(self): + pass + + +def create_client(url: str, key: str) -> SupabaseClient: + return SupabaseClient(url, key) + + +def _private_function(): + pass diff --git a/scripts/capability-matrix/test/python-parser.test.ts b/scripts/capability-matrix/test/python-parser.test.ts new file mode 100644 index 0000000..056c3e6 --- /dev/null +++ b/scripts/capability-matrix/test/python-parser.test.ts @@ -0,0 +1,334 @@ +import { describe, it, expect } from "vitest"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { writeFileSync, mkdirSync } from "node:fs"; +import { extractFromSource, parsePythonProject } from "../src/python-parser"; + +function names(src: string): string[] { + return extractFromSource(src, "supabase/client.py").map((s) => s.name); +} + +function kinds(src: string): Record { + return Object.fromEntries( + extractFromSource(src, "supabase/client.py").map((s) => [s.name, s.kind]), + ); +} + +// --------------------------------------------------------------------------- +// Type declarations +// --------------------------------------------------------------------------- + +describe("extractFromSource — class declarations", () => { + it("extracts a public class", () => { + expect(names("class AuthClient:\n pass\n")).toContain("AuthClient"); + }); + + it("extracts a class with a base class", () => { + expect(names("class AuthClient(BaseClient):\n pass\n")).toContain("AuthClient"); + }); + + it("does not extract a private class (leading underscore)", () => { + expect(names("class _Internal:\n pass\n")).not.toContain("_Internal"); + }); + + it("extracts an abstract base class", () => { + expect(names("class BaseAuth(ABC):\n pass\n")).toContain("BaseAuth"); + }); +}); + +// --------------------------------------------------------------------------- +// Public methods +// --------------------------------------------------------------------------- + +describe("extractFromSource — public methods", () => { + const src = ` +class AuthClient: + def sign_up(self, email: str, password: str) -> dict: + pass + + async def sign_in(self, credentials: dict) -> dict: + pass + + def sign_out(self) -> None: + pass +`; + it("extracts a regular method", () => expect(names(src)).toContain("AuthClient.sign_up")); + it("extracts an async method", () => expect(names(src)).toContain("AuthClient.sign_in")); + it("extracts multiple methods", () => expect(names(src)).toContain("AuthClient.sign_out")); + it("marks methods with kind 'method'", () => { + expect(kinds(src)["AuthClient.sign_up"]).toBe("method"); + }); +}); + +// --------------------------------------------------------------------------- +// Private / dunder exclusions +// --------------------------------------------------------------------------- + +describe("extractFromSource — private and dunder exclusions", () => { + const src = ` +class SupabaseClient: + def __init__(self, url: str, key: str): + pass + + def __repr__(self) -> str: + pass + + def _connect(self): + pass + + def public_method(self): + pass +`; + it("excludes __init__", () => expect(names(src)).not.toContain("SupabaseClient.__init__")); + it("excludes __repr__", () => expect(names(src)).not.toContain("SupabaseClient.__repr__")); + it("excludes private methods (leading _)", () => expect(names(src)).not.toContain("SupabaseClient._connect")); + it("includes the public method", () => expect(names(src)).toContain("SupabaseClient.public_method")); +}); + +// --------------------------------------------------------------------------- +// Properties +// --------------------------------------------------------------------------- + +describe("extractFromSource — properties", () => { + const src = ` +class SupabaseClient: + @property + def auth(self): + return self._auth + + @auth.setter + def auth(self, value): + self._auth = value + + @property + def storage(self): + return self._storage +`; + it("extracts @property getter", () => expect(names(src)).toContain("SupabaseClient.auth")); + it("marks @property with kind 'property'", () => { + expect(kinds(src)["SupabaseClient.auth"]).toBe("property"); + }); + it("extracts second @property", () => expect(names(src)).toContain("SupabaseClient.storage")); + it("does not emit duplicate from @auth.setter", () => { + const authEntries = names(src).filter((n) => n === "SupabaseClient.auth"); + expect(authEntries).toHaveLength(1); + }); +}); + +// --------------------------------------------------------------------------- +// Static and class methods +// --------------------------------------------------------------------------- + +describe("extractFromSource — static and class methods", () => { + const src = ` +class FunctionsClient: + @staticmethod + def create(url: str) -> "FunctionsClient": + pass + + @classmethod + def from_env(cls) -> "FunctionsClient": + pass +`; + it("extracts @staticmethod", () => expect(names(src)).toContain("FunctionsClient.create")); + it("marks @staticmethod as kind 'method'", () => { + expect(kinds(src)["FunctionsClient.create"]).toBe("method"); + }); + it("extracts @classmethod", () => expect(names(src)).toContain("FunctionsClient.from_env")); +}); + +// --------------------------------------------------------------------------- +// Top-level functions +// --------------------------------------------------------------------------- + +describe("extractFromSource — top-level functions", () => { + it("extracts a top-level function", () => { + expect(names("def create_client(url: str, key: str):\n pass\n")).toContain("create_client"); + }); + + it("marks top-level functions with kind 'function'", () => { + expect(kinds("def create_client(url: str):\n pass\n")["create_client"]).toBe("function"); + }); + + it("does not extract private top-level functions", () => { + expect(names("def _internal_helper():\n pass\n")).not.toContain("_internal_helper"); + }); + + it("extracts an async top-level function", () => { + expect(names("async def fetch(url: str):\n pass\n")).toContain("fetch"); + }); +}); + +// --------------------------------------------------------------------------- +// Nested classes +// --------------------------------------------------------------------------- + +describe("extractFromSource — nested classes", () => { + const src = ` +class SupabaseClient: + class Config: + def get(self, key: str) -> str: + pass + + def from_(self, table: str): + pass +`; + it("extracts the outer class", () => expect(names(src)).toContain("SupabaseClient")); + it("extracts the nested class with dotted name", () => expect(names(src)).toContain("SupabaseClient.Config")); + it("extracts a method of the nested class", () => expect(names(src)).toContain("SupabaseClient.Config.get")); + it("extracts a method of the outer class after the nested class", () => expect(names(src)).toContain("SupabaseClient.from_")); +}); + +// --------------------------------------------------------------------------- +// Methods inside method bodies must not leak +// --------------------------------------------------------------------------- + +describe("extractFromSource — method body isolation", () => { + const src = ` +class Foo: + def outer(self): + def inner_helper(): + pass + return inner_helper + + def real_method(self): + pass +`; + it("does not capture nested function defined inside a method body", () => { + expect(names(src)).not.toContain("Foo.inner_helper"); + }); + it("still captures the real method after the containing method", () => { + expect(names(src)).toContain("Foo.real_method"); + }); +}); + +// --------------------------------------------------------------------------- +// Comment stripping +// --------------------------------------------------------------------------- + +describe("extractFromSource — comment stripping", () => { + it("ignores class definitions in inline comments", () => { + const src = ` +class Foo: + def real(self): pass # def fake(self): pass +`; + expect(names(src)).toContain("Foo.real"); + expect(names(src)).not.toContain("Foo.fake"); + }); + + it("ignores full-line comments", () => { + const src = ` +class Foo: + # def commented_out(self): pass + def real(self): pass +`; + expect(names(src)).toContain("Foo.real"); + expect(names(src)).not.toContain("Foo.commented_out"); + }); +}); + +// --------------------------------------------------------------------------- +// Context stack correctness +// --------------------------------------------------------------------------- + +describe("extractFromSource — context stack correctness", () => { + it("does not bleed members across sibling classes", () => { + const src = ` +class Auth: + def sign_up(self): pass + +class Storage: + def upload(self): pass +`; + const n = names(src); + expect(n).toContain("Auth.sign_up"); + expect(n).toContain("Storage.upload"); + expect(n).not.toContain("Auth.upload"); + expect(n).not.toContain("Storage.sign_up"); + }); +}); + +// --------------------------------------------------------------------------- +// Integration test against fixture project +// --------------------------------------------------------------------------- + +describe("parsePythonProject — fixture project", () => { + const fixtureDir = join(__dirname, "fixtures", "python-sample"); + + it("finds and parses all public symbols", () => { + const result = parsePythonProject(fixtureDir); + const symbolNames = result.symbols.map((s) => s.name); + + // Public classes + expect(symbolNames).toContain("SupabaseClient"); + expect(symbolNames).toContain("StorageClient"); + + // Public methods / properties + expect(symbolNames).toContain("SupabaseClient.auth"); + expect(symbolNames).toContain("SupabaseClient.storage"); + expect(symbolNames).toContain("SupabaseClient.rpc"); + expect(symbolNames).toContain("SupabaseClient.from_"); + expect(symbolNames).toContain("SupabaseClient.create"); + expect(symbolNames).toContain("SupabaseClient.from_url"); + expect(symbolNames).toContain("StorageClient.upload"); + expect(symbolNames).toContain("StorageClient.download"); + + // Top-level function + expect(symbolNames).toContain("create_client"); + + // Private things must be absent + expect(symbolNames).not.toContain("_InternalCache"); + expect(symbolNames).not.toContain("_private_function"); + expect(symbolNames).not.toContain("SupabaseClient.__init__"); + expect(symbolNames).not.toContain("SupabaseClient.__repr__"); + expect(symbolNames).not.toContain("SupabaseClient._private_helper"); + + // Setter must not duplicate the property + const authEntries = symbolNames.filter((n) => n === "SupabaseClient.auth"); + expect(authEntries).toHaveLength(1); + }); + + it("marks properties correctly from fixture", () => { + const result = parsePythonProject(fixtureDir); + const prop = result.symbols.find((s) => s.name === "SupabaseClient.auth"); + expect(prop?.kind).toBe("property"); + }); +}); + +// --------------------------------------------------------------------------- +// sdk-parse-ignore support +// --------------------------------------------------------------------------- + +describe("parsePythonProject — sdk-parse-ignore", () => { + it("excludes files matched by sdk-parse-ignore", () => { + const dir = join(tmpdir(), `python-parser-ignore-test-${process.pid}`); + const pkgDir = join(dir, "supabase"); + mkdirSync(pkgDir, { recursive: true }); + writeFileSync(join(pkgDir, "client.py"), "class SupabaseClient:\n def sign_up(self): pass\n"); + writeFileSync(join(dir, "sdk-parse-ignore"), "supabase/client.py\n"); + const result = parsePythonProject(dir); + expect(result.symbols.map((s) => s.name)).not.toContain("SupabaseClient"); + }); + + it("excludes entire directories matched by sdk-parse-ignore", () => { + const dir = join(tmpdir(), `python-parser-dir-ignore-test-${process.pid}`); + const testDir = join(dir, "tests"); + mkdirSync(testDir, { recursive: true }); + writeFileSync(join(dir, "client.py"), "class SupabaseClient:\n pass\n"); + writeFileSync(join(testDir, "test_client.py"), "class TestClient:\n pass\n"); + writeFileSync(join(dir, "sdk-parse-ignore"), "tests/\n"); + const result = parsePythonProject(dir); + const n = result.symbols.map((s) => s.name); + expect(n).toContain("SupabaseClient"); + expect(n).not.toContain("TestClient"); + }); + + it("does not filter when sdk-parse-ignore is absent", () => { + const dir = join(tmpdir(), `python-parser-no-ignore-test-${process.pid}`); + const pkgDir = join(dir, "supabase"); + mkdirSync(pkgDir, { recursive: true }); + writeFileSync(join(pkgDir, "client.py"), "class SupabaseClient:\n pass\n"); + const result = parsePythonProject(dir); + expect(result.symbols.map((s) => s.name)).toContain("SupabaseClient"); + }); +}); From 160b625374ed1b56b96713039c80f2b818555ba4 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 19 Jun 2026 10:59:12 -0300 Subject: [PATCH 2/9] feat: add griffe JSON normalizer for Python public API surface --- .../capability-matrix/src/normalize-griffe.ts | 75 +++++ .../test/normalize-griffe.test.ts | 293 ++++++++++++++++++ 2 files changed, 368 insertions(+) create mode 100644 scripts/capability-matrix/src/normalize-griffe.ts create mode 100644 scripts/capability-matrix/test/normalize-griffe.test.ts diff --git a/scripts/capability-matrix/src/normalize-griffe.ts b/scripts/capability-matrix/src/normalize-griffe.ts new file mode 100644 index 0000000..c68a698 --- /dev/null +++ b/scripts/capability-matrix/src/normalize-griffe.ts @@ -0,0 +1,75 @@ +import { relative } from "node:path"; +import type { ParsedSymbol, ParseResult } from "./ts-parser.js"; +import { loadIgnore, type Ignore } from "./parse-ignore.js"; + +export type { ParsedSymbol, ParseResult }; + +export interface GriffeNode { + kind: "module" | "class" | "function" | "attribute"; + filepath?: string; + labels?: string[] | null; + members?: Record; +} + +export type GriffeOutput = Record; + +export function normalizeGriffe(raw: GriffeOutput, projectRoot = ""): ParseResult { + const ig = projectRoot ? loadIgnore(projectRoot) : null; + const symbols: ParsedSymbol[] = []; + + for (const [, moduleNode] of Object.entries(raw)) { + walkNode("", moduleNode, "", [], symbols, ig, projectRoot); + } + + return { symbols }; +} + +function walkNode( + name: string, + node: GriffeNode, + inheritedFile: string, + classStack: string[], + symbols: ParsedSymbol[], + ig: Ignore | null, + projectRoot: string, +): void { + const file = node.filepath + ? (projectRoot ? relative(projectRoot, node.filepath) : node.filepath) + : inheritedFile; + + if (node.kind === "module") { + for (const [childName, child] of Object.entries(node.members ?? {})) { + walkNode(childName, child, file, classStack, symbols, ig, projectRoot); + } + return; + } + + if (name.startsWith("_")) return; + + if (node.kind === "class") { + emit(symbols, ig, { name: qual(classStack, name), kind: "class", file }); + for (const [childName, child] of Object.entries(node.members ?? {})) { + walkNode(childName, child, file, [...classStack, name], symbols, ig, projectRoot); + } + return; + } + + if (node.kind === "function") { + const kind = classStack.length > 0 ? ("method" as const) : ("function" as const); + emit(symbols, ig, { name: qual(classStack, name), kind, file }); + return; + } + + if (node.kind === "attribute" && node.labels?.includes("property")) { + emit(symbols, ig, { name: qual(classStack, name), kind: "property", file }); + } +} + +function qual(classStack: string[], name: string): string { + return classStack.length > 0 ? `${classStack.join(".")}.${name}` : name; +} + +function emit(symbols: ParsedSymbol[], ig: Ignore | null, sym: ParsedSymbol): void { + if (ig && sym.file && ig.ignores(sym.file)) return; + symbols.push(sym); +} diff --git a/scripts/capability-matrix/test/normalize-griffe.test.ts b/scripts/capability-matrix/test/normalize-griffe.test.ts new file mode 100644 index 0000000..cad6a4c --- /dev/null +++ b/scripts/capability-matrix/test/normalize-griffe.test.ts @@ -0,0 +1,293 @@ +import { describe, it, expect } from "vitest"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { writeFileSync, mkdtempSync } from "node:fs"; +import { normalizeGriffe, type GriffeOutput, type GriffeNode } from "../src/normalize-griffe"; + +// Helper: build a minimal module node wrapping members +function pkg(filepath: string, members: Record): GriffeOutput { + return { mypkg: { kind: "module", filepath, members } }; +} + +describe("normalizeGriffe — classes", () => { + it("emits public class", () => { + const input = pkg("/repo/src/client.py", { + MyClient: { kind: "class", members: {} }, + }); + const { symbols } = normalizeGriffe(input, "/repo"); + expect(symbols).toContainEqual({ name: "MyClient", kind: "class", file: "src/client.py" }); + }); + + it("skips class whose name starts with underscore", () => { + const input = pkg("/repo/src/client.py", { + _Internal: { kind: "class", members: {} }, + }); + const { symbols } = normalizeGriffe(input, "/repo"); + expect(symbols).toHaveLength(0); + }); + + it("skips methods inside a private class", () => { + const input = pkg("/repo/src/client.py", { + _Internal: { + kind: "class", + members: { public_method: { kind: "function", labels: null } }, + }, + }); + const { symbols } = normalizeGriffe(input, "/repo"); + expect(symbols).toHaveLength(0); + }); +}); + +describe("normalizeGriffe — methods and functions", () => { + it("emits public method as ClassName.method_name", () => { + const input = pkg("/repo/src/client.py", { + MyClient: { + kind: "class", + members: { sign_up: { kind: "function", labels: null } }, + }, + }); + const { symbols } = normalizeGriffe(input, "/repo"); + expect(symbols).toContainEqual({ name: "MyClient.sign_up", kind: "method", file: "src/client.py" }); + }); + + it("skips method whose name starts with underscore", () => { + const input = pkg("/repo/src/client.py", { + MyClient: { + kind: "class", + members: { _private: { kind: "function", labels: null } }, + }, + }); + const { symbols } = normalizeGriffe(input, "/repo"); + // only the class itself is emitted, not the private method + expect(symbols).toHaveLength(1); + expect(symbols[0].name).toBe("MyClient"); + }); + + it("emits top-level function as function kind", () => { + const input = pkg("/repo/src/client.py", { + create_client: { kind: "function", labels: null }, + }); + const { symbols } = normalizeGriffe(input, "/repo"); + expect(symbols).toContainEqual({ name: "create_client", kind: "function", file: "src/client.py" }); + }); + + it("skips top-level function whose name starts with underscore", () => { + const input = pkg("/repo/src/client.py", { + _helper: { kind: "function", labels: null }, + }); + const { symbols } = normalizeGriffe(input, "/repo"); + expect(symbols).toHaveLength(0); + }); + + it("emits staticmethod member as method (label is informational only)", () => { + const input = pkg("/repo/src/client.py", { + MyClient: { + kind: "class", + members: { from_env: { kind: "function", labels: ["staticmethod"] } }, + }, + }); + const { symbols } = normalizeGriffe(input, "/repo"); + expect(symbols).toContainEqual({ name: "MyClient.from_env", kind: "method", file: "src/client.py" }); + }); + + it("emits classmethod member as method", () => { + const input = pkg("/repo/src/client.py", { + MyClient: { + kind: "class", + members: { from_url: { kind: "function", labels: ["classmethod"] } }, + }, + }); + const { symbols } = normalizeGriffe(input, "/repo"); + expect(symbols).toContainEqual({ name: "MyClient.from_url", kind: "method", file: "src/client.py" }); + }); +}); + +describe("normalizeGriffe — properties", () => { + it("emits attribute with property label as property kind", () => { + const input = pkg("/repo/src/client.py", { + MyClient: { + kind: "class", + members: { session: { kind: "attribute", labels: ["property", "writable"] } }, + }, + }); + const { symbols } = normalizeGriffe(input, "/repo"); + expect(symbols).toContainEqual({ name: "MyClient.session", kind: "property", file: "src/client.py" }); + }); + + it("does not emit attribute without property label (plain class variable)", () => { + const input = pkg("/repo/src/client.py", { + MyClient: { + kind: "class", + members: { DEFAULT_URL: { kind: "attribute", labels: null } }, + }, + }); + const { symbols } = normalizeGriffe(input, "/repo"); + // only the class itself + expect(symbols.find((s) => s.name === "MyClient.DEFAULT_URL")).toBeUndefined(); + }); + + it("handles labels: [] (empty array) — not a property", () => { + const input = pkg("/repo/src/client.py", { + MyClient: { + kind: "class", + members: { TOKEN: { kind: "attribute", labels: [] } }, + }, + }); + const { symbols } = normalizeGriffe(input, "/repo"); + expect(symbols.find((s) => s.name === "MyClient.TOKEN")).toBeUndefined(); + }); + + it("skips property whose name starts with underscore", () => { + const input = pkg("/repo/src/client.py", { + MyClient: { + kind: "class", + members: { _session: { kind: "attribute", labels: ["property"] } }, + }, + }); + const { symbols } = normalizeGriffe(input, "/repo"); + expect(symbols.find((s) => s.name.includes("_session"))).toBeUndefined(); + }); +}); + +describe("normalizeGriffe — sub-modules", () => { + it("recurses into private-named sub-module (_async) and emits public classes inside", () => { + const input: GriffeOutput = { + supabase_auth: { + kind: "module", + filepath: "/repo/src/supabase_auth/__init__.py", + members: { + _async: { + kind: "module", + filepath: "/repo/src/supabase_auth/_async/__init__.py", + members: { + AsyncGoTrueClient: { + kind: "class", + members: { + sign_up: { kind: "function", labels: null }, + }, + }, + }, + }, + }, + }, + }; + const { symbols } = normalizeGriffe(input, "/repo"); + expect(symbols).toContainEqual({ name: "AsyncGoTrueClient", kind: "class", file: "src/supabase_auth/_async/__init__.py" }); + expect(symbols).toContainEqual({ name: "AsyncGoTrueClient.sign_up", kind: "method", file: "src/supabase_auth/_async/__init__.py" }); + }); +}); + +describe("normalizeGriffe — nested classes", () => { + it("emits nested class as Outer.Inner", () => { + const input = pkg("/repo/src/client.py", { + Outer: { + kind: "class", + members: { + Inner: { kind: "class", members: {} }, + }, + }, + }); + const { symbols } = normalizeGriffe(input, "/repo"); + expect(symbols).toContainEqual({ name: "Outer.Inner", kind: "class", file: "src/client.py" }); + }); + + it("emits method of nested class as Outer.Inner.method", () => { + const input = pkg("/repo/src/client.py", { + Outer: { + kind: "class", + members: { + Inner: { + kind: "class", + members: { do_thing: { kind: "function", labels: null } }, + }, + }, + }, + }); + const { symbols } = normalizeGriffe(input, "/repo"); + expect(symbols).toContainEqual({ name: "Outer.Inner.do_thing", kind: "method", file: "src/client.py" }); + }); +}); + +describe("normalizeGriffe — multi-package input", () => { + it("emits symbols from all packages in output", () => { + const input: GriffeOutput = { + pkg_a: { + kind: "module", + filepath: "/repo/src/pkg_a/__init__.py", + members: { ClassA: { kind: "class", members: {} } }, + }, + pkg_b: { + kind: "module", + filepath: "/repo/src/pkg_b/__init__.py", + members: { ClassB: { kind: "class", members: {} } }, + }, + }; + const { symbols } = normalizeGriffe(input, "/repo"); + expect(symbols.map((s) => s.name)).toContain("ClassA"); + expect(symbols.map((s) => s.name)).toContain("ClassB"); + }); +}); + +describe("normalizeGriffe — file inheritance", () => { + it("inherits filepath from parent module when member has no filepath", () => { + const input: GriffeOutput = { + mypkg: { + kind: "module", + filepath: "/repo/src/mypkg/client.py", + members: { + // class has no own filepath — should inherit module's + MyClient: { kind: "class", members: {} }, + }, + }, + }; + const { symbols } = normalizeGriffe(input, "/repo"); + expect(symbols[0].file).toBe("src/mypkg/client.py"); + }); +}); + +describe("normalizeGriffe — sdk-parse-ignore", () => { + it("filters out symbols whose file matches sdk-parse-ignore", () => { + const root = mkdtempSync(join(tmpdir(), "griffe-test-")); + writeFileSync(join(root, "sdk-parse-ignore"), "src/tests/\n"); + + const input: GriffeOutput = { + mypkg: { + kind: "module", + filepath: join(root, "src/tests/test_client.py"), + members: { + TestHelper: { kind: "class", members: {} }, + }, + }, + }; + const { symbols } = normalizeGriffe(input, root); + expect(symbols).toHaveLength(0); + }); + + it("does not filter symbols whose file does not match sdk-parse-ignore", () => { + const root = mkdtempSync(join(tmpdir(), "griffe-test-")); + writeFileSync(join(root, "sdk-parse-ignore"), "src/tests/\n"); + + const input: GriffeOutput = { + mypkg: { + kind: "module", + filepath: join(root, "src/mypkg/client.py"), + members: { + MyClient: { kind: "class", members: {} }, + }, + }, + }; + const { symbols } = normalizeGriffe(input, root); + expect(symbols).toHaveLength(1); + }); + + it("works with no sdk-parse-ignore file present", () => { + const root = mkdtempSync(join(tmpdir(), "griffe-test-no-ignore-")); + // no sdk-parse-ignore file written + + const input = pkg(join(root, "src/client.py"), { + MyClient: { kind: "class", members: {} }, + }); + const { symbols } = normalizeGriffe(input, root); + expect(symbols).toHaveLength(1); + }); +}); From 448aef40bf649bd14682e71a276c8e72036e5bb8 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 19 Jun 2026 11:01:52 -0300 Subject: [PATCH 3/9] feat: add normalize-griffe CLI and npm script --- scripts/capability-matrix/package.json | 2 +- .../src/normalize-griffe-cli.ts | 27 +++++++++++++++++++ .../capability-matrix/src/normalize-griffe.ts | 4 +-- 3 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 scripts/capability-matrix/src/normalize-griffe-cli.ts diff --git a/scripts/capability-matrix/package.json b/scripts/capability-matrix/package.json index f1393c9..597bf25 100644 --- a/scripts/capability-matrix/package.json +++ b/scripts/capability-matrix/package.json @@ -10,7 +10,7 @@ "validate-compliance": "tsx src/compliance-cli.ts", "parse-ts": "tsx src/parse-ts.ts", "parse-swift": "tsx src/parse-swift.ts", - "parse-python": "tsx src/parse-python.ts", + "normalize-griffe": "tsx src/normalize-griffe-cli.ts", "check-api-symbols": "tsx src/check-api-symbols.ts", "aggregate": "tsx src/aggregate.ts", "report": "tsx src/cli.ts report", diff --git a/scripts/capability-matrix/src/normalize-griffe-cli.ts b/scripts/capability-matrix/src/normalize-griffe-cli.ts new file mode 100644 index 0000000..90ac066 --- /dev/null +++ b/scripts/capability-matrix/src/normalize-griffe-cli.ts @@ -0,0 +1,27 @@ +import { readFileSync } from "node:fs"; +import { normalizeGriffe, type GriffeOutput } from "./normalize-griffe.js"; + +async function main(): Promise { + const [, , filePath, ...rest] = process.argv; + if (!filePath) { + console.error("Usage: normalize-griffe [--project-root ]"); + process.exit(1); + } + + const rootIdx = rest.indexOf("--project-root"); + const projectRoot = rootIdx >= 0 ? rest[rootIdx + 1] : undefined; + + try { + const raw: GriffeOutput = JSON.parse(readFileSync(filePath, "utf8")); + const result = normalizeGriffe(raw, projectRoot); + console.log(JSON.stringify(result, null, 2)); + } catch (e) { + console.error(`Error: ${(e as Error).message}`); + process.exit(1); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/capability-matrix/src/normalize-griffe.ts b/scripts/capability-matrix/src/normalize-griffe.ts index c68a698..6fb47a6 100644 --- a/scripts/capability-matrix/src/normalize-griffe.ts +++ b/scripts/capability-matrix/src/normalize-griffe.ts @@ -1,4 +1,4 @@ -import { relative } from "node:path"; +import { basename, relative } from "node:path"; import type { ParsedSymbol, ParseResult } from "./ts-parser.js"; import { loadIgnore, type Ignore } from "./parse-ignore.js"; @@ -34,7 +34,7 @@ function walkNode( projectRoot: string, ): void { const file = node.filepath - ? (projectRoot ? relative(projectRoot, node.filepath) : node.filepath) + ? (projectRoot ? relative(projectRoot, node.filepath) : basename(node.filepath)) : inheritedFile; if (node.kind === "module") { From e448776c2cb9abae944a19fc338ffbca4618a6a7 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 19 Jun 2026 11:06:15 -0300 Subject: [PATCH 4/9] ci: replace regex Python parser with griffe-based approach Add griffe-packages and griffe-search-paths workflow inputs, add Python-specific steps (setup-python, install griffe, run griffe dump, normalize output) with if: inputs.language == 'python' guards, restrict existing Resolve/Parse steps with if: inputs.language != 'python', and remove the old regex-based python-parser.ts, parse-python.ts, and related tests. --- .github/workflows/validate-sdk-compliance.yml | 62 +++- scripts/capability-matrix/src/parse-python.ts | 19 - .../capability-matrix/src/python-parser.ts | 177 ---------- .../fixtures/python-sample/supabase/client.py | 58 --- .../test/python-parser.test.ts | 334 ------------------ 5 files changed, 61 insertions(+), 589 deletions(-) delete mode 100644 scripts/capability-matrix/src/parse-python.ts delete mode 100644 scripts/capability-matrix/src/python-parser.ts delete mode 100644 scripts/capability-matrix/test/fixtures/python-sample/supabase/client.py delete mode 100644 scripts/capability-matrix/test/python-parser.test.ts diff --git a/.github/workflows/validate-sdk-compliance.yml b/.github/workflows/validate-sdk-compliance.yml index 19b1eaf..0ec3f9f 100644 --- a/.github/workflows/validate-sdk-compliance.yml +++ b/.github/workflows/validate-sdk-compliance.yml @@ -15,6 +15,14 @@ on: description: Ref to checkout from supabase/sdk for scripts (defaults to main; override only during pre-merge testing) type: string default: main + griffe-packages: + description: Space-separated griffe package names to dump (python only) + type: string + default: "" + griffe-search-paths: + description: Comma-separated search paths for griffe relative to repo root (python only) + type: string + default: "" jobs: validate: @@ -73,23 +81,75 @@ jobs: run: npm ci working-directory: _sdk-spec/scripts/capability-matrix + - name: Set up Python + if: inputs.language == 'python' + uses: actions/setup-python@39cd14951b08e74b54015ebf0246ef4cc7b4ce8 # v5.5.0 + with: + python-version: "3.x" + + - name: Install griffe + if: inputs.language == 'python' + run: pip install griffe + + - name: Run griffe on PR branch + if: inputs.language == 'python' + run: | + SEARCH_FLAGS="" + if [ -n "${{ inputs.griffe-search-paths }}" ]; then + SEARCH_FLAGS=$(echo "${{ inputs.griffe-search-paths }}" | tr ',' '\n' \ + | while read -r p; do echo "--search $GITHUB_WORKSPACE/_sdk-pr/$p"; done \ + | tr '\n' ' ') + fi + python -m griffe dump ${{ inputs.griffe-packages }} $SEARCH_FLAGS \ + -o "$GITHUB_WORKSPACE/api-raw-pr.json" + + - name: Normalize griffe output (PR branch) + if: inputs.language == 'python' + run: | + npm run --silent normalize-griffe -- "$GITHUB_WORKSPACE/api-raw-pr.json" \ + --project-root "$GITHUB_WORKSPACE/_sdk-pr" \ + > "$GITHUB_WORKSPACE/pr-symbols.json" + working-directory: _sdk-spec/scripts/capability-matrix + + - name: Run griffe on base branch + if: inputs.language == 'python' + run: | + SEARCH_FLAGS="" + if [ -n "${{ inputs.griffe-search-paths }}" ]; then + SEARCH_FLAGS=$(echo "${{ inputs.griffe-search-paths }}" | tr ',' '\n' \ + | while read -r p; do echo "--search $GITHUB_WORKSPACE/_sdk-base/$p"; done \ + | tr '\n' ' ') + fi + python -m griffe dump ${{ inputs.griffe-packages }} $SEARCH_FLAGS \ + -o "$GITHUB_WORKSPACE/api-raw-base.json" + + - name: Normalize griffe output (base branch) + if: inputs.language == 'python' + run: | + npm run --silent normalize-griffe -- "$GITHUB_WORKSPACE/api-raw-base.json" \ + --project-root "$GITHUB_WORKSPACE/_sdk-base" \ + > "$GITHUB_WORKSPACE/base-symbols.json" + working-directory: _sdk-spec/scripts/capability-matrix + - name: Resolve parse command + if: inputs.language != 'python' id: resolve run: | case "${{ inputs.language }}" in swift) echo "cmd=parse-swift" >> "$GITHUB_OUTPUT" ;; javascript) echo "cmd=parse-ts" >> "$GITHUB_OUTPUT" ;; - python) echo "cmd=parse-python" >> "$GITHUB_OUTPUT" ;; *) echo "::error::Unsupported language '${{ inputs.language }}'. Supported values: swift, javascript, python"; exit 1 ;; esac - name: Parse PR branch + if: inputs.language != 'python' run: | npm run --silent ${{ steps.resolve.outputs.cmd }} -- "$GITHUB_WORKSPACE/_sdk-pr" \ > "$GITHUB_WORKSPACE/pr-symbols.json" working-directory: _sdk-spec/scripts/capability-matrix - name: Parse base branch + if: inputs.language != 'python' run: | npm run --silent ${{ steps.resolve.outputs.cmd }} -- "$GITHUB_WORKSPACE/_sdk-base" \ > "$GITHUB_WORKSPACE/base-symbols.json" diff --git a/scripts/capability-matrix/src/parse-python.ts b/scripts/capability-matrix/src/parse-python.ts deleted file mode 100644 index 0232cac..0000000 --- a/scripts/capability-matrix/src/parse-python.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { parsePythonProject } from "./python-parser.js"; - -async function main(): Promise { - const projectPath = process.argv[2]; - if (!projectPath) { - console.error("Usage: parse-python "); - process.exit(1); - } - - try { - const result = parsePythonProject(projectPath); - console.log(JSON.stringify(result, null, 2)); - } catch (e) { - console.error(`Error: ${(e as Error).message}`); - process.exit(1); - } -} - -main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/scripts/capability-matrix/src/python-parser.ts b/scripts/capability-matrix/src/python-parser.ts deleted file mode 100644 index 4dbf212..0000000 --- a/scripts/capability-matrix/src/python-parser.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { readFileSync, readdirSync, existsSync } from "node:fs"; -import { join, resolve, relative } from "node:path"; -import type { ParsedSymbol, ParseResult } from "./ts-parser.js"; -import { loadIgnore, type Ignore } from "./parse-ignore.js"; - -export type { ParsedSymbol, ParseResult }; - -// --------------------------------------------------------------------------- -// Internal helpers -// --------------------------------------------------------------------------- - -// Directories that are never meaningful Python source -const SKIP_DIRS = new Set([ - "__pycache__", ".git", "node_modules", - "venv", ".venv", "env", ".env", - "build", "dist", ".tox", ".pytest_cache", ".mypy_cache", -]); - -function findPythonFiles(dir: string, root: string, ig: Ignore): string[] { - const results: string[] = []; - try { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - if (entry.name.startsWith(".")) continue; - const full = join(dir, entry.name); - const rel = relative(root, full); - if (entry.isDirectory()) { - if (SKIP_DIRS.has(entry.name)) continue; - if (ig.ignores(rel + "/")) continue; - results.push(...findPythonFiles(full, root, ig)); - } else if (entry.isFile() && entry.name.endsWith(".py")) { - if (ig.ignores(rel)) continue; - results.push(full); - } - } - } catch { /* ignore unreadable dirs */ } - return results; -} - -// --------------------------------------------------------------------------- -// Single-file parser -// --------------------------------------------------------------------------- - -interface Context { - name: string; // possibly dotted: "SupabaseClient.Config" - classIndent: number; // indent of the `class` keyword line itself - memberIndent: number | null; // indent of direct class body members (set on first member) -} - -// Normalise leading whitespace: tabs count as 4 spaces -function getIndent(line: string): number { - let n = 0; - for (const ch of line) { - if (ch === " ") n += 1; - else if (ch === "\t") n += 4; - else break; - } - return n; -} - -// Dunder names like __init__, __repr__ are implementation details, not API surface -const DUNDER_RE = /^__\w+__$/; - -export function extractFromSource(source: string, relPath: string): ParsedSymbol[] { - const symbols: ParsedSymbol[] = []; - const contextStack: Context[] = []; - const pendingDecorators: string[] = []; - - for (const rawLine of source.split("\n")) { - // Strip inline # comments — simplified (doesn't handle # inside strings, which - // is acceptable for the SDK codebases this parser targets) - const commentIdx = rawLine.indexOf("#"); - const line = commentIdx >= 0 ? rawLine.slice(0, commentIdx) : rawLine; - const trimmed = line.trim(); - - if (!trimmed) continue; - - const indent = getIndent(rawLine); - - // Pop contexts that have ended: we're at or before the class indent level - while ( - contextStack.length > 0 && - indent <= contextStack[contextStack.length - 1].classIndent - ) { - contextStack.pop(); - } - - // Collect decorator names without arguments: "@property" → "property", - // "@name.setter" → "name.setter" - if (trimmed.startsWith("@")) { - pendingDecorators.push(trimmed.slice(1).split("(")[0].trim()); - continue; - } - - const topCtx = contextStack.length > 0 ? contextStack[contextStack.length - 1] : null; - - // Determine whether this line sits at the valid direct-member indent for the - // current class context. The member indent is discovered lazily from the first - // declaration line seen inside the class body. - let atMemberLevel: boolean; - if (topCtx === null) { - atMemberLevel = indent === 0; - } else if (topCtx.memberIndent === null) { - topCtx.memberIndent = indent; // first declaration — establish the body indent - atMemberLevel = true; - } else { - atMemberLevel = indent === topCtx.memberIndent; - } - - const decorators = [...pendingDecorators]; - pendingDecorators.length = 0; - - if (!atMemberLevel) continue; - - const currentName = topCtx ? topCtx.name : ""; - - // --- Class declaration --- - const classMatch = trimmed.match(/^class\s+(\w+)/); - if (classMatch) { - const className = classMatch[1]; - if (!className.startsWith("_")) { - const qualifiedName = currentName ? `${currentName}.${className}` : className; - symbols.push({ name: qualifiedName, kind: "class", file: relPath }); - contextStack.push({ name: qualifiedName, classIndent: indent, memberIndent: null }); - } - continue; - } - - // --- Function / method declaration --- - const funcMatch = trimmed.match(/^(?:async\s+)?def\s+(\w+)/); - if (funcMatch) { - const funcName = funcMatch[1]; - // Skip private names (leading _) and dunder methods (__x__) - if (funcName.startsWith("_") || DUNDER_RE.test(funcName)) continue; - - // Skip property setters/deleters — their decorator ends with .setter / .deleter - const isAccessor = decorators.some((d) => /\.\w+$/.test(d)); - if (isAccessor) continue; - - if (currentName) { - const isProperty = decorators.includes("property"); - symbols.push({ - name: `${currentName}.${funcName}`, - kind: isProperty ? "property" : "method", - file: relPath, - }); - } else { - symbols.push({ name: funcName, kind: "function", file: relPath }); - } - continue; - } - } - - return symbols; -} - -// --------------------------------------------------------------------------- -// Project-level entry point -// --------------------------------------------------------------------------- - -export function parsePythonProject(projectRoot: string): ParseResult { - const root = resolve(projectRoot); - const ig = loadIgnore(root); - // Support both src-layout (src/packagename/) and flat layout (packagename/) - const srcDir = join(root, "src"); - const scanRoot = existsSync(srcDir) ? srcDir : root; - - const files = findPythonFiles(scanRoot, root, ig); - const symbols: ParsedSymbol[] = []; - - for (const file of files) { - const source = readFileSync(file, "utf8"); - const relPath = relative(root, file); - symbols.push(...extractFromSource(source, relPath)); - } - - return { symbols }; -} diff --git a/scripts/capability-matrix/test/fixtures/python-sample/supabase/client.py b/scripts/capability-matrix/test/fixtures/python-sample/supabase/client.py deleted file mode 100644 index 0fd7901..0000000 --- a/scripts/capability-matrix/test/fixtures/python-sample/supabase/client.py +++ /dev/null @@ -1,58 +0,0 @@ -class SupabaseClient: - def __init__(self, url: str, key: str): - self._auth = None - self._storage = None - - @property - def auth(self): - return self._auth - - @auth.setter - def auth(self, value): - self._auth = value - - @property - def storage(self): - return self._storage - - async def rpc(self, fn: str, params: dict = None): - pass - - def from_(self, table: str): - pass - - @staticmethod - def create(url: str, key: str) -> "SupabaseClient": - pass - - @classmethod - def from_url(cls, url: str) -> "SupabaseClient": - pass - - def _private_helper(self): - pass - - def __repr__(self) -> str: - return f"SupabaseClient({self})" - - -class StorageClient: - async def upload(self, path: str, data: bytes) -> None: - pass - - def download(self, path: str) -> bytes: - pass - - -# Private class — must not appear in output -class _InternalCache: - def method(self): - pass - - -def create_client(url: str, key: str) -> SupabaseClient: - return SupabaseClient(url, key) - - -def _private_function(): - pass diff --git a/scripts/capability-matrix/test/python-parser.test.ts b/scripts/capability-matrix/test/python-parser.test.ts deleted file mode 100644 index 056c3e6..0000000 --- a/scripts/capability-matrix/test/python-parser.test.ts +++ /dev/null @@ -1,334 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { writeFileSync, mkdirSync } from "node:fs"; -import { extractFromSource, parsePythonProject } from "../src/python-parser"; - -function names(src: string): string[] { - return extractFromSource(src, "supabase/client.py").map((s) => s.name); -} - -function kinds(src: string): Record { - return Object.fromEntries( - extractFromSource(src, "supabase/client.py").map((s) => [s.name, s.kind]), - ); -} - -// --------------------------------------------------------------------------- -// Type declarations -// --------------------------------------------------------------------------- - -describe("extractFromSource — class declarations", () => { - it("extracts a public class", () => { - expect(names("class AuthClient:\n pass\n")).toContain("AuthClient"); - }); - - it("extracts a class with a base class", () => { - expect(names("class AuthClient(BaseClient):\n pass\n")).toContain("AuthClient"); - }); - - it("does not extract a private class (leading underscore)", () => { - expect(names("class _Internal:\n pass\n")).not.toContain("_Internal"); - }); - - it("extracts an abstract base class", () => { - expect(names("class BaseAuth(ABC):\n pass\n")).toContain("BaseAuth"); - }); -}); - -// --------------------------------------------------------------------------- -// Public methods -// --------------------------------------------------------------------------- - -describe("extractFromSource — public methods", () => { - const src = ` -class AuthClient: - def sign_up(self, email: str, password: str) -> dict: - pass - - async def sign_in(self, credentials: dict) -> dict: - pass - - def sign_out(self) -> None: - pass -`; - it("extracts a regular method", () => expect(names(src)).toContain("AuthClient.sign_up")); - it("extracts an async method", () => expect(names(src)).toContain("AuthClient.sign_in")); - it("extracts multiple methods", () => expect(names(src)).toContain("AuthClient.sign_out")); - it("marks methods with kind 'method'", () => { - expect(kinds(src)["AuthClient.sign_up"]).toBe("method"); - }); -}); - -// --------------------------------------------------------------------------- -// Private / dunder exclusions -// --------------------------------------------------------------------------- - -describe("extractFromSource — private and dunder exclusions", () => { - const src = ` -class SupabaseClient: - def __init__(self, url: str, key: str): - pass - - def __repr__(self) -> str: - pass - - def _connect(self): - pass - - def public_method(self): - pass -`; - it("excludes __init__", () => expect(names(src)).not.toContain("SupabaseClient.__init__")); - it("excludes __repr__", () => expect(names(src)).not.toContain("SupabaseClient.__repr__")); - it("excludes private methods (leading _)", () => expect(names(src)).not.toContain("SupabaseClient._connect")); - it("includes the public method", () => expect(names(src)).toContain("SupabaseClient.public_method")); -}); - -// --------------------------------------------------------------------------- -// Properties -// --------------------------------------------------------------------------- - -describe("extractFromSource — properties", () => { - const src = ` -class SupabaseClient: - @property - def auth(self): - return self._auth - - @auth.setter - def auth(self, value): - self._auth = value - - @property - def storage(self): - return self._storage -`; - it("extracts @property getter", () => expect(names(src)).toContain("SupabaseClient.auth")); - it("marks @property with kind 'property'", () => { - expect(kinds(src)["SupabaseClient.auth"]).toBe("property"); - }); - it("extracts second @property", () => expect(names(src)).toContain("SupabaseClient.storage")); - it("does not emit duplicate from @auth.setter", () => { - const authEntries = names(src).filter((n) => n === "SupabaseClient.auth"); - expect(authEntries).toHaveLength(1); - }); -}); - -// --------------------------------------------------------------------------- -// Static and class methods -// --------------------------------------------------------------------------- - -describe("extractFromSource — static and class methods", () => { - const src = ` -class FunctionsClient: - @staticmethod - def create(url: str) -> "FunctionsClient": - pass - - @classmethod - def from_env(cls) -> "FunctionsClient": - pass -`; - it("extracts @staticmethod", () => expect(names(src)).toContain("FunctionsClient.create")); - it("marks @staticmethod as kind 'method'", () => { - expect(kinds(src)["FunctionsClient.create"]).toBe("method"); - }); - it("extracts @classmethod", () => expect(names(src)).toContain("FunctionsClient.from_env")); -}); - -// --------------------------------------------------------------------------- -// Top-level functions -// --------------------------------------------------------------------------- - -describe("extractFromSource — top-level functions", () => { - it("extracts a top-level function", () => { - expect(names("def create_client(url: str, key: str):\n pass\n")).toContain("create_client"); - }); - - it("marks top-level functions with kind 'function'", () => { - expect(kinds("def create_client(url: str):\n pass\n")["create_client"]).toBe("function"); - }); - - it("does not extract private top-level functions", () => { - expect(names("def _internal_helper():\n pass\n")).not.toContain("_internal_helper"); - }); - - it("extracts an async top-level function", () => { - expect(names("async def fetch(url: str):\n pass\n")).toContain("fetch"); - }); -}); - -// --------------------------------------------------------------------------- -// Nested classes -// --------------------------------------------------------------------------- - -describe("extractFromSource — nested classes", () => { - const src = ` -class SupabaseClient: - class Config: - def get(self, key: str) -> str: - pass - - def from_(self, table: str): - pass -`; - it("extracts the outer class", () => expect(names(src)).toContain("SupabaseClient")); - it("extracts the nested class with dotted name", () => expect(names(src)).toContain("SupabaseClient.Config")); - it("extracts a method of the nested class", () => expect(names(src)).toContain("SupabaseClient.Config.get")); - it("extracts a method of the outer class after the nested class", () => expect(names(src)).toContain("SupabaseClient.from_")); -}); - -// --------------------------------------------------------------------------- -// Methods inside method bodies must not leak -// --------------------------------------------------------------------------- - -describe("extractFromSource — method body isolation", () => { - const src = ` -class Foo: - def outer(self): - def inner_helper(): - pass - return inner_helper - - def real_method(self): - pass -`; - it("does not capture nested function defined inside a method body", () => { - expect(names(src)).not.toContain("Foo.inner_helper"); - }); - it("still captures the real method after the containing method", () => { - expect(names(src)).toContain("Foo.real_method"); - }); -}); - -// --------------------------------------------------------------------------- -// Comment stripping -// --------------------------------------------------------------------------- - -describe("extractFromSource — comment stripping", () => { - it("ignores class definitions in inline comments", () => { - const src = ` -class Foo: - def real(self): pass # def fake(self): pass -`; - expect(names(src)).toContain("Foo.real"); - expect(names(src)).not.toContain("Foo.fake"); - }); - - it("ignores full-line comments", () => { - const src = ` -class Foo: - # def commented_out(self): pass - def real(self): pass -`; - expect(names(src)).toContain("Foo.real"); - expect(names(src)).not.toContain("Foo.commented_out"); - }); -}); - -// --------------------------------------------------------------------------- -// Context stack correctness -// --------------------------------------------------------------------------- - -describe("extractFromSource — context stack correctness", () => { - it("does not bleed members across sibling classes", () => { - const src = ` -class Auth: - def sign_up(self): pass - -class Storage: - def upload(self): pass -`; - const n = names(src); - expect(n).toContain("Auth.sign_up"); - expect(n).toContain("Storage.upload"); - expect(n).not.toContain("Auth.upload"); - expect(n).not.toContain("Storage.sign_up"); - }); -}); - -// --------------------------------------------------------------------------- -// Integration test against fixture project -// --------------------------------------------------------------------------- - -describe("parsePythonProject — fixture project", () => { - const fixtureDir = join(__dirname, "fixtures", "python-sample"); - - it("finds and parses all public symbols", () => { - const result = parsePythonProject(fixtureDir); - const symbolNames = result.symbols.map((s) => s.name); - - // Public classes - expect(symbolNames).toContain("SupabaseClient"); - expect(symbolNames).toContain("StorageClient"); - - // Public methods / properties - expect(symbolNames).toContain("SupabaseClient.auth"); - expect(symbolNames).toContain("SupabaseClient.storage"); - expect(symbolNames).toContain("SupabaseClient.rpc"); - expect(symbolNames).toContain("SupabaseClient.from_"); - expect(symbolNames).toContain("SupabaseClient.create"); - expect(symbolNames).toContain("SupabaseClient.from_url"); - expect(symbolNames).toContain("StorageClient.upload"); - expect(symbolNames).toContain("StorageClient.download"); - - // Top-level function - expect(symbolNames).toContain("create_client"); - - // Private things must be absent - expect(symbolNames).not.toContain("_InternalCache"); - expect(symbolNames).not.toContain("_private_function"); - expect(symbolNames).not.toContain("SupabaseClient.__init__"); - expect(symbolNames).not.toContain("SupabaseClient.__repr__"); - expect(symbolNames).not.toContain("SupabaseClient._private_helper"); - - // Setter must not duplicate the property - const authEntries = symbolNames.filter((n) => n === "SupabaseClient.auth"); - expect(authEntries).toHaveLength(1); - }); - - it("marks properties correctly from fixture", () => { - const result = parsePythonProject(fixtureDir); - const prop = result.symbols.find((s) => s.name === "SupabaseClient.auth"); - expect(prop?.kind).toBe("property"); - }); -}); - -// --------------------------------------------------------------------------- -// sdk-parse-ignore support -// --------------------------------------------------------------------------- - -describe("parsePythonProject — sdk-parse-ignore", () => { - it("excludes files matched by sdk-parse-ignore", () => { - const dir = join(tmpdir(), `python-parser-ignore-test-${process.pid}`); - const pkgDir = join(dir, "supabase"); - mkdirSync(pkgDir, { recursive: true }); - writeFileSync(join(pkgDir, "client.py"), "class SupabaseClient:\n def sign_up(self): pass\n"); - writeFileSync(join(dir, "sdk-parse-ignore"), "supabase/client.py\n"); - const result = parsePythonProject(dir); - expect(result.symbols.map((s) => s.name)).not.toContain("SupabaseClient"); - }); - - it("excludes entire directories matched by sdk-parse-ignore", () => { - const dir = join(tmpdir(), `python-parser-dir-ignore-test-${process.pid}`); - const testDir = join(dir, "tests"); - mkdirSync(testDir, { recursive: true }); - writeFileSync(join(dir, "client.py"), "class SupabaseClient:\n pass\n"); - writeFileSync(join(testDir, "test_client.py"), "class TestClient:\n pass\n"); - writeFileSync(join(dir, "sdk-parse-ignore"), "tests/\n"); - const result = parsePythonProject(dir); - const n = result.symbols.map((s) => s.name); - expect(n).toContain("SupabaseClient"); - expect(n).not.toContain("TestClient"); - }); - - it("does not filter when sdk-parse-ignore is absent", () => { - const dir = join(tmpdir(), `python-parser-no-ignore-test-${process.pid}`); - const pkgDir = join(dir, "supabase"); - mkdirSync(pkgDir, { recursive: true }); - writeFileSync(join(pkgDir, "client.py"), "class SupabaseClient:\n pass\n"); - const result = parsePythonProject(dir); - expect(result.symbols.map((s) => s.name)).toContain("SupabaseClient"); - }); -}); From e90801c9a9633807aa9e30ca267d8fa0301cd401 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 19 Jun 2026 11:15:08 -0300 Subject: [PATCH 5/9] ci: fix griffe-packages normalization and add empty-input guard - Normalize griffe-packages input to handle both comma and space-separated values by adding a normalization step that converts commas to spaces and removes extra whitespace - Add early-exit guard in both griffe dump steps to require griffe-packages when language=python - Add test case for skipping dunder methods (__init__) in normalize-griffe --- .github/workflows/validate-sdk-compliance.yml | 14 ++++++++++++-- .../test/normalize-griffe.test.ts | 11 +++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/.github/workflows/validate-sdk-compliance.yml b/.github/workflows/validate-sdk-compliance.yml index 0ec3f9f..9866321 100644 --- a/.github/workflows/validate-sdk-compliance.yml +++ b/.github/workflows/validate-sdk-compliance.yml @@ -94,13 +94,18 @@ jobs: - name: Run griffe on PR branch if: inputs.language == 'python' run: | + if [ -z "${{ inputs.griffe-packages }}" ]; then + echo "::error::griffe-packages input is required when language is python" + exit 1 + fi SEARCH_FLAGS="" if [ -n "${{ inputs.griffe-search-paths }}" ]; then SEARCH_FLAGS=$(echo "${{ inputs.griffe-search-paths }}" | tr ',' '\n' \ | while read -r p; do echo "--search $GITHUB_WORKSPACE/_sdk-pr/$p"; done \ | tr '\n' ' ') fi - python -m griffe dump ${{ inputs.griffe-packages }} $SEARCH_FLAGS \ + PKGS=$(echo "${{ inputs.griffe-packages }}" | tr ',' ' ' | tr -s ' ') + python -m griffe dump $PKGS $SEARCH_FLAGS \ -o "$GITHUB_WORKSPACE/api-raw-pr.json" - name: Normalize griffe output (PR branch) @@ -114,13 +119,18 @@ jobs: - name: Run griffe on base branch if: inputs.language == 'python' run: | + if [ -z "${{ inputs.griffe-packages }}" ]; then + echo "::error::griffe-packages input is required when language is python" + exit 1 + fi SEARCH_FLAGS="" if [ -n "${{ inputs.griffe-search-paths }}" ]; then SEARCH_FLAGS=$(echo "${{ inputs.griffe-search-paths }}" | tr ',' '\n' \ | while read -r p; do echo "--search $GITHUB_WORKSPACE/_sdk-base/$p"; done \ | tr '\n' ' ') fi - python -m griffe dump ${{ inputs.griffe-packages }} $SEARCH_FLAGS \ + PKGS=$(echo "${{ inputs.griffe-packages }}" | tr ',' ' ' | tr -s ' ') + python -m griffe dump $PKGS $SEARCH_FLAGS \ -o "$GITHUB_WORKSPACE/api-raw-base.json" - name: Normalize griffe output (base branch) diff --git a/scripts/capability-matrix/test/normalize-griffe.test.ts b/scripts/capability-matrix/test/normalize-griffe.test.ts index cad6a4c..50658c8 100644 --- a/scripts/capability-matrix/test/normalize-griffe.test.ts +++ b/scripts/capability-matrix/test/normalize-griffe.test.ts @@ -100,6 +100,17 @@ describe("normalizeGriffe — methods and functions", () => { const { symbols } = normalizeGriffe(input, "/repo"); expect(symbols).toContainEqual({ name: "MyClient.from_url", kind: "method", file: "src/client.py" }); }); + + it("skips dunder method (__init__)", () => { + const input = pkg("/repo/src/client.py", { + MyClient: { + kind: "class", + members: { __init__: { kind: "function", labels: null } }, + }, + }); + const { symbols } = normalizeGriffe(input, "/repo"); + expect(symbols.find((s) => s.name.includes("__init__"))).toBeUndefined(); + }); }); describe("normalizeGriffe — properties", () => { From d4e78c4d3137efbc8a6288e95af9d6b7c2700953 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 19 Jun 2026 11:35:42 -0300 Subject: [PATCH 6/9] test: fix sdk-parse-ignore test cross-platform compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anchored patterns with an internal slash (e.g. "src/tests/") behave inconsistently in ignore@6.x across macOS and Ubuntu — the pattern fails to match file paths under that directory on Linux. Use a non-anchored directory pattern ("tests/") instead, which matches at any depth and is consistent across platforms, matching the approach already used in parse-ignore.test.ts. --- scripts/capability-matrix/test/normalize-griffe.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/capability-matrix/test/normalize-griffe.test.ts b/scripts/capability-matrix/test/normalize-griffe.test.ts index 50658c8..d800854 100644 --- a/scripts/capability-matrix/test/normalize-griffe.test.ts +++ b/scripts/capability-matrix/test/normalize-griffe.test.ts @@ -259,7 +259,7 @@ describe("normalizeGriffe — file inheritance", () => { describe("normalizeGriffe — sdk-parse-ignore", () => { it("filters out symbols whose file matches sdk-parse-ignore", () => { const root = mkdtempSync(join(tmpdir(), "griffe-test-")); - writeFileSync(join(root, "sdk-parse-ignore"), "src/tests/\n"); + writeFileSync(join(root, "sdk-parse-ignore"), "tests/\n"); const input: GriffeOutput = { mypkg: { From 803c784473d2a9ed1c797c0049447e67ced269ce Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 19 Jun 2026 11:38:26 -0300 Subject: [PATCH 7/9] test: fix sdk-parse-ignore test path to match pattern root The ignore@6.x package on Linux only matches directory patterns against the leading path component. Change the test filepath from src/tests/test_client.py to tests/test_client.py so the "tests/" pattern matches the first component, consistent with how parse-ignore.test.ts structures its fixtures ("Tests/AuthTests.swift" for pattern "Tests/"). --- scripts/capability-matrix/test/normalize-griffe.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/capability-matrix/test/normalize-griffe.test.ts b/scripts/capability-matrix/test/normalize-griffe.test.ts index d800854..c95fa34 100644 --- a/scripts/capability-matrix/test/normalize-griffe.test.ts +++ b/scripts/capability-matrix/test/normalize-griffe.test.ts @@ -264,7 +264,7 @@ describe("normalizeGriffe — sdk-parse-ignore", () => { const input: GriffeOutput = { mypkg: { kind: "module", - filepath: join(root, "src/tests/test_client.py"), + filepath: join(root, "tests/test_client.py"), members: { TestHelper: { kind: "class", members: {} }, }, From 334b2a744488fcda948f8e1b0cc902bef900ea1b Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 19 Jun 2026 11:48:24 -0300 Subject: [PATCH 8/9] fix: update branch to use .sdk-parse-ignore (renamed in #37) PR #37 renamed sdk-parse-ignore to .sdk-parse-ignore on main after this branch was cut. Update parse-ignore.ts and all test files to use the new name so the branch is consistent both locally and on the CI merge commit. --- scripts/capability-matrix/src/parse-ignore.ts | 2 +- scripts/capability-matrix/test/parse-ignore.test.ts | 10 +++++----- scripts/capability-matrix/test/swift-parser.test.ts | 4 ++-- scripts/capability-matrix/test/ts-parser.test.ts | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/scripts/capability-matrix/src/parse-ignore.ts b/scripts/capability-matrix/src/parse-ignore.ts index 6df215e..6a0b661 100644 --- a/scripts/capability-matrix/src/parse-ignore.ts +++ b/scripts/capability-matrix/src/parse-ignore.ts @@ -6,7 +6,7 @@ export type { Ignore }; export function loadIgnore(projectRoot: string): Ignore { const ig = ignore(); - const filePath = join(projectRoot, "sdk-parse-ignore"); + const filePath = join(projectRoot, ".sdk-parse-ignore"); if (existsSync(filePath)) { ig.add(readFileSync(filePath, "utf8")); } diff --git a/scripts/capability-matrix/test/parse-ignore.test.ts b/scripts/capability-matrix/test/parse-ignore.test.ts index 0a48cde..e05c8a9 100644 --- a/scripts/capability-matrix/test/parse-ignore.test.ts +++ b/scripts/capability-matrix/test/parse-ignore.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { loadIgnore } from "../src/parse-ignore"; function makeTempDir(suffix: string): string { - const dir = join(tmpdir(), `sdk-parse-ignore-test-${suffix}-${process.pid}`); + const dir = join(tmpdir(), `.sdk-parse-ignore-test-${suffix}-${process.pid}`); mkdirSync(dir, { recursive: true }); return dir; } @@ -17,9 +17,9 @@ describe("loadIgnore", () => { expect(ig.ignores("Tests/Foo.swift")).toBe(false); }); - it("applies patterns from sdk-parse-ignore", () => { + it("applies patterns from .sdk-parse-ignore", () => { const dir = makeTempDir("patterns"); - writeFileSync(join(dir, "sdk-parse-ignore"), "Tests/\n**/*.test.ts\n"); + writeFileSync(join(dir, ".sdk-parse-ignore"), "Tests/\n**/*.test.ts\n"); const ig = loadIgnore(dir); expect(ig.ignores("Tests/AuthTests.swift")).toBe(true); expect(ig.ignores("src/auth.test.ts")).toBe(true); @@ -28,14 +28,14 @@ describe("loadIgnore", () => { it("ignores comment lines and blank lines", () => { const dir = makeTempDir("comments"); - writeFileSync(join(dir, "sdk-parse-ignore"), "# comment\n\nTests/\n"); + writeFileSync(join(dir, ".sdk-parse-ignore"), "# comment\n\nTests/\n"); const ig = loadIgnore(dir); expect(ig.ignores("Tests/Foo.swift")).toBe(true); }); it("supports negation to un-ignore a subdirectory", () => { const dir = makeTempDir("negation"); - writeFileSync(join(dir, "sdk-parse-ignore"), "Tests/*\n!Tests/Helpers/\n"); + writeFileSync(join(dir, ".sdk-parse-ignore"), "Tests/*\n!Tests/Helpers/\n"); const ig = loadIgnore(dir); expect(ig.ignores("Tests/AuthTests.swift")).toBe(true); expect(ig.ignores("Tests/Helpers/Mock.swift")).toBe(false); diff --git a/scripts/capability-matrix/test/swift-parser.test.ts b/scripts/capability-matrix/test/swift-parser.test.ts index 0e32318..8ff2870 100644 --- a/scripts/capability-matrix/test/swift-parser.test.ts +++ b/scripts/capability-matrix/test/swift-parser.test.ts @@ -173,7 +173,7 @@ describe("parseSwiftProject — sdk-parse-ignore", () => { const srcDir = join(dir, "Sources", "MyLib"); mkdirSync(srcDir, { recursive: true }); writeFileSync(join(srcDir, "Auth.swift"), "public class AuthClient {\n public func signUp() {}\n}\n"); - writeFileSync(join(dir, "sdk-parse-ignore"), "Sources/MyLib/Auth.swift\n"); + writeFileSync(join(dir, ".sdk-parse-ignore"), "Sources/MyLib/Auth.swift\n"); const result = parseSwiftProject(dir); expect(result.symbols.map((s) => s.name)).not.toContain("AuthClient"); }); @@ -184,7 +184,7 @@ describe("parseSwiftProject — sdk-parse-ignore", () => { mkdirSync(testDir, { recursive: true }); writeFileSync(join(dir, "Client.swift"), "public class SupabaseClient {}\n"); writeFileSync(join(testDir, "ClientTests.swift"), "public class SupabaseClientTests {}\n"); - writeFileSync(join(dir, "sdk-parse-ignore"), "Tests/\n"); + writeFileSync(join(dir, ".sdk-parse-ignore"), "Tests/\n"); const result = parseSwiftProject(dir); const names = result.symbols.map((s) => s.name); expect(names).toContain("SupabaseClient"); diff --git a/scripts/capability-matrix/test/ts-parser.test.ts b/scripts/capability-matrix/test/ts-parser.test.ts index 0d36fb8..366556b 100644 --- a/scripts/capability-matrix/test/ts-parser.test.ts +++ b/scripts/capability-matrix/test/ts-parser.test.ts @@ -122,7 +122,7 @@ describe("parseTypeScriptProject — sdk-parse-ignore", () => { cpSync(FIXTURE, dir, { recursive: true }); // The fixture has src/index.ts which exports AuthClient. // Ignore the entire src/ directory. - writeFileSync(join(dir, "sdk-parse-ignore"), "src/\n"); + writeFileSync(join(dir, ".sdk-parse-ignore"), "src/\n"); const result = parseTypeScriptProject(dir); expect(result.symbols).toHaveLength(0); }); From 52f969e88d5500220a6a9aedcbf16c8bc3d3ba06 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Tue, 23 Jun 2026 07:41:51 -0300 Subject: [PATCH 9/9] test: use .sdk-parse-ignore in normalize-griffe test The filename was renamed in #37 after this branch was cut. Update normalize-griffe.test.ts to write .sdk-parse-ignore so the CI merge commit (which picks up the rename from main) finds the file. --- .../test/normalize-griffe.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/scripts/capability-matrix/test/normalize-griffe.test.ts b/scripts/capability-matrix/test/normalize-griffe.test.ts index c95fa34..1a0a5a6 100644 --- a/scripts/capability-matrix/test/normalize-griffe.test.ts +++ b/scripts/capability-matrix/test/normalize-griffe.test.ts @@ -256,10 +256,10 @@ describe("normalizeGriffe — file inheritance", () => { }); }); -describe("normalizeGriffe — sdk-parse-ignore", () => { - it("filters out symbols whose file matches sdk-parse-ignore", () => { +describe("normalizeGriffe — .sdk-parse-ignore", () => { + it("filters out symbols whose file matches .sdk-parse-ignore", () => { const root = mkdtempSync(join(tmpdir(), "griffe-test-")); - writeFileSync(join(root, "sdk-parse-ignore"), "tests/\n"); + writeFileSync(join(root, ".sdk-parse-ignore"), "tests/\n"); const input: GriffeOutput = { mypkg: { @@ -274,9 +274,9 @@ describe("normalizeGriffe — sdk-parse-ignore", () => { expect(symbols).toHaveLength(0); }); - it("does not filter symbols whose file does not match sdk-parse-ignore", () => { + it("does not filter symbols whose file does not match .sdk-parse-ignore", () => { const root = mkdtempSync(join(tmpdir(), "griffe-test-")); - writeFileSync(join(root, "sdk-parse-ignore"), "src/tests/\n"); + writeFileSync(join(root, ".sdk-parse-ignore"), "src/tests/\n"); const input: GriffeOutput = { mypkg: { @@ -291,9 +291,9 @@ describe("normalizeGriffe — sdk-parse-ignore", () => { expect(symbols).toHaveLength(1); }); - it("works with no sdk-parse-ignore file present", () => { + it("works with no .sdk-parse-ignore file present", () => { const root = mkdtempSync(join(tmpdir(), "griffe-test-no-ignore-")); - // no sdk-parse-ignore file written + // no .sdk-parse-ignore file written const input = pkg(join(root, "src/client.py"), { MyClient: { kind: "class", members: {} },