Skip to content

Commit ff4d21b

Browse files
iscai-msftCopilot
andcommitted
feat(spector): derive paging surface check from @list; split surface-doc tests
Add the core `@list` decorator to the surface-check derivation registry so a `@surfaceDoc`-annotated list operation yields a deterministic `paging` check. Split the single surface-doc test into focused per-category tests (naming, access, hierarchy, paging, multi-decorator, and the prose-only AI fallback) plus manifest-formatting tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c6baef8 commit ff4d21b

3 files changed

Lines changed: 131 additions & 67 deletions

File tree

packages/spector/docs/decorators.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ Derived checks (matched by decorator name + namespace, no dependency on the clie
3737
| `@access` (`Azure.ClientGenerator.Core`) | `access` |
3838
| `@clientLocation` (`Azure.ClientGenerator.Core`) | `client-location` |
3939
| `@hierarchyBuilding` (`Azure.ClientGenerator.Core.Legacy`) | `hierarchy` |
40+
| `@list` (`TypeSpec`) | `paging` |
4041
| _(none recognized)_ | AI-verified prose |
4142

4243
Usage:

packages/spector/src/lib/decorators.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -417,10 +417,11 @@ export function getSurfaceKind(target: SurfaceDocTarget): string | undefined {
417417
}
418418

419419
/**
420-
* A recognized client decorator that carries a machine-checkable surface
421-
* assertion. Matched by decorator name + declaring namespace so spector does
422-
* not need to depend on the client-generator package — it only recognizes the
423-
* decorators if a spec applies them.
420+
* A recognized decorator that carries a machine-checkable surface assertion —
421+
* either a core paging/typing decorator or a client-generator decorator.
422+
* Matched by decorator name + declaring namespace so spector does not need to
423+
* depend on the client-generator package — it only recognizes the decorators if
424+
* a spec applies them.
424425
*/
425426
interface KnownDecorator {
426427
name: `@${string}`;
@@ -430,8 +431,17 @@ interface KnownDecorator {
430431

431432
const CLIENT_GENERATOR_CORE = "Azure.ClientGenerator.Core";
432433
const CLIENT_GENERATOR_LEGACY = "Azure.ClientGenerator.Core.Legacy";
434+
const TYPESPEC_CORE = "TypeSpec";
433435

434436
const KNOWN_DECORATORS: KnownDecorator[] = [
437+
{
438+
// @list — the operation surfaces a paginated iterator on the client
439+
name: "@list",
440+
namespace: TYPESPEC_CORE,
441+
derive: () => {
442+
return { category: "paging" };
443+
},
444+
},
435445
{
436446
// @clientName("RenamedForClients")
437447
name: "@clientName",
Lines changed: 116 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { resolvePath } from "@typespec/compiler";
22
import { createTester, mockFile } from "@typespec/compiler/testing";
3-
import { describe, expect, it } from "vitest";
3+
import { beforeEach, describe, expect, it } from "vitest";
44
import { createSurfaceChecksManifest } from "../src/coverage/surface-checks-manifest.js";
5-
import { listSurfaceDocs } from "../src/lib/decorators.js";
5+
import { listSurfaceDocs, type SurfaceDoc } from "../src/lib/decorators.js";
66

77
// A minimal stand-in for the client-generator decorators, declared under the
88
// same namespaces so derivation matches them by name+namespace without spector
9-
// having to depend on the real client-generator package.
9+
// having to depend on the (separate-repo) client-generator package. Core
10+
// decorators like `@list` are real and need no stub.
1011
const clientGeneratorTsp = `
1112
import "./client-generator.js";
1213
using TypeSpec.Reflection;
@@ -52,81 +53,133 @@ const Tester = createTester(resolvePath(import.meta.dirname, ".."), {
5253
.import("./client-generator.tsp")
5354
.using("Spector");
5455

55-
const spec = `
56-
@Azure.ClientGenerator.Core.clientName("ClientExtensibleEnum")
57-
@surfaceDoc("Exposed to clients as ClientExtensibleEnum, not its service name.")
58-
enum ServerExtensibleEnum {
59-
value1,
60-
value2,
61-
}
62-
63-
@Azure.ClientGenerator.Core.access(Azure.ClientGenerator.Core.Access.internal)
64-
@Azure.ClientGenerator.Core.clientName("WidgetInternal")
65-
@surfaceDoc("Hidden from the public surface and renamed to WidgetInternal for clients.")
66-
model Widget {
67-
id: string;
68-
}
69-
70-
model Animal {}
56+
async function compileSurfaceDocs(code: string): Promise<SurfaceDoc[]> {
57+
const { program } = await Tester.compile(code);
58+
return listSurfaceDocs(program);
59+
}
60+
61+
describe("listSurfaceDocs derivation", () => {
62+
it("keeps the authored prose on the surface doc", async () => {
63+
const [doc] = await compileSurfaceDocs(`
64+
@surfaceDoc("Surfaces a lazy paged iterator on the client, not a raw response.")
65+
@list
66+
op listItems(): { @pageItems items: string[]; @nextLink next?: url };
67+
`);
68+
expect(doc.doc).toBe("Surfaces a lazy paged iterator on the client, not a raw response.");
69+
});
7170

72-
@Azure.ClientGenerator.Core.Legacy.hierarchyBuilding(Pet)
73-
@surfaceDoc("Dog is surfaced as a subtype of Pet, not Animal.")
74-
model Dog extends Animal {
75-
kind: string;
76-
}
71+
it("derives a naming check from @clientName", async () => {
72+
const [doc] = await compileSurfaceDocs(`
73+
@Azure.ClientGenerator.Core.clientName("ClientExtensibleEnum")
74+
@surfaceDoc("Exposed to clients as ClientExtensibleEnum, not its service name.")
75+
enum ServerExtensibleEnum {
76+
value1,
77+
value2,
78+
}
79+
`);
80+
expect(doc.checks).toEqual([
81+
{ category: "naming", expected: "ClientExtensibleEnum", kind: "enum" },
82+
]);
83+
});
7784

78-
model Pet {}
85+
it("derives an access check from @access", async () => {
86+
const [doc] = await compileSurfaceDocs(`
87+
@Azure.ClientGenerator.Core.access(Azure.ClientGenerator.Core.Access.internal)
88+
@surfaceDoc("Hidden from the public client surface.")
89+
model Widget {
90+
id: string;
91+
}
92+
`);
93+
expect(doc.checks).toEqual([{ category: "access", internal: true }]);
94+
});
7995

80-
@surfaceDoc("This operation surfaces a lazy paged iterator, not a raw response.")
81-
op listItems(): string[];
82-
`;
96+
it("derives a hierarchy check from @hierarchyBuilding", async () => {
97+
const [doc] = await compileSurfaceDocs(`
98+
model Animal {}
99+
model Pet {}
100+
101+
@Azure.ClientGenerator.Core.Legacy.hierarchyBuilding(Pet)
102+
@surfaceDoc("Dog is surfaced as a subtype of Pet, not Animal.")
103+
model Dog extends Animal {
104+
kind: string;
105+
}
106+
`);
107+
expect(doc.checks).toEqual([{ category: "hierarchy", expectedBase: "Pet" }]);
108+
});
83109

84-
describe("@surfaceDoc / listSurfaceDocs (natural language + derivation)", () => {
85-
it("keeps the prose and derives checks from client decorators", async () => {
86-
const { program } = await Tester.compile(spec);
87-
const docs = listSurfaceDocs(program);
110+
it("derives a paging check from the core @list decorator", async () => {
111+
const [doc] = await compileSurfaceDocs(`
112+
@surfaceDoc("Surfaces a lazy paged iterator on the client.")
113+
@list
114+
op listItems(): { @pageItems items: string[]; @nextLink next?: url };
115+
`);
116+
expect(doc.checks).toEqual([{ category: "paging" }]);
117+
});
88118

89-
expect(docs.map((d) => d.name).sort()).toEqual([
90-
"Dog",
91-
"ServerExtensibleEnum",
92-
"Widget",
93-
"listItems",
94-
]);
119+
it("derives one check per client decorator when several are applied", async () => {
120+
const [doc] = await compileSurfaceDocs(`
121+
@Azure.ClientGenerator.Core.access(Azure.ClientGenerator.Core.Access.internal)
122+
@Azure.ClientGenerator.Core.clientName("WidgetInternal")
123+
@surfaceDoc("Hidden from the public surface and renamed to WidgetInternal for clients.")
124+
model Widget {
125+
id: string;
126+
}
127+
`);
128+
expect(doc.checks.map((c) => c.category).sort()).toEqual(["access", "naming"]);
129+
});
95130

96-
const widget = docs.find((d) => d.name === "Widget")!;
97-
expect(widget.doc).toContain("Hidden from the public surface");
98-
// Two client decorators -> two derived checks.
99-
expect(widget.checks.map((c) => c.category).sort()).toEqual(["access", "naming"]);
131+
it("derives no checks for prose with no recognized decorator (AI fallback)", async () => {
132+
const [doc] = await compileSurfaceDocs(`
133+
@surfaceDoc("The response body is surfaced as a strongly typed model.")
134+
op getItem(): { id: string };
135+
`);
136+
expect(doc.checks).toHaveLength(0);
137+
});
138+
});
100139

101-
// Prose with no recognized client decorator -> no derived checks (AI fallback).
102-
const listItems = docs.find((d) => d.name === "listItems")!;
103-
expect(listItems.checks).toHaveLength(0);
104-
expect(listItems.doc).toContain("lazy paged iterator");
140+
describe("surface-checks.json manifest", () => {
141+
let byId: Record<string, ReturnType<typeof createSurfaceChecksManifest>["items"][number]>;
142+
143+
beforeEach(async () => {
144+
const docs = await compileSurfaceDocs(`
145+
@Azure.ClientGenerator.Core.clientName("ClientExtensibleEnum")
146+
@surfaceDoc("Exposed to clients as ClientExtensibleEnum, not its service name.")
147+
enum ServerExtensibleEnum {
148+
value1,
149+
value2,
150+
}
151+
152+
model Pet {}
153+
154+
@Azure.ClientGenerator.Core.Legacy.hierarchyBuilding(Pet)
155+
@surfaceDoc("Dog is surfaced as a subtype of Pet.")
156+
model Dog {
157+
kind: string;
158+
}
159+
160+
@surfaceDoc("Surfaces a lazy paged iterator on the client, not a raw response.")
161+
@list
162+
op listItems(): { @pageItems items: string[]; @nextLink next?: url };
163+
`);
164+
const manifest = createSurfaceChecksManifest(".", "1.0.0", "abc123", docs);
165+
byId = Object.fromEntries(manifest.items.map((i) => [i.id, i]));
105166
});
106167

107-
it("formats a verify.py-compatible manifest, prose carried on every item", async () => {
108-
const { program } = await Tester.compile(spec);
109-
const manifest = createSurfaceChecksManifest(".", "1.0.0", "abc123", listSurfaceDocs(program));
168+
it("carries the authored prose on every item", () => {
169+
expect(byId["ServerExtensibleEnum_naming"].doc).toContain("Exposed to clients");
170+
expect(byId["listItems_paging"].doc).toContain("lazy paged iterator");
171+
});
110172

111-
const byId = Object.fromEntries(manifest.items.map((i) => [i.id, i]));
173+
it("emits camelCase check fields as snake_case for verify.py", () => {
174+
expect(byId["Dog_hierarchy"].expected_base).toBe("Pet");
175+
});
112176

177+
it("routes each derived check to its category", () => {
178+
expect(byId["listItems_paging"]).toMatchObject({ category: "paging", target: "listItems" });
113179
expect(byId["ServerExtensibleEnum_naming"]).toMatchObject({
114180
category: "naming",
115-
target: "ServerExtensibleEnum",
116181
expected: "ClientExtensibleEnum",
117182
kind: "enum",
118183
});
119-
expect(byId["ServerExtensibleEnum_naming"].doc).toContain("Exposed to clients");
120-
121-
expect(byId["Widget_access"]).toMatchObject({ category: "access", internal: true });
122-
expect(byId["Widget_naming"]).toMatchObject({ expected: "WidgetInternal", kind: "model" });
123-
124-
// camelCase check fields are emitted snake_case for verify.py.
125-
expect(byId["Dog_hierarchy"].expected_base).toBe("Pet");
126-
127-
// Prose-only element becomes a single AI-routed item.
128-
expect(byId["listItems_unspecified"]).toMatchObject({ category: "unspecified" });
129-
expect(byId["listItems_unspecified"].doc).toContain("lazy paged iterator");
130-
expect(byId["listItems_unspecified"].expected).toBeUndefined();
131184
});
132185
});

0 commit comments

Comments
 (0)