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
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
import { resolveProviderNamespace } from "@azure-tools/typespec-azure-resource-manager";
import {
createTCGCContext,
isInScope,
type TCGCContext,
} from "@azure-tools/typespec-client-generator-core";
import {
createRule,
getDiscriminator,
Expand All @@ -13,6 +8,7 @@ import {
resolveEncodedName,
type Model,
type ModelProperty,
type Program,
type Type,
} from "@typespec/compiler";
import { getAllHttpServices, type HttpOperation, type HttpOperationResponse } from "@typespec/http";
Expand All @@ -28,23 +24,19 @@ export const consistentPatchPropertiesRule = createRule({
create(context) {
return {
root: () => {
const emitterContext = createTCGCContext(context.program, "@azure-tools/typespec-autorest");
const [services] = getAllHttpServices(context.program);
for (const service of services) {
if (resolveProviderNamespace(context.program, service.namespace) === undefined) {
continue;
}

for (const httpOperation of service.operations) {
if (
httpOperation.verb !== "patch" ||
!isInScope(emitterContext, httpOperation.operation)
) {
if (httpOperation.verb !== "patch") {
continue;
}

const patchBody = getObjectModel(httpOperation.parameters.body?.type);
const resourceType = getResourceType(emitterContext, httpOperation, service.operations);
const resourceType = getResourceType(httpOperation, service.operations);
if (patchBody === undefined) {
continue;
}
Expand All @@ -55,10 +47,10 @@ export const consistentPatchPropertiesRule = createRule({
const resourceModel = getObjectModel(resourceType);
const invalidProperties =
resourceModel === undefined
? [...getPayloadProperties(emitterContext, patchBody)].map(
? [...getPayloadProperties(context.program, patchBody)].map(
([jsonName, property]) => ({ path: [jsonName], target: property.target }),
)
: findInvalidPatchProperties(emitterContext, patchBody, resourceModel);
: findInvalidPatchProperties(context.program, patchBody, resourceModel);

for (const invalidProperty of invalidProperties) {
context.reportDiagnostic({
Expand All @@ -76,15 +68,11 @@ export const consistentPatchPropertiesRule = createRule({
});

function getResourceType(
emitterContext: TCGCContext,
patchOperation: HttpOperation,
operations: HttpOperation[],
): Type | undefined {
const getOperation = operations.find(
(operation) =>
operation.verb === "get" &&
operation.path === patchOperation.path &&
isInScope(emitterContext, operation.operation),
(operation) => operation.verb === "get" && operation.path === patchOperation.path,
);

return (
Expand Down Expand Up @@ -112,7 +100,7 @@ function getResponseBodyType(
}

function findInvalidPatchProperties(
emitterContext: TCGCContext,
program: Program,
patchModel: Model,
resourceModel: Model,
path: string[] = [],
Expand All @@ -129,15 +117,15 @@ function findInvalidPatchProperties(
}

const invalidProperties: Array<{ path: string[]; target: Model | ModelProperty }> = [];
const resourceProperties = getPayloadProperties(emitterContext, resourceModel);
const resourceProperties = getPayloadProperties(program, resourceModel);

for (const [jsonName, patchProperty] of getPayloadProperties(emitterContext, patchModel)) {
for (const [jsonName, patchProperty] of getPayloadProperties(program, patchModel)) {
const currentPath = [...path, jsonName];
const resourceProperty = resourceProperties.get(jsonName);

if (resourceProperty === undefined) {
invalidProperties.push(
...collectPropertyPaths(emitterContext, patchProperty, currentPath, new Set()),
...collectPropertyPaths(program, patchProperty, currentPath, new Set()),
);
continue;
}
Expand All @@ -148,7 +136,7 @@ function findInvalidPatchProperties(
if (resourcePropertyModel !== undefined) {
invalidProperties.push(
...findInvalidPatchProperties(
emitterContext,
program,
patchPropertyModel,
resourcePropertyModel,
currentPath,
Expand All @@ -157,7 +145,7 @@ function findInvalidPatchProperties(
);
} else {
invalidProperties.push(
...collectNestedPropertyPaths(emitterContext, patchPropertyModel, currentPath),
...collectNestedPropertyPaths(program, patchPropertyModel, currentPath),
);
}
}
Expand All @@ -168,18 +156,18 @@ function findInvalidPatchProperties(
}

function collectNestedPropertyPaths(
emitterContext: TCGCContext,
program: Program,
model: Model,
path: string[],
): Array<{ path: string[]; target: Model | ModelProperty }> {
const visited = new Set([model]);
return [...getPayloadProperties(emitterContext, model)].flatMap(([jsonName, property]) =>
collectPropertyPaths(emitterContext, property, [...path, jsonName], visited),
return [...getPayloadProperties(program, model)].flatMap(([jsonName, property]) =>
collectPropertyPaths(program, property, [...path, jsonName], visited),
);
}

function collectPropertyPaths(
emitterContext: TCGCContext,
program: Program,
property: PayloadProperty,
path: string[],
visited: Set<Model>,
Expand All @@ -194,13 +182,13 @@ function collectPropertyPaths(
}
visited.add(propertyModel);

const nestedProperties = getPayloadProperties(emitterContext, propertyModel);
const nestedProperties = getPayloadProperties(program, propertyModel);
if (nestedProperties.size === 0) {
return [{ path, target: property.target }];
}

const invalidProperties = [...nestedProperties].flatMap(([jsonName, nestedProperty]) =>
collectPropertyPaths(emitterContext, nestedProperty, [...path, jsonName], visited),
collectPropertyPaths(program, nestedProperty, [...path, jsonName], visited),
);
visited.delete(propertyModel);
return invalidProperties;
Expand All @@ -211,25 +199,18 @@ interface PayloadProperty {
type?: Type;
}

function getPayloadProperties(
emitterContext: TCGCContext,
model: Model,
): Map<string, PayloadProperty> {
function getPayloadProperties(program: Program, model: Model): Map<string, PayloadProperty> {
const properties = new Map<string, PayloadProperty>();

for (let current: Model | undefined = model; current !== undefined; current = current.baseModel) {
for (const property of current.properties.values()) {
const jsonName = resolveEncodedName(emitterContext.program, property, "application/json");
if (
!properties.has(jsonName) &&
!isNeverType(property.type) &&
isInScope(emitterContext, property)
) {
const jsonName = resolveEncodedName(program, property, "application/json");
if (!properties.has(jsonName) && !isNeverType(property.type)) {
properties.set(jsonName, { target: property, type: property.type });
}
}

const discriminator = getDiscriminator(emitterContext.program, current);
const discriminator = getDiscriminator(program, current);
if (
discriminator !== undefined &&
!current.properties.has(discriminator.propertyName) &&
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,64 @@
{
"schemaVersion": 1,
"schemaVersion": 2,
"rule": "ConsistentPatchProperties",
"specsCommit": "f6b53f105b95da05276530a0754a1c71b4f16397",
"generatedAt": "2026-09-04T14:39:11.685Z",
"generatedAt": "2026-09-09T09:24:28.545Z",
"completedAt": "2026-09-09T17:28:59.7719922+08:00",
"sourceBaseCommit": "29c4a87b0799092be0ede854ffdf23a0a9648795",
"sourceBranch": "feature/lintdiff-consistent-patch-properties-native",
"sourceState": "Uncommitted native-boundary repair, before final formatting",
"localLinterFingerprint": "sha256:33b2527404c4fb88cc32a37f2dd7cb1d58b17789308bdce020e23635019bd6f1",
"coverageKind": "partial",
"command": "pnpm --dir packages/typespec-lintdiff specs:typespec --specs-repo C:\\dev\\worktrees\\azure-rest-api-specs-lintdiff-consistent-patch-properties --concurrency 6",
"fullRun": true,
"durationMs": 1261930,
"durationMs": 4678050,
"sourceProjectCount": 468,
"successfulProjectCount": 462,
"failedProjectCount": 6,
"validator": {
"diagnostics": 151,
"projects": 27,
"fileIndependentIdentities": 48
"fileIndependentIdentities": 48,
"fileAndPathIdentities": 48
},
"typespec": {
"diagnostics": 325,
"sourceIdentities": 188,
"projects": 32,
"selectedVersionDiagnostics": 306,
"selectedVersionProjects": 28
"projects": 32
},
"afterOneSidedVersionExclusions": {
"diagnostics": 306,
"projects": 28,
"excludedDiagnostics": 19,
"globallyProjected": false,
"scope": "Remove only the documented findings in the four older-version-only projects"
},
"rawCountComparison": {
"equalOverlapProjects": 18,
"typeSpecHigherOverlapProjects": 9,
"validatorHigherOverlapProjects": 0,
"positiveDifferenceAllProjects": 174,
"negativeDifferenceAllProjects": 0
},
"deduplicatedCountComparison": {
"equalProjects": 9,
"typeSpecHigherProjects": 22,
"validatorHigherProjects": 1,
"positiveDifference": 142,
"negativeDifference": 2,
"identities": "Validator project + JSON path; TypeSpec project + source file + line + column"
},
"oneSidedProjectCounts": {
"Batch": 2,
"Cdn": 1,
"Informatica": 22,
"ManagedNetworkFabric": 15,
"NetApp": 1
},
"uncertainty": {
"informatica": "Selected Swagger schema mismatches are verified; the validator-side omission mechanism is not isolated.",
"versions": "The remaining 306 diagnostics are not a globally latest-version-projected population.",
"scope": "Four fixtures prove intentional native/emitter contract differences despite unchanged corpus counts."
},
"overlapProjects": [
"specification/apimanagement/resource-manager/Microsoft.ApiManagement/ApiManagement",
Expand Down Expand Up @@ -62,6 +101,14 @@
"specification/netapp/resource-manager/Microsoft.NetApp/NetApp"
]
},
"compileFailureKinds": {
"DeviceProvisioningServices": "@typespec/http/duplicate-body",
"TenantActionGroups": "@typespec/http/missing-uri-param",
"Network": "@typespec/http/missing-uri-param",
"Quota": "@typespec/http/missing-uri-param",
"deployments": "@typespec/http/duplicate-body",
"ServiceLinker": "@typespec/http/duplicate-body"
},
"failedProjects": [
"specification/deviceprovisioningservices/resource-manager/Microsoft.Devices/DeviceProvisioningServices",
"specification/monitor/resource-manager/Microsoft.Insights/Insights/TenantActionGroups",
Expand Down
Loading