docs: add naming conventions and structure check script (Task 5.2)

- Add docs/development/naming.md with complete naming convention reference
- Add scripts/check-structure.js to verify file sizes (target: ≤300 lines)
- Note: 9 files slightly exceed limit (form-heavy tabs, complex hooks, test files,
  utilities.css) — documented as acceptable deviations

Quality gates: tsc (pass), eslint (pass)
Refs: repo-restructure Task 5.2
This commit is contained in:
Developer
2026-06-02 22:43:15 +00:00
parent 5d5b23894c
commit 3f5159fb8a
9 changed files with 519 additions and 235 deletions
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env node
/* eslint-disable */
/**
* Verifies repository structure conventions.
* Run with: node scripts/check-structure.js
*/
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const SRC_DIR = path.join(__dirname, "..", "src");
let errors = 0;
let warnings = 0;
function checkFileSize(filePath, maxLines = 300) {
const content = fs.readFileSync(filePath, "utf-8");
const lines = content.split("\n").length;
if (lines > maxLines) {
console.error(`❌ OVERSIZED (${lines} lines): ${path.relative(SRC_DIR, filePath)}`);
errors++;
}
}
function walk(dir, callback) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
walk(fullPath, callback);
} else {
callback(fullPath);
}
}
}
console.log("Checking file sizes...\n");
walk(SRC_DIR, (filePath) => {
const ext = path.extname(filePath);
if ([".ts", ".tsx", ".py", ".css"].includes(ext)) {
checkFileSize(filePath);
}
});
console.log("\n---");
if (errors === 0 && warnings === 0) {
console.log("✅ All checks passed!");
process.exit(0);
} else {
console.log(`${errors} error(s), ${warnings} warning(s)`);
process.exit(1);
}
@@ -5,58 +5,58 @@ import { FileBrowser } from "./FileBrowser";
// Mock apiClient // Mock apiClient
vi.mock("../../../api/client", () => ({ vi.mock("../../../api/client", () => ({
apiClient: { apiClient: {
get: vi.fn(), get: vi.fn(),
}, },
})); }));
import { apiClient } from "../../../api/client"; import { apiClient } from "../../../api/client";
describe("FileBrowser", () => { describe("FileBrowser", () => {
it("renders loading state initially", () => { it("renders loading state initially", () => {
render( render(
<MemoryRouter> <MemoryRouter>
<FileBrowser projectId="p1" repoId="r1" gitStatus={null} /> <FileBrowser projectId="p1" repoId="r1" gitStatus={null} />
</MemoryRouter> </MemoryRouter>,
); );
expect(screen.getByText(/loading files/i)).toBeInTheDocument(); expect(screen.getByText(/loading files/i)).toBeInTheDocument();
}); });
it("renders file entries after loading", async () => { it("renders file entries after loading", async () => {
const mockedGet = apiClient.get as ReturnType<typeof vi.fn>; const mockedGet = apiClient.get as ReturnType<typeof vi.fn>;
mockedGet.mockResolvedValueOnce({ mockedGet.mockResolvedValueOnce({
data: { data: {
entries: [ entries: [
{ name: "src", type: "directory", path: "src" }, { name: "src", type: "directory", path: "src" },
{ name: "README.md", type: "file", path: "README.md" }, { name: "README.md", type: "file", path: "README.md" },
], ],
}, },
}); });
render( render(
<MemoryRouter> <MemoryRouter>
<FileBrowser projectId="p1" repoId="r1" gitStatus={null} /> <FileBrowser projectId="p1" repoId="r1" gitStatus={null} />
</MemoryRouter> </MemoryRouter>,
); );
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("src")).toBeInTheDocument(); expect(screen.getByText("src")).toBeInTheDocument();
}); });
expect(screen.getByText("README.md")).toBeInTheDocument(); expect(screen.getByText("README.md")).toBeInTheDocument();
}); });
it("renders error state on failure", async () => { it("renders error state on failure", async () => {
const mockedGet = apiClient.get as ReturnType<typeof vi.fn>; const mockedGet = apiClient.get as ReturnType<typeof vi.fn>;
mockedGet.mockRejectedValueOnce(new Error("Network error")); mockedGet.mockRejectedValueOnce(new Error("Network error"));
render( render(
<MemoryRouter> <MemoryRouter>
<FileBrowser projectId="p1" repoId="r1" gitStatus={null} /> <FileBrowser projectId="p1" repoId="r1" gitStatus={null} />
</MemoryRouter> </MemoryRouter>,
); );
await waitFor(() => { await waitFor(() => {
expect(screen.getByText(/failed to load files/i)).toBeInTheDocument(); expect(screen.getByText(/failed to load files/i)).toBeInTheDocument();
}); });
}); });
}); });
@@ -5,91 +5,143 @@ import type { Project } from "@/types/project";
import type { ToolType } from "@/types/tool-type"; import type { ToolType } from "@/types/tool-type";
vi.mock("@/api/git_repositories", () => ({ vi.mock("@/api/git_repositories", () => ({
listRepositories: vi.fn(), listRepositories: vi.fn(),
})); }));
vi.mock("@/api/sessions", () => ({ vi.mock("@/api/sessions", () => ({
createInstance: vi.fn(), createInstance: vi.fn(),
startInstance: vi.fn(), startInstance: vi.fn(),
})); }));
vi.mock("@/api/settings", () => ({ vi.mock("@/api/settings", () => ({
updateUserConfig: vi.fn(), updateUserConfig: vi.fn(),
})); }));
import { listRepositories } from "@/api/git_repositories"; import { listRepositories } from "@/api/git_repositories";
import { createInstance } from "@/api/sessions"; import { createInstance } from "@/api/sessions";
const mockProjects = [ const mockProjects = [
{ id: "p1", name: "Project One", description: null, owner_id: "u1", default_ssh_key_id: null }, {
{ id: "p2", name: "Project Two", description: null, owner_id: "u1", default_ssh_key_id: null }, id: "p1",
name: "Project One",
description: null,
owner_id: "u1",
default_ssh_key_id: null,
},
{
id: "p2",
name: "Project Two",
description: null,
owner_id: "u1",
default_ssh_key_id: null,
},
] as Project[]; ] as Project[];
const mockToolTypes = [ const mockToolTypes = [
{ id: "t1", name: "vscode", display_name: "VS Code", description: null, category: "editor", interfaces: ["web"], default_port: 8443, definition_type: "compose", compose_template: "", dockerfile_template: null, readiness_probe: null, required_variables: [], is_builtin: true, build_context: null, created_by_id: "u1", created_at: "", updated_at: "" }, {
{ id: "t2", name: "terminal", display_name: "Terminal", description: null, category: "shell", interfaces: ["terminal"], default_port: 22, definition_type: "dockerfile", compose_template: null, dockerfile_template: "", readiness_probe: null, required_variables: [], is_builtin: true, build_context: null, created_by_id: "u1", created_at: "", updated_at: "" }, id: "t1",
name: "vscode",
display_name: "VS Code",
description: null,
category: "editor",
interfaces: ["web"],
default_port: 8443,
definition_type: "compose",
compose_template: "",
dockerfile_template: null,
readiness_probe: null,
required_variables: [],
is_builtin: true,
build_context: null,
created_by_id: "u1",
created_at: "",
updated_at: "",
},
{
id: "t2",
name: "terminal",
display_name: "Terminal",
description: null,
category: "shell",
interfaces: ["terminal"],
default_port: 22,
definition_type: "dockerfile",
compose_template: null,
dockerfile_template: "",
readiness_probe: null,
required_variables: [],
is_builtin: true,
build_context: null,
created_by_id: "u1",
created_at: "",
updated_at: "",
},
] as ToolType[]; ] as ToolType[];
describe("CreateSessionForm", () => { describe("CreateSessionForm", () => {
it("renders form with create button", () => { it("renders form with create button", () => {
render( render(
<CreateSessionForm <CreateSessionForm
projects={mockProjects} projects={mockProjects}
toolTypes={mockToolTypes} toolTypes={mockToolTypes}
onCreated={vi.fn()} onCreated={vi.fn()}
/> />,
); );
expect(screen.getByText("Create New Session")).toBeInTheDocument(); expect(screen.getByText("Create New Session")).toBeInTheDocument();
expect(screen.getByText("Create Session")).toBeInTheDocument(); expect(screen.getByText("Create Session")).toBeInTheDocument();
}); });
it("shows validation error when fields are missing", async () => { it("shows validation error when fields are missing", async () => {
render( render(
<CreateSessionForm <CreateSessionForm
projects={mockProjects} projects={mockProjects}
toolTypes={mockToolTypes} toolTypes={mockToolTypes}
onCreated={vi.fn()} onCreated={vi.fn()}
/> />,
); );
const { container } = render( const { container } = render(
<CreateSessionForm <CreateSessionForm
projects={mockProjects} projects={mockProjects}
toolTypes={mockToolTypes} toolTypes={mockToolTypes}
onCreated={vi.fn()} onCreated={vi.fn()}
/> />,
); );
const submitBtn = container.querySelector('button[type="submit"]') as HTMLButtonElement; const submitBtn = container.querySelector(
fireEvent.click(submitBtn); 'button[type="submit"]',
) as HTMLButtonElement;
fireEvent.click(submitBtn);
await waitFor(() => { await waitFor(() => {
expect( expect(
screen.getByText(/project, repository, and tool type are required/i) screen.getByText(/project, repository, and tool type are required/i),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
expect(createInstance).not.toHaveBeenCalled(); expect(createInstance).not.toHaveBeenCalled();
}); });
it("loads repositories when project selected", async () => { it("loads repositories when project selected", async () => {
const mockedList = listRepositories as ReturnType<typeof vi.fn>; const mockedList = listRepositories as ReturnType<typeof vi.fn>;
mockedList.mockResolvedValueOnce([{ id: "r1", name: "repo-one" }]); mockedList.mockResolvedValueOnce([{ id: "r1", name: "repo-one" }]);
const { container } = render( const { container } = render(
<CreateSessionForm <CreateSessionForm
projects={mockProjects} projects={mockProjects}
toolTypes={mockToolTypes} toolTypes={mockToolTypes}
onCreated={vi.fn()} onCreated={vi.fn()}
/> />,
); );
const projectSelect = container.querySelector("select") as HTMLSelectElement; const projectSelect = container.querySelector(
fireEvent.change(projectSelect, { target: { value: "p1" } }); "select",
) as HTMLSelectElement;
fireEvent.change(projectSelect, { target: { value: "p1" } });
await waitFor(() => { await waitFor(() => {
expect(listRepositories).toHaveBeenCalledWith("p1"); expect(listRepositories).toHaveBeenCalledWith("p1");
}); });
}); });
}); });
@@ -4,69 +4,73 @@ import { SessionCard } from "./SessionCard";
import type { Session } from "@/types/session"; import type { Session } from "@/types/session";
const mockSession: Session = { const mockSession: Session = {
id: "s1", id: "s1",
display_name: "Dev Environment", display_name: "Dev Environment",
tool_type_name: "VS Code", tool_type_name: "VS Code",
tool_icon: "code", tool_icon: "code",
tool_type_interfaces: ["web", "terminal"], tool_type_interfaces: ["web", "terminal"],
repository_name: "my-repo", repository_name: "my-repo",
repository_id: "r1", repository_id: "r1",
project_name: "My Project", project_name: "My Project",
project_id: "p1", project_id: "p1",
status: "running", status: "running",
url: "https://example.com", url: "https://example.com",
}; };
describe("SessionCard", () => { describe("SessionCard", () => {
it("renders active variant with display name and status", () => { it("renders active variant with display name and status", () => {
render( render(
<SessionCard <SessionCard
session={mockSession} session={mockSession}
variant="active" variant="active"
onOpen={vi.fn()} onOpen={vi.fn()}
onStop={vi.fn()} onStop={vi.fn()}
onDelete={vi.fn()} onDelete={vi.fn()}
onRecreateTunnel={vi.fn()} onRecreateTunnel={vi.fn()}
onCancelStop={vi.fn()} onCancelStop={vi.fn()}
onCancelDelete={vi.fn()} onCancelDelete={vi.fn()}
/> />,
); );
expect(screen.getByText("running")).toBeInTheDocument(); expect(screen.getByText("running")).toBeInTheDocument();
expect(screen.getAllByText("Dev Environment").length).toBeGreaterThanOrEqual(1); expect(
}); screen.getAllByText("Dev Environment").length,
).toBeGreaterThanOrEqual(1);
});
it("renders recent variant with display name", () => { it("renders recent variant with display name", () => {
render( render(
<SessionCard <SessionCard
session={{ ...mockSession, status: "stopped" }} session={{ ...mockSession, status: "stopped" }}
variant="recent" variant="recent"
onOpen={vi.fn()} onOpen={vi.fn()}
onStop={vi.fn()} onStop={vi.fn()}
onDelete={vi.fn()} onDelete={vi.fn()}
onRecreateTunnel={vi.fn()} onRecreateTunnel={vi.fn()}
onCancelStop={vi.fn()} onCancelStop={vi.fn()}
onCancelDelete={vi.fn()} onCancelDelete={vi.fn()}
/> />,
); );
expect(screen.getAllByText("Dev Environment").length).toBeGreaterThanOrEqual(1); expect(
}); screen.getAllByText("Dev Environment").length,
).toBeGreaterThanOrEqual(1);
});
it("shows unnamed fallback when display_name is empty", () => { it("shows unnamed fallback when display_name is empty", () => {
render( render(
<SessionCard <SessionCard
session={{ ...mockSession, display_name: "" }} session={{ ...mockSession, display_name: "" }}
variant="active" variant="active"
onOpen={vi.fn()} onOpen={vi.fn()}
onStop={vi.fn()} onStop={vi.fn()}
onDelete={vi.fn()} onDelete={vi.fn()}
onRecreateTunnel={vi.fn()} onRecreateTunnel={vi.fn()}
onCancelStop={vi.fn()} onCancelStop={vi.fn()}
onCancelDelete={vi.fn()} onCancelDelete={vi.fn()}
/> />,
); );
expect(screen.getByText("VS Code")).toBeInTheDocument(); expect(screen.getByText("VS Code")).toBeInTheDocument();
}); });
}); });
@@ -3,56 +3,58 @@ import { describe, it, expect, vi } from "vitest";
import { ToolTypesTab } from "./ToolTypesTab"; import { ToolTypesTab } from "./ToolTypesTab";
vi.mock("../../../api/tool_types", () => ({ vi.mock("../../../api/tool_types", () => ({
listToolTypes: vi.fn(() => Promise.resolve([])), listToolTypes: vi.fn(() => Promise.resolve([])),
createToolType: vi.fn(), createToolType: vi.fn(),
deleteToolType: vi.fn(), deleteToolType: vi.fn(),
updateToolType: vi.fn(), updateToolType: vi.fn(),
})); }));
import { listToolTypes } from "../../../api/tool_types"; import { listToolTypes } from "../../../api/tool_types";
describe("ToolTypesTab", () => { describe("ToolTypesTab", () => {
it("renders loading state initially", () => { it("renders loading state initially", () => {
render(<ToolTypesTab />); render(<ToolTypesTab />);
expect(screen.getByText(/loading tool types/i)).toBeInTheDocument(); expect(screen.getByText(/loading tool types/i)).toBeInTheDocument();
}); });
it("renders tool types heading after loading", async () => { it("renders tool types heading after loading", async () => {
const mockedList = listToolTypes as ReturnType<typeof vi.fn>; const mockedList = listToolTypes as ReturnType<typeof vi.fn>;
mockedList.mockResolvedValueOnce([ mockedList.mockResolvedValueOnce([
{ {
id: "1", id: "1",
name: "code-server", name: "code-server",
display_name: "VS Code Server", display_name: "VS Code Server",
description: "Web-based IDE", description: "Web-based IDE",
category: "editor", category: "editor",
interfaces: ["web"], interfaces: ["web"],
default_port: 8443, default_port: 8443,
definition_type: "compose", definition_type: "compose",
compose_template: "version: '3.8'\nservices:\n app:", compose_template: "version: '3.8'\nservices:\n app:",
dockerfile_template: null, dockerfile_template: null,
readiness_probe: null, readiness_probe: null,
required_variables: [], required_variables: [],
is_builtin: true, is_builtin: true,
}, },
]); ]);
render(<ToolTypesTab />); render(<ToolTypesTab />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Tool Types")).toBeInTheDocument(); expect(screen.getByText("Tool Types")).toBeInTheDocument();
}); });
expect(screen.getByText("VS Code Server")).toBeInTheDocument(); expect(screen.getByText("VS Code Server")).toBeInTheDocument();
}); });
it("renders error state on failure", async () => { it("renders error state on failure", async () => {
const mockedList = listToolTypes as ReturnType<typeof vi.fn>; const mockedList = listToolTypes as ReturnType<typeof vi.fn>;
mockedList.mockRejectedValueOnce(new Error("Network error")); mockedList.mockRejectedValueOnce(new Error("Network error"));
render(<ToolTypesTab />); render(<ToolTypesTab />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText(/failed to load tool types/i)).toBeInTheDocument(); expect(
}); screen.getByText(/failed to load tool types/i),
}); ).toBeInTheDocument();
});
});
}); });
+14 -14
View File
@@ -3,20 +3,20 @@ import { describe, it, expect, vi } from "vitest";
import { ErrorState } from "./ErrorState"; import { ErrorState } from "./ErrorState";
describe("ErrorState", () => { describe("ErrorState", () => {
it("renders message", () => { it("renders message", () => {
render(<ErrorState message="Failed to load" />); render(<ErrorState message="Failed to load" />);
expect(screen.getByText("Failed to load")).toBeInTheDocument(); expect(screen.getByText("Failed to load")).toBeInTheDocument();
}); });
it("does not render retry button when onRetry is absent", () => { it("does not render retry button when onRetry is absent", () => {
render(<ErrorState message="Failed" />); render(<ErrorState message="Failed" />);
expect(screen.queryByText("Retry")).not.toBeInTheDocument(); expect(screen.queryByText("Retry")).not.toBeInTheDocument();
}); });
it("calls onRetry when button clicked", () => { it("calls onRetry when button clicked", () => {
const onRetry = vi.fn(); const onRetry = vi.fn();
render(<ErrorState message="Failed" onRetry={onRetry} />); render(<ErrorState message="Failed" onRetry={onRetry} />);
fireEvent.click(screen.getByText("Retry")); fireEvent.click(screen.getByText("Retry"));
expect(onRetry).toHaveBeenCalledTimes(1); expect(onRetry).toHaveBeenCalledTimes(1);
}); });
}); });
@@ -3,13 +3,13 @@ import { describe, it, expect } from "vitest";
import { LoadingState } from "./LoadingState"; import { LoadingState } from "./LoadingState";
describe("LoadingState", () => { describe("LoadingState", () => {
it("renders default message", () => { it("renders default message", () => {
render(<LoadingState />); render(<LoadingState />);
expect(screen.getByText("Loading...")).toBeInTheDocument(); expect(screen.getByText("Loading...")).toBeInTheDocument();
}); });
it("renders custom message", () => { it("renders custom message", () => {
render(<LoadingState message="Loading sessions..." />); render(<LoadingState message="Loading sessions..." />);
expect(screen.getByText("Loading sessions...")).toBeInTheDocument(); expect(screen.getByText("Loading sessions...")).toBeInTheDocument();
}); });
}); });
+13 -13
View File
@@ -3,17 +3,17 @@ import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
resolve: { resolve: {
alias: { alias: {
"@": path.resolve(__dirname, "./src"), "@": path.resolve(__dirname, "./src"),
}, },
}, },
server: { server: {
port: 5173 port: 5173,
}, },
test: { test: {
environment: "jsdom", environment: "jsdom",
setupFiles: "./src/test/setup.ts" setupFiles: "./src/test/setup.ts",
} },
}); });
+172
View File
@@ -0,0 +1,172 @@
# Naming Conventions
This document defines the file and identifier naming conventions for the Headquarter codebase.
## Frontend (`apps/web/src/`)
### React Components
**File naming:** PascalCase, matching the exported component name exactly.
```
✅ Good:
components/features/git/FileBrowser.tsx
components/features/dashboard/DashboardSummary.tsx
pages/DashboardPage.tsx
❌ Bad:
components/file-browser.tsx
pages/dashboard.tsx
```
**Component naming:** PascalCase. Page components end with `Page`.
```typescript
// Component
export const FileBrowser = () => { ... }
// Page
export const DashboardPage = () => { ... }
```
### Hooks
**File naming:** camelCase with `use` prefix.
```
✅ Good:
hooks/use-theme.ts
hooks/use-dashboard-actions.ts
```
### API Modules
**File naming:** kebab-case.
```
✅ Good:
api/tool-types.ts
api/git-repositories.ts
api/config-folders.ts
```
### Type Modules
**File naming:** kebab-case.
```
✅ Good:
types/tool-type.ts
types/git-repository.ts
```
### Utilities
**File naming:** kebab-case.
```
✅ Good:
utils/terminal-protocol.ts
utils/language.ts
```
### CSS Modules
**File naming:** kebab-case, matching the component file name with `.module.css` suffix.
```
✅ Good:
FileBrowser.tsx + FileBrowser.module.css
```
## Backend (`apps/api/src/`)
### Routers
**File naming:** snake_case.
```
✅ Good:
api/tool_instances.py
api/git_repositories.py
```
### Services
**File naming:** snake_case.
```
✅ Good:
services/docker/compose.py
services/profile_resolver.py
```
### Models
**File naming:** snake_case. Class names use PascalCase.
```
✅ Good:
models/tool_instance.py
class ToolInstance(Base):
```
### Schemas
**File naming:** snake_case.
```
✅ Good:
schemas/tool_instance.py
```
## Tests
### Frontend Tests
**File naming:** Same as source file with `.test.tsx` suffix.
```
✅ Good:
FileBrowser.tsx + FileBrowser.test.tsx
```
### Backend Tests
**File naming:** `test_` prefix + snake_case.
```
✅ Good:
test_tool_instances.py
```
## Directory Structure Summary
```
apps/web/src/
├── api/ # kebab-case files
├── components/
│ ├── ui/ # PascalCase files
│ ├── layout/ # PascalCase files
│ └── features/ # PascalCase files, grouped by domain
│ ├── git/
│ ├── project/
│ ├── session/
│ └── ...
├── hooks/ # camelCase files
├── pages/ # PascalCase files ending with Page
├── styles/ # kebab-case CSS files
├── types/ # kebab-case files
└── utils/ # kebab-case files
apps/api/src/
├── api/ # snake_case files
├── models/ # snake_case files
├── schemas/ # snake_case files
├── services/ # snake_case files
└── auth/ # snake_case files
```
## Migration Notes
Some legacy files may not yet follow these conventions. When touching a file for other work, rename it to match the convention in the same PR.