diff --git a/.chronus/changes/fix-filter-visibility-extends-2026-6-22-18-35-0.md b/.chronus/changes/fix-filter-visibility-extends-2026-6-22-18-35-0.md new file mode 100644 index 00000000000..3ce8c7649f4 --- /dev/null +++ b/.chronus/changes/fix-filter-visibility-extends-2026-6-22-18-35-0.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/compiler" +--- + +`FilterVisibility` now applies the visibility filter to inherited types (via `extends`), filtering out invisible properties from base models in the inheritance chain. diff --git a/packages/compiler/src/lib/visibility.ts b/packages/compiler/src/lib/visibility.ts index dd8965ab487..1da7727b9b4 100644 --- a/packages/compiler/src/lib/visibility.ts +++ b/packages/compiler/src/lib/visibility.ts @@ -748,6 +748,14 @@ function createVisibilityFilterMutator( } } + if (model.baseModel) { + const mutatedBase = cachedMutateSubgraph(program, self, model.baseModel); + if (mutatedBase.type !== model.baseModel) { + clone.baseModel = mutatedBase.type as Model; + modified = true; + } + } + if (options.decoratorFn) { clone.decorators = clone.decorators.filter( (d) => diff --git a/packages/compiler/test/visibility.test.ts b/packages/compiler/test/visibility.test.ts index b4c6911a06c..a2092d372ef 100644 --- a/packages/compiler/test/visibility.test.ts +++ b/packages/compiler/test/visibility.test.ts @@ -1596,6 +1596,208 @@ describe("compiler: visibility core", () => { ok(!arrA.properties.has("invisible")); }); + it("applies visibility filter to base model (extends)", async () => { + const { UserCreate, program } = await Tester.compile(t.code` + model Base { + @visibility(Lifecycle.Read) id: string; + } + + model Auditable extends Base { + @visibility(Lifecycle.Read) created_at: utcDateTime; + @visibility(Lifecycle.Read) updated_at: utcDateTime; + } + + model User extends Auditable { + name: string; + } + + @test model ${t.model("UserCreate")} is FilterVisibility; + `); + + ok(UserCreate); + + // User's own property 'name' has default visibility (all), so it should be visible + ok(UserCreate.properties.has("name")); + + // The base model should be transformed (Auditable -> AuditableCreate) + const auditableCreate = UserCreate.baseModel; + ok(auditableCreate); + strictEqual(auditableCreate.name, "AuditableCreate"); + + // Auditable's read-only properties should be filtered out + ok(!auditableCreate.properties.has("created_at")); + ok(!auditableCreate.properties.has("updated_at")); + + // The base of Auditable (Base) should also be transformed + const baseCreate = auditableCreate.baseModel; + ok(baseCreate); + strictEqual(baseCreate.name, "BaseCreate"); + + // Base's read-only property should be filtered out + ok(!baseCreate.properties.has("id")); + }); + + it("does not transform base model when all properties are visible", async () => { + const { ChildFiltered, program } = await Tester.compile(t.code` + model Parent { + name: string; + } + + model Child extends Parent { + @visibility(Lifecycle.Read) id: string; + description: string; + } + + @test model ${t.model("ChildFiltered")} is FilterVisibility; + `); + + ok(ChildFiltered); + + // Child's 'description' is visible (default visibility), 'id' is not + ok(ChildFiltered.properties.has("description")); + ok(!ChildFiltered.properties.has("id")); + + // Parent has no properties with restricted visibility, so it should remain unchanged + const base = ChildFiltered.baseModel; + ok(base); + strictEqual(base.name, "Parent"); + ok(base.properties.has("name")); + }); + + it("applies visibility filter across multiple layers where base is unchanged", async () => { + const { Result, program } = await Tester.compile(t.code` + model GrandParent { + gp_all: string; + } + + model Parent extends GrandParent { + parent_all: string; + } + + model Child extends Parent { + @visibility(Lifecycle.Read) child_read: string; + child_all: string; + } + + @test model ${t.model("Result")} is FilterVisibility; + `); + + ok(Result); + + // Child's properties + ok(Result.properties.has("child_all")); + ok(!Result.properties.has("child_read")); + + // Neither Parent nor GrandParent have visibility-restricted properties, + // so the entire base chain should remain unchanged + const parentBase = Result.baseModel; + ok(parentBase); + strictEqual(parentBase.name, "Parent"); + ok(parentBase.properties.has("parent_all")); + + const gpBase = parentBase.baseModel; + ok(gpBase); + strictEqual(gpBase.name, "GrandParent"); + ok(gpBase.properties.has("gp_all")); + }); + + it("applies visibility filter across multiple inheritance layers", async () => { + const { Result, program } = await Tester.compile(t.code` + model GrandParent { + @visibility(Lifecycle.Read) gp_read: string; + gp_all: string; + } + + model Parent extends GrandParent { + parent_all: string; + } + + model Child extends Parent { + @visibility(Lifecycle.Read) child_read: string; + child_all: string; + } + + @test model ${t.model("Result")} is FilterVisibility; + `); + + ok(Result); + + // Child's properties + ok(Result.properties.has("child_all")); + ok(!Result.properties.has("child_read")); + + // Parent is transformed because its base (GrandParent) is transformed + const parentFiltered = Result.baseModel; + ok(parentFiltered); + strictEqual(parentFiltered.name, "ParentCreate"); + ok(parentFiltered.properties.has("parent_all")); + + // GrandParent needs transformation (has read-only property) + const gpFiltered = parentFiltered.baseModel; + ok(gpFiltered); + strictEqual(gpFiltered.name, "GrandParentCreate"); + ok(gpFiltered.properties.has("gp_all")); + ok(!gpFiltered.properties.has("gp_read")); + }); + + it("applies visibility filter to base model via applyVisibilityFilter", async () => { + const { User, program } = await Tester.compile(t.code` + model Base { + @visibility(Lifecycle.Read) id: string; + } + + model ${t.model("User")} extends Base { + name: string; + } + `); + + const fnContext = { program } satisfies Pick; + const lifecycle = getLifecycleVisibilityEnum(program); + const filtered = applyVisibilityFilter( + fnContext, + User, + anyFilter(lifecycle.members.get("Create")!), + "{name}Create", + ); + + ok(filtered); + strictEqual(filtered.name, "UserCreate"); + ok(filtered.properties.has("name")); + + // Base model should be transformed + const baseFiltered = filtered.baseModel; + ok(baseFiltered); + strictEqual(baseFiltered.name, "BaseCreate"); + ok(!baseFiltered.properties.has("id")); + }); + + it("does not produce infinite regress for self-referential models", async () => { + const { Result } = await Tester.compile(t.code` + model LinkedNode { + value: string; + @visibility(Lifecycle.Read) id: string; + next?: LinkedNode; + } + + model Child extends LinkedNode { + extra: string; + } + + @test model ${t.model("Result")} is FilterVisibility; + `); + + ok(Result); + ok(Result.properties.has("extra")); + + // Base model (LinkedNode) should be filtered and renamed + const base = Result.baseModel; + ok(base); + strictEqual(base.name, "LinkedNodeCreate"); + ok(base.properties.has("value")); + ok(base.properties.has("next")); + ok(!base.properties.has("id")); + }); + it("does not duplicate encodedName metadata", async () => { const diagnostics = await Tester.diagnose(` model SomeModel {