Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
5458d85
[TSV] Skip FolderStructureRule
mikeharder May 22, 2025
9cea263
Merge branch 'main' into folder-structure-v2
mikeharder May 22, 2025
b6d7d5e
[avocado] Exclude TSP examples
mikeharder May 22, 2025
1536857
Exclude paths containing "/examples/" but not "/stable/" or "/preview/"
mikeharder May 23, 2025
b5b85aa
simplify regex
mikeharder May 23, 2025
2a579fc
fix quoting
mikeharder May 23, 2025
d4e010d
Merge branch 'main' into folder-structure-v2
mikeharder May 23, 2025
177e9e2
Merge branch 'main' into folder-structure-v2
mikeharder May 23, 2025
bfd50e9
Merge branch 'main' into folder-structure-v2
mikeharder May 27, 2025
a5bd917
[TypeSpecValidation] Allow folder structure v2
mikeharder May 27, 2025
a4371ef
revert formatting
mikeharder May 27, 2025
f06ccb8
set success=false after reporting error
mikeharder May 27, 2025
1577743
Add unit tests for v2
mikeharder May 27, 2025
930181a
Merge branch 'main' into folder-structure-v2
mikeharder May 27, 2025
6fd752d
Merge branch 'main' into folder-structure-v2
mikeharder May 28, 2025
829c714
Add TODO comment
mikeharder May 28, 2025
e761239
consider typespec under resource-manager as mgmt typespec
raych1 May 29, 2025
99ecbd5
Merge branch 'main' into folder-structure-v2
mikeharder May 30, 2025
9627a5a
Merge branch 'main' into folder-structure-v2
mikeharder Jun 5, 2025
757907a
Merge branch 'main' into folder-structure-v2
mikeharder Jun 6, 2025
e140b82
Update eng/tools/typespec-validation/src/rules/folder-structure.ts
mikeharder Jun 6, 2025
3224d25
Update eng/tools/typespec-validation/src/rules/folder-structure.ts
mikeharder Jun 6, 2025
6162802
update rpnamespace and service regexes
mikeharder Jun 6, 2025
486e170
Merge branch 'main' into folder-structure-v2
mikeharder Jun 6, 2025
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
1 change: 1 addition & 0 deletions .github/workflows/avocado-code.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: "[TEST-IGNORE] Swagger Avocado - Analyze Code"

Check failure on line 1 in .github/workflows/avocado-code.yml

View workflow job for this annotation

GitHub Actions / Protected Files

File '.github/workflows/avocado-code.yml' should only be updated by the Azure SDK team. If intentional, the PR may be merged by the Azure SDK team via bypassing the branch protections.

on: pull_request

Expand Down Expand Up @@ -29,6 +29,7 @@
"/package.json" \
"/package-lock.json" \
"/cadl/examples/" \
'(?=/examples/)(?!(?:/stable/|/preview/))' \
--includePaths \
"data-plane" \
"resource-manager"
Expand Down
2 changes: 1 addition & 1 deletion eng/tools/spec-gen-sdk-runner/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { spawn, spawnSync, exec } from "node:child_process";

Check failure on line 1 in eng/tools/spec-gen-sdk-runner/src/utils.ts

View workflow job for this annotation

GitHub Actions / Protected Files

File 'eng/tools/spec-gen-sdk-runner/src/utils.ts' should only be updated by the Azure SDK team. If intentional, the PR may be merged by the Azure SDK team via bypassing the branch protections.
import path from "node:path";
import fs from "node:fs";
import { LogLevel, logMessage } from "./log.js";
Expand Down Expand Up @@ -409,7 +409,7 @@
}

