Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
[TypeSpecValidation] Allow folder structure v2
  • Loading branch information
mikeharder committed May 27, 2025
commit a5bd917f896f0e815b88290a5c200854f63ce6b6
6 changes: 1 addition & 5 deletions eng/tools/typespec-validation/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ParseArgsConfig, parseArgs } from "node:util";
import { stat } from "node:fs/promises";
import { ParseArgsConfig, parseArgs } from "node:util";
import { Suppression } from "suppressions";
import { CompileRule } from "./rules/compile.js";
import { EmitAutorestRule } from "./rules/emit-autorest.js";
Expand Down Expand Up @@ -58,10 +58,6 @@
for (let i = 0; i < rules.length; i++) {
const rule = rules[i];

if (rule instanceof FolderStructureRule) {
continue;
}

console.log("\nExecuting rule: " + rule.name);
const result = await rule.execute(absolutePath);
if (result.stdOutput) console.log(result.stdOutput);
Expand Down
153 changes: 98 additions & 55 deletions eng/tools/typespec-validation/src/rules/folder-structure.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import debug from "debug";
import { readFile } from "fs/promises";
import { globby } from "globby";
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,112 @@
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;
} 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 += `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'.`;
}
}

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;
}

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'.";
}
} 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'.";
}

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 +187,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
Loading