Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 16 additions & 9 deletions scripts/capability-matrix/src/api-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { ParsedSymbol } from "./ts-parser.js";

export interface CheckResult {
newSymbols: string[];
uncoveredSymbols: string[];
uncoveredSymbols: ParsedSymbol[];
removedRegisteredSymbols: Array<{ symbol: string; featureId: string }>;
}

Expand All @@ -16,12 +16,11 @@ export function checkNewSymbols(
const baseNames = new Set(baseSymbols.map((s) => s.name));
const prNames = new Set(prSymbols.map((s) => s.name));

const newSymbols = prSymbols
.filter((s) => !baseNames.has(s.name))
.map((s) => s.name);
const newSymbolObjs = prSymbols.filter((s) => !baseNames.has(s.name));
const newSymbols = newSymbolObjs.map((s) => s.name);

const symbolIndex = buildSymbolIndex(compliance);
const uncoveredSymbols = newSymbols.filter((sym) => !symbolIndex.has(sym));
const uncoveredSymbols = newSymbolObjs.filter((s) => !symbolIndex.has(s.name));

const removedRegisteredSymbols = baseSymbols
.filter((s) => !prNames.has(s.name) && symbolIndex.has(s.name))
Expand All @@ -31,24 +30,32 @@ export function checkNewSymbols(
}

export function formatErrorMessage(
uncoveredSymbols: string[],
uncoveredSymbols: ParsedSymbol[],
sdkName: string,
): string {
const lines: string[] = [
"❌ Capability matrix check failed",
"New public API detected that is not in the capability matrix:",
...uncoveredSymbols.map((s) => ` - ${s} (${sdkName})`),
];
for (const s of uncoveredSymbols) {
lines.push(` - ${s.name} (${sdkName})`);
if (s.file) {
const location = s.line !== undefined ? `${s.file}:${s.line}` : s.file;
lines.push(` defined at: ${location}`);
}
}
lines.push(
"",
"Register each symbol in sdk-compliance.yaml under the appropriate feature:",
"",
" auth.my_feature:",
" status: implemented",
" symbols:",
` - ${uncoveredSymbols[0] ?? "ClassName.methodName"}`,
` - ${uncoveredSymbols[0]?.name ?? "ClassName.methodName"}`,
"",
"If the feature does not exist in the matrix yet, add it there first:",
" https://github.com/supabase/sdk/blob/main/CONTRIBUTING.md",
];
);
return lines.join("\n");
}

Expand Down
12 changes: 7 additions & 5 deletions scripts/capability-matrix/src/normalize-griffe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface GriffeNode {
filepath?: string;
labels?: string[] | null;
members?: Record<string, GriffeNode>;
lineno?: number;
}