const info = serviceMap.get(serviceName)!;
if (folderPath.endsWith(".Management")) {
if (folderPath.endsWith(".Management") || folderPath.includes("resource-manager")) {
info.managementPaths.push(folderPath);
} else {
info.otherTypeSpecPaths.push(folderPath);
Expand Down
158 changes: 103 additions & 55 deletions eng/tools/typespec-validation/src/rules/folder-structure.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import debug from "debug";

Check failure on line 1 in eng/tools/typespec-validation/src/rules/folder-structure.ts

View workflow job for this annotation

GitHub Actions / Protected Files

File 'eng/tools/typespec-validation/src/rules/folder-structure.ts' should only be updated by the Azure SDK team. If intentional, the PR may be merged by the Azure SDK team via bypassing the branch protections.
import { readFile } from "fs/promises";
import { globby } from "globby";
import path from "path";
Expand All @@ -21,6 +21,11 @@
const gitRoot = normalizePath(await simpleGit(folder).revparse("--show-toplevel"));
const relativePath = path.relative(gitRoot, folder).split(path.sep).join("/");

// If the folder containing TypeSpec sources is under "data-plane" or "resource-manager", the spec
// must be using "folder structure v2". Otherwise, it must be using v1.
const structureVersion =
relativePath.includes("data-plane") || relativePath.includes("resource-manager") ? 2 : 1;

stdOutput += `folder: ${folder}\n`;
if (!(await fileExists(folder))) {
return {
Expand All @@ -39,35 +44,6 @@
}
});

// Verify top level folder is lower case and remove empty entries when splitting by slash
const folderStruct = relativePath.split("/").filter(Boolean);
if (folderStruct[1].match(/[A-Z]/g)) {
success = false;
errorOutput += `Invalid folder name. Folders under specification/ must be lower case.\n`;
}

const packageFolder = folderStruct[folderStruct.length - 1];

// Verify package folder is at most 3 levels deep
if (folderStruct.length > 4) {
success = false;
errorOutput += `Please limit TypeSpec folder depth to 3 levels or less`;
}

// Verify second level folder is capitalized after each '.'
if (/(^|\. *)([a-z])/g.test(packageFolder)) {
success = false;
errorOutput += `Invalid folder name. Folders under specification/${folderStruct[1]} must be capitalized after each '.'\n`;
}

// Verify 'Shared' follows 'Management'
if (packageFolder.includes("Management") && packageFolder.includes("Shared")) {
if (!packageFolder.includes("Management.Shared")) {
success = false;
errorOutput += `Invalid folder name. For management libraries with a shared component, 'Shared' should follow 'Management'.`;
}
}

// Verify tspconfig, main.tsp, examples/
const mainExists = await fileExists(path.join(folder, "main.tsp"));
const clientExists = await fileExists(path.join(folder, "client.tsp"));
Expand All @@ -83,45 +59,117 @@
success = false;
}

