diff --git a/.github/workflows/validate-sdk-compliance.yml b/.github/workflows/validate-sdk-compliance.yml index b6210f3..50b07d4 100644 --- a/.github/workflows/validate-sdk-compliance.yml +++ b/.github/workflows/validate-sdk-compliance.yml @@ -8,13 +8,21 @@ on: type: string default: sdk-compliance.yaml language: - description: SDK language for public API check — "swift", "javascript", or "dart" + description: SDK language for public API check — "swift", "javascript", "python", or "dart" type: string required: true sdk-ref: 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: @@ -84,6 +92,66 @@ 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: | + 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 + 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) + 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: | + 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 + 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) + 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 + # The Dart parser is a small package:analyzer tool run directly with # `dart run`. It parses syntactically, so the target packages need no # `pub get` — only the Dart SDK and the tool's own dependencies. @@ -140,23 +208,23 @@ jobs: working-directory: _sdk-spec/scripts/capability-matrix - name: Resolve parse command - if: inputs.language != 'swift' && inputs.language != 'dart' + if: inputs.language != 'python' && inputs.language != 'swift' && inputs.language != 'dart' id: resolve run: | case "${{ inputs.language }}" in javascript) echo "cmd=parse-ts" >> "$GITHUB_OUTPUT" ;; - *) echo "::error::Unsupported language '${{ inputs.language }}'. Supported values: swift, javascript, dart"; exit 1 ;; + *) echo "::error::Unsupported language '${{ inputs.language }}'. Supported values: swift, dart, python, javascript"; exit 1 ;; esac - name: Parse PR branch - if: inputs.language != 'swift' && inputs.language != 'dart' + if: inputs.language != 'python' && inputs.language != 'swift' && inputs.language != 'dart' 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 != 'swift' && inputs.language != 'dart' + if: inputs.language != 'python' && inputs.language != 'swift' && inputs.language != 'dart' run: | npm run --silent ${{ steps.resolve.outputs.cmd }} -- "$GITHUB_WORKSPACE/_sdk-base" \ > "$GITHUB_WORKSPACE/base-symbols.json" diff --git a/scripts/capability-matrix/package.json b/scripts/capability-matrix/package.json index 84ae0c9..8eb0dbb 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", "normalize-symbolgraph": "tsx src/normalize-symbolgraph-cli.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 new file mode 100644 index 0000000..6fb47a6 --- /dev/null +++ b/scripts/capability-matrix/src/normalize-griffe.ts @@ -0,0 +1,75 @@ +import { basename, 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) : basename(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..1a0a5a6 --- /dev/null +++ b/scripts/capability-matrix/test/normalize-griffe.test.ts @@ -0,0 +1,304 @@ +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" }); + }); + + 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", () => { + 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"), "tests/\n"); + + const input: GriffeOutput = { + mypkg: { + kind: "module", + filepath: join(root, "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); + }); +}); diff --git a/scripts/capability-matrix/test/parse-ignore.test.ts b/scripts/capability-matrix/test/parse-ignore.test.ts index 575234f..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; }