export type GriffeOutput = Record<string, GriffeNode>;
Expand Down Expand Up @@ -47,7 +48,7 @@ function walkNode(
if (name.startsWith("_")) return;

if (node.kind === "class") {
emit(symbols, ig, { name: qual(classStack, name), kind: "class", file });
emit(symbols, ig, { name: qual(classStack, name), kind: "class", file }, node.lineno);
for (const [childName, child] of Object.entries(node.members ?? {})) {
walkNode(childName, child, file, [...classStack, name], symbols, ig, projectRoot);
}
Expand All @@ -56,20 +57,21 @@ function walkNode(

if (node.kind === "function") {
const kind = classStack.length > 0 ? ("method" as const) : ("function" as const);
emit(symbols, ig, { name: qual(classStack, name), kind, file });
emit(symbols, ig, { name: qual(classStack, name), kind, file }, node.lineno);
return;
}

if (node.kind === "attribute" && node.labels?.includes("property")) {
emit(symbols, ig, { name: qual(classStack, name), kind: "property", file });
emit(symbols, ig, { name: qual(classStack, name), kind: "property", file }, node.lineno);
}
}

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 {
function emit(symbols: ParsedSymbol[], ig: Ignore | null, sym: ParsedSymbol, lineno?: number): void {
if (ig && sym.file && ig.ignores(sym.file)) return;
symbols.push(sym);
// Griffe inherits Python ast.lineno which is already 1-based — no +1 needed (unlike TS/Swift).
symbols.push(lineno !== undefined ? { ...sym, line: lineno } : sym);
}
10 changes: 7 additions & 3 deletions scripts/capability-matrix/src/normalize-symbolgraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export type { ParsedSymbol, ParseResult };
export interface SymbolGraphSymbol {
kind: { identifier: string };
pathComponents: string[];
location?: { uri: string };
location?: { uri: string; position?: { line: number; character: number } };
}

// Kind identifiers that map to ParsedSymbol kinds.
Expand Down Expand Up @@ -41,11 +41,15 @@ export function normalizeSymbolGraph(
const kind = KIND_MAP[sym.kind.identifier];
if (kind === undefined) continue;

result.push({
const parsed: ParsedSymbol = {
name: qualifiedName(sym.pathComponents),
kind,
file: resolveFile(sym.location?.uri, sdkRoot),
});
};
if (sym.location?.position !== undefined) {
parsed.line = sym.location.position.line + 1;
}
result.push(parsed);
}

return { symbols: result };
Expand Down
18 changes: 13 additions & 5 deletions scripts/capability-matrix/src/ts-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export interface ParsedSymbol {
name: string;
kind: "class" | "method" | "property" | "function" | "variable";
file: string;
line?: number;
}

export interface ParseResult {
Expand Down Expand Up @@ -65,6 +66,7 @@ function extractClassMembers(
className: string,
node: ts.ClassDeclaration,
relPath: string,
sf: ts.SourceFile,
out: ParsedSymbol[],
): void {
for (const member of node.members) {
Expand All @@ -81,7 +83,8 @@ function extractClassMembers(
? "method"
: "property";

out.push({ name: `${className}.${name}`, kind, file: relPath });
const line = sf.getLineAndCharacterOfPosition(member.getStart(sf)).line + 1;
out.push({ name: `${className}.${name}`, kind, file: relPath, line });
}
}

Expand All @@ -100,16 +103,21 @@ export function extractFromSource(
if (ts.isClassDeclaration(stmt) && isExported(stmt)) {
const className = stmt.name?.text;
if (className) {
symbols.push({ name: className, kind: "class", file: relPath });
extractClassMembers(className, stmt, relPath, symbols);
const line = sf.getLineAndCharacterOfPosition(stmt.getStart(sf)).line + 1;
symbols.push({ name: className, kind: "class", file: relPath, line });
extractClassMembers(className, stmt, relPath, sf, symbols);
}
} else if (ts.isFunctionDeclaration(stmt) && isExported(stmt)) {
const name = stmt.name?.text;
if (name) symbols.push({ name, kind: "function", file: relPath });
if (name) {
const line = sf.getLineAndCharacterOfPosition(stmt.getStart(sf)).line + 1;
symbols.push({ name, kind: "function", file: relPath, line });
}
} else if (ts.isVariableStatement(stmt) && isExported(stmt)) {
for (const decl of stmt.declarationList.declarations) {
if (ts.isIdentifier(decl.name)) {
symbols.push({ name: decl.name.text, kind: "variable", file: relPath });
const line = sf.getLineAndCharacterOfPosition(decl.getStart(sf)).line + 1;
symbols.push({ name: decl.name.text, kind: "variable", file: relPath, line });
}
}
}
Expand Down
42 changes: 36 additions & 6 deletions scripts/capability-matrix/test/api-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { describe, it, expect } from "vitest";
import { checkNewSymbols, formatErrorMessage, formatRemovedMessage } from "../src/api-check";
import type { ParsedSymbol } from "../src/ts-parser";

function sym(name: string): ParsedSymbol {
return { name, kind: "method", file: "src/index.ts" };
function sym(name: string, line?: number): ParsedSymbol {
const s: ParsedSymbol = { name, kind: "method", file: "src/index.ts" };
if (line !== undefined) s.line = line;
return s;
}

const compliance = {
Expand Down Expand Up @@ -34,7 +36,7 @@ describe("checkNewSymbols", () => {
const pr = [sym("AuthClient.signInWithPasskey")];
const result = checkNewSymbols(base, pr, compliance);
expect(result.newSymbols).toEqual(["AuthClient.signInWithPasskey"]);
expect(result.uncoveredSymbols).toEqual(["AuthClient.signInWithPasskey"]);
expect(result.uncoveredSymbols).toEqual([sym("AuthClient.signInWithPasskey")]);
});

it("ignores symbols that exist in both base and PR", () => {
Expand All @@ -57,7 +59,15 @@ describe("checkNewSymbols", () => {
const base: ParsedSymbol[] = [];
const pr = [sym("AuthClient.foo"), sym("AuthClient.bar")];
const result = checkNewSymbols(base, pr, compliance);
expect(result.uncoveredSymbols).toEqual(["AuthClient.foo", "AuthClient.bar"]);
expect(result.uncoveredSymbols).toEqual([sym("AuthClient.foo"), sym("AuthClient.bar")]);
});

it("preserves file and line through to uncoveredSymbols", () => {
const withLocation: ParsedSymbol = { name: "AuthClient.signInWithPasskey", kind: "method", file: "src/auth.ts", line: 42 };
const result = checkNewSymbols([], [withLocation], compliance);
expect(result.uncoveredSymbols).toHaveLength(1);
expect(result.uncoveredSymbols[0].file).toBe("src/auth.ts");
expect(result.uncoveredSymbols[0].line).toBe(42);
});
});

Expand Down Expand Up @@ -98,17 +108,37 @@ describe("checkNewSymbols — removed registered symbols", () => {

describe("formatErrorMessage", () => {
it("includes all uncovered symbols in output", () => {
const msg = formatErrorMessage(["AuthClient.signInWithPasskey"], "javascript");
const msg = formatErrorMessage([sym("AuthClient.signInWithPasskey")], "javascript");
expect(msg).toContain("❌ Capability matrix check failed");
expect(msg).toContain("AuthClient.signInWithPasskey (javascript)");
expect(msg).toContain("sdk-compliance.yaml");
});

it("includes multiple uncovered symbols", () => {
const msg = formatErrorMessage(["Foo.a", "Foo.b"], "flutter");
const msg = formatErrorMessage([sym("Foo.a"), sym("Foo.b")], "flutter");
expect(msg).toContain("Foo.a (flutter)");
expect(msg).toContain("Foo.b (flutter)");
});

it("includes defined-at with file and line when both are present", () => {
const s: ParsedSymbol = { name: "SupabaseClient.signInWithPasskey", kind: "method", file: "Sources/Auth/SupabaseClient.swift", line: 142 };
const msg = formatErrorMessage([s], "swift");
expect(msg).toContain("SupabaseClient.signInWithPasskey (swift)");
expect(msg).toContain("defined at: Sources/Auth/SupabaseClient.swift:142");
});

it("includes defined-at with file only when line is absent", () => {
const s: ParsedSymbol = { name: "AuthClient.signUp", kind: "method", file: "src/auth.ts" };
const msg = formatErrorMessage([s], "javascript");
expect(msg).toContain("defined at: src/auth.ts");
expect(msg).not.toContain("defined at: src/auth.ts:");
});

it("omits defined-at when file is empty", () => {
const s: ParsedSymbol = { name: "AuthClient.signUp", kind: "method", file: "" };
const msg = formatErrorMessage([s], "javascript");
expect(msg).not.toContain("defined at:");
});
});

describe("formatRemovedMessage", () => {
Expand Down
30 changes: 30 additions & 0 deletions scripts/capability-matrix/test/normalize-griffe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,36 @@ describe("normalizeGriffe — file inheritance", () => {
});
});

describe("normalizeGriffe — line numbers", () => {
it("includes line number when node has lineno", () => {
const input = pkg("/repo/src/client.py", {
MyClient: { kind: "class", members: {}, lineno: 10 },
});
const { symbols } = normalizeGriffe(input, "/repo");
expect(symbols[0].line).toBe(10);
});

it("omits line when node has no lineno", () => {
const input = pkg("/repo/src/client.py", {
MyClient: { kind: "class", members: {} },
});
const { symbols } = normalizeGriffe(input, "/repo");
expect(symbols[0].line).toBeUndefined();
});

it("passes lineno through for methods", () => {
const input = pkg("/repo/src/client.py", {
MyClient: {
kind: "class",
members: { sign_up: { kind: "function", labels: null, lineno: 25 } },
},
});
const { symbols } = normalizeGriffe(input, "/repo");
const method = symbols.find((s) => s.name === "MyClient.sign_up");
expect(method?.line).toBe(25);
});
});

describe("normalizeGriffe — .sdk-parse-ignore", () => {
it("filters out symbols whose file matches .sdk-parse-ignore", () => {
const root = mkdtempSync(join(tmpdir(), "griffe-test-"));
Expand Down
34 changes: 29 additions & 5 deletions scripts/capability-matrix/test/normalize-symbolgraph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ function sym(
identifier: string,
pathComponents: string[],
uri?: string,
position?: { line: number; character: number },
): SymbolGraphSymbol {
return {
kind: { identifier },
pathComponents,
...(uri ? { location: { uri } } : {}),
};
if (uri) {
return { kind: { identifier }, pathComponents, location: { uri, ...(position ? { position } : {}) } };
}
return { kind: { identifier }, pathComponents };
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -163,6 +163,30 @@ describe("file path resolution", () => {
});
});

describe("line number extraction", () => {
it("extracts 1-based line from 0-based position.line", () => {
const sdkRoot = "/sdk";
const uri = `file://${sdkRoot}/Sources/Auth/AuthClient.swift`;
const { symbols } = normalizeSymbolGraph(
[sym("swift.class", ["AuthClient"], uri, { line: 141, character: 0 })],
sdkRoot,
);
expect(symbols[0].line).toBe(142);
});

it("omits line when position is absent", () => {
const sdkRoot = "/sdk";
const uri = `file://${sdkRoot}/Sources/Auth/AuthClient.swift`;
const { symbols } = normalizeSymbolGraph([sym("swift.class", ["AuthClient"], uri)], sdkRoot);
expect(symbols[0].line).toBeUndefined();
});

it("omits line when location is absent", () => {
const { symbols } = normalizeSymbolGraph([sym("swift.class", ["AuthClient"])], "/sdk");
expect(symbols[0].line).toBeUndefined();
});
});

// ---------------------------------------------------------------------------
// Multi-input
// ---------------------------------------------------------------------------
Expand Down
18 changes: 16 additions & 2 deletions scripts/capability-matrix/test/ts-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,27 @@ describe("extractFromSource", () => {
it("extracts exported functions", () => {
const source = `export function createClient(url: string): void {}`;
const symbols = extractFromSource(source, "src/index.ts");
expect(symbols).toEqual([{ name: "createClient", kind: "function", file: "src/index.ts" }]);
expect(symbols).toEqual([{ name: "createClient", kind: "function", file: "src/index.ts", line: 1 }]);
});

it("extracts exported variables", () => {
const source = `export const version = "1.0.0";`;
const symbols = extractFromSource(source, "src/index.ts");
expect(symbols).toEqual([{ name: "version", kind: "variable", file: "src/index.ts" }]);
expect(symbols).toEqual([{ name: "version", kind: "variable", file: "src/index.ts", line: 1 }]);
});

it("records line numbers for class and its members", () => {
const source = [
"export class AuthClient {",
" public signUp(email: string): void {}",
" public signIn(): void {}",
"}",
].join("\n");
const symbols = extractFromSource(source, "src/index.ts");
const byName = Object.fromEntries(symbols.map((s) => [s.name, s]));
expect(byName["AuthClient"].line).toBe(1);
expect(byName["AuthClient.signUp"].line).toBe(2);
expect(byName["AuthClient.signIn"].line).toBe(3);
});

it("skips constructor", () => {
Expand Down
Loading