if (!packageFolder.includes("Shared") && !tspConfigExists) {
errorOutput += `Invalid folder structure: Spec folder must contain tspconfig.yaml.`;
const folderStruct = relativePath.split("/").filter(Boolean);

// Verify top level folder is lower case and remove empty entries when splitting by slash
if (folderStruct[1].match(/[A-Z]/g)) {
success = false;
errorOutput += `Invalid folder name. Folders under specification/ must be lower case.\n`;
}

if (tspConfigExists) {
const configText = await readTspConfig(folder);
const config = yamlParse(configText);
const rpFolder =
config?.options?.["@azure-tools/typespec-autorest"]?.["azure-resource-provider-folder"];
stdOutput += `azure-resource-provider-folder: ${JSON.stringify(rpFolder)}\n`;

if (
rpFolder?.trim()?.endsWith("resource-manager") &&
!packageFolder.endsWith(".Management")
) {
errorOutput += `Invalid folder structure: TypeSpec for resource-manager specs must be in a folder ending with '.Management'`;
if (structureVersion === 1) {
const packageFolder = folderStruct[folderStruct.length - 1];

if (!packageFolder.includes("Shared") && !tspConfigExists) {
errorOutput += `Invalid folder structure: Spec folder must contain tspconfig.yaml.`;
success = false;
}

// Verify package folder is at most 3 levels deep
if (folderStruct.length > 4) {
success = false;
errorOutput += `Please limit TypeSpec folder depth to 3 levels or less`;
}

// Verify second level folder is capitalized after each '.'
if (/(^|\. *)([a-z])/g.test(packageFolder)) {
success = false;
} else if (
!rpFolder?.trim()?.endsWith("resource-manager") &&
packageFolder.endsWith(".Management")
) {
errorOutput += `Invalid folder structure: TypeSpec for data-plane specs or shared code must be in a folder NOT ending with '.Management'`;
errorOutput += `Invalid folder name. Folders under specification/${folderStruct[1]} must be capitalized after each '.'\n`;
}

// Verify 'Shared' follows 'Management'
if (packageFolder.includes("Management") && packageFolder.includes("Shared")) {
if (!packageFolder.includes("Management.Shared")) {
success = false;
errorOutput += `Invalid folder name. For management libraries with a shared component, 'Shared' should follow 'Management'.`;
}
}

if (tspConfigExists) {
const configText = await readTspConfig(folder);
const config = yamlParse(configText);
const rpFolder =
config?.options?.["@azure-tools/typespec-autorest"]?.["azure-resource-provider-folder"];
stdOutput += `azure-resource-provider-folder: ${JSON.stringify(rpFolder)}\n`;

if (
rpFolder?.trim()?.endsWith("resource-manager") &&
!packageFolder.endsWith(".Management")
) {
errorOutput += `Invalid folder structure: TypeSpec for resource-manager specs must be in a folder ending with '.Management'`;
success = false;
} else if (
!rpFolder?.trim()?.endsWith("resource-manager") &&
packageFolder.endsWith(".Management")
) {
errorOutput += `Invalid folder structure: TypeSpec for data-plane specs or shared code must be in a folder NOT ending with '.Management'`;
success = false;
}
}
} else if (structureVersion === 2) {
if (!tspConfigExists) {
errorOutput += `Invalid folder structure: Spec folder must contain tspconfig.yaml.`;
success = false;
}

// TODO: Decide if v2 should validate folder names and structure to ensure specs align, or allow a more loose structure to ease
// migration from v1 to v2.

const specType = folder.includes("data-plane") ? "data-plane" : "resource-manager";
if (specType === "data-plane") {
if (folderStruct.length !== 4) {
errorOutput +=
"Invalid folder structure: TypeSpec for data-plane specs must be in a folder exactly one level under 'data-plane', like 'specification/foo/data-plane/Foo'.";
success = false;
}
} else if (specType === "resource-manager") {
if (folderStruct.length !== 5) {
errorOutput +=
"Invalid folder structure: TypeSpec for resource-manager specs must be in a folder exactly two levels under 'resource-manager', like 'specification/foo/resource-management/Microsoft.Foo/FooManagement'.";
success = false;
}

const rpNamespaceFolder = folderStruct[folderStruct.length - 2];

// Verify service folder is capitalized after each '.'
if (/(^|\. *)([a-z])/g.test(rpNamespaceFolder)) {
success = false;
errorOutput += `Invalid RP namespace folder '${rpNamespaceFolder}'. RP namespace folders must be capitalized after each '.'`;
}
}

const serviceFolder = folderStruct[folderStruct.length - 1];

// Verify service folder is capitalized after each '.'
if (/(^|\. *)([a-z])/g.test(serviceFolder)) {
success = false;
errorOutput += `Invalid service folder '${serviceFolder}'. Service folders must be capitalized after each '.'`;
}
}

// Ensure specs only import files from same folder under "specification"
stdOutput += "imports:\n";

const teamFolder = path.join(...folderStruct.slice(0, 2));
stdOutput += ` ${teamFolder}\n`;
const allowedImportRoot =
structureVersion === 1 ? path.join(...folderStruct.slice(0, 2)) : folder;
stdOutput += ` ${allowedImportRoot}\n`;

const teamFolderResolved = path.resolve(gitRoot, teamFolder);
const allowedImportRootResolved = path.resolve(gitRoot, allowedImportRoot);

const tsps = await globby("**/*.tsp", { cwd: teamFolderResolved });
const tsps = await globby("**/*.tsp", { cwd: allowedImportRootResolved });

for (const tsp of tsps) {
const tspResolved = path.resolve(teamFolderResolved, tsp);
const tspResolved = path.resolve(allowedImportRootResolved, tsp);

const pattern = /^\s*import\s+['"]([^'"]+)['"]\s*;\s*$/gm;
const text = await readFile(tspResolved, { encoding: "utf8" });
Expand All @@ -144,12 +192,12 @@
for (const fileImport of fileImports) {
const fileImportResolved = path.resolve(path.dirname(tspResolved), fileImport);

const relative = path.relative(teamFolderResolved, fileImportResolved);
const relative = path.relative(allowedImportRootResolved, fileImportResolved);

if (relative.startsWith("..")) {
errorOutput +=
`Invalid folder structure: '${tsp}' imports '${fileImport}', ` +
`which is outside '${path.relative(gitRoot, teamFolder)}'`;
`which is outside '${path.relative(gitRoot, allowedImportRoot)}'`;
success = false;
}
}
Expand Down
82 changes: 82 additions & 0 deletions eng/tools/typespec-validation/test/folder-structure.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mockAll, mockFolder } from "./mocks.js";

Check failure on line 1 in eng/tools/typespec-validation/test/folder-structure.test.ts

View workflow job for this annotation

GitHub Actions / Protected Files

File 'eng/tools/typespec-validation/test/folder-structure.test.ts' should only be updated by the Azure SDK team. If intentional, the PR may be merged by the Azure SDK team via bypassing the branch protections.
mockAll();

import { contosoTspConfig } from "@azure-tools/specs-shared/test/examples";
Expand All @@ -24,6 +24,14 @@
vi.clearAllMocks();
});

it("should fail if folder doesn't exist", async function () {
fileExistsSpy.mockResolvedValue(false);

const result = await new FolderStructureRule().execute(mockFolder);
assert(result.errorOutput);
assert(result.errorOutput.includes("does not exist"));
});

it("should fail if tspconfig has incorrect extension", async function () {
vi.mocked(globby.globby).mockImplementation(async () => {
return ["/foo/bar/tspconfig.yml"];
Expand Down Expand Up @@ -260,4 +268,78 @@
assert(result.errorOutput);
assert(result.errorOutput.includes(".Management"));
});

it("v2: should fail if no tspconfig.yaml", async function () {
vi.mocked(globby.globby).mockImplementation(async () => {
return ["main.tsp"];
});
normalizePathSpy.mockReturnValue("/gitroot");

fileExistsSpy.mockImplementation(async (file: string) => {
if (file.includes("tspconfig.yaml")) {
return false;
}
return true;
});

const result = await new FolderStructureRule().execute(
"/gitroot/specification/foo/data-plane/Foo",
);

assert(result.errorOutput?.includes("must contain"));
});

it("v2: should fail if incorrect folder depth", async function () {
vi.mocked(globby.globby).mockImplementation(async () => {
return ["tspconfig.yaml"];
});
normalizePathSpy.mockReturnValue("/gitroot");

let result = await new FolderStructureRule().execute("/gitroot/specification/foo/data-plane");
assert(result.errorOutput?.includes("level under"));

result = await new FolderStructureRule().execute(
"/gitroot/specification/foo/data-plane/Foo/too-deep",
);
assert(result.errorOutput?.includes("level under"));

result = await new FolderStructureRule().execute("/gitroot/specification/foo/resource-manager");
assert(result.errorOutput?.includes("levels under"));

result = await new FolderStructureRule().execute(
"/gitroot/specification/foo/resource-manager/RP.Namespace",
);
assert(result.errorOutput?.includes("levels under"));

result = await new FolderStructureRule().execute(
"/gitroot/specification/foo/resource-manager/RP.Namespace/FooManagement/too-deep",
);
assert(result.errorOutput?.includes("levels under"));
});

it("v2: should succeed with data-plane", async function () {
vi.mocked(globby.globby).mockImplementation(async (patterns) => {
return patterns[0].includes("tspconfig") ? ["tspconfig.yaml"] : ["main.tsp"];
});
normalizePathSpy.mockReturnValue("/gitroot");

const result = await new FolderStructureRule().execute(
"/gitroot/specification/foo/data-plane/Foo",
);

assert(result.success);
});

it("v2: should succeed with resource-manager", async function () {
vi.mocked(globby.globby).mockImplementation(async (patterns) => {
return patterns[0].includes("tspconfig") ? ["tspconfig.yaml"] : ["main.tsp"];
});
normalizePathSpy.mockReturnValue("/gitroot");

const result = await new FolderStructureRule().execute(
"/gitroot/specification/foo/resource-manager/Microsoft.Foo/FooManagement",
);

assert(result.success);
});
});
Loading