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
1 change: 1 addition & 0 deletions .github/workflows/codesight.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ on:
permissions:
contents: write
pull-requests: write
issues: write

jobs:
codesight:
Expand Down
5 changes: 4 additions & 1 deletion src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@ export async function scan(
` ${project.frameworks.length > 0 ? project.frameworks.join(", ") : "generic"} | ${project.orms.length > 0 ? project.orms.join(", ") : "no ORM"} | ${project.language}`
);
if (project.isMonorepo) {
console.log(` Monorepo: ${project.workspaces.map((w) => w.name).join(", ")}`);
const repoLabel = project.repoType === "meta" ? "Meta-repo"
: project.repoType === "microservices" ? "Microservices"
: "Monorepo";
console.log(` ${repoLabel}: ${project.workspaces.map((w) => w.name).join(", ")}`);
}
}

Expand Down
5 changes: 4 additions & 1 deletion src/formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,10 @@ function formatCombined(
lines.push(`> **Stack:** ${fw} | ${orm} | ${compFw} | ${lang}`);
if (result.project.isMonorepo) {
const wsNames = result.project.workspaces.map((w) => w.name).join(", ");
lines.push(`> **Monorepo:** ${wsNames}`);
const repoLabel = result.project.repoType === "meta" ? "Meta-repo"
: result.project.repoType === "microservices" ? "Microservices"
: "Monorepo";
lines.push(`> **${repoLabel}:** ${wsNames}`);
}
lines.push("");

Expand Down
10 changes: 8 additions & 2 deletions src/generators/ai-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ function generateContext(result: ScanResult): string {
lines.push(`This is a ${project.language} project using ${fw}${orm !== "none" ? ` with ${orm}` : ""}.`);

if (project.isMonorepo) {
lines.push(`It is a monorepo with workspaces: ${project.workspaces.map((w) => `${w.name} (${w.path})`).join(", ")}.`);
const repoLabel = project.repoType === "meta" ? "meta-repo"
: project.repoType === "microservices" ? "microservices repo"
: "monorepo";
lines.push(`It is a ${repoLabel} with workspaces: ${project.workspaces.map((w) => `${w.name} (${w.path})`).join(", ")}.`);
}

lines.push("");
Expand Down Expand Up @@ -175,7 +178,10 @@ export async function generateProfileConfig(
summaryLines.push(`# ${project.name} — Project Context\n`);
summaryLines.push(`**Stack:** ${project.frameworks.join(", ") || "generic"} | ${project.orms.join(", ") || "none"} | ${project.language}`);
if (project.isMonorepo) {
summaryLines.push(`**Monorepo:** ${project.workspaces.map((w) => w.name).join(", ")}`);
const repoLabel = project.repoType === "meta" ? "Meta-repo"
: project.repoType === "microservices" ? "Microservices"
: "Monorepo";
summaryLines.push(`**${repoLabel}:** ${project.workspaces.map((w) => w.name).join(", ")}`);
}
summaryLines.push(`\n${routes.length} routes | ${schemas.length} models | ${config.envVars.length} env vars | ${graph.edges.length} import links\n`);

Expand Down
2 changes: 1 addition & 1 deletion src/generators/html-report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ ${project.frameworks.map((f) => `<span class="stack-badge">${escapeHtml(f)}</spa
${project.orms.map((o) => `<span class="stack-badge">${escapeHtml(o)}</span>`).join("")}
<span class="stack-badge">${escapeHtml(project.componentFramework)}</span>
<span class="stack-badge">${escapeHtml(project.language)}</span>
${project.isMonorepo ? '<span class="stack-badge">monorepo</span>' : ""}
${project.repoType !== "single" ? `<span class="stack-badge">${escapeHtml(project.repoType)}</span>` : ""}
</div>

<div class="token-hero">
Expand Down
9 changes: 7 additions & 2 deletions src/generators/wiki.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,12 +157,17 @@ function overviewArticle(result: ScanResult): string {
// One-sentence description
const parts: string[] = [`a ${project.language} project built with ${fw}`];
if (orm !== "none") parts.push(`using ${orm} for data persistence`);
if (project.isMonorepo) parts.push(`organized as a monorepo`);
if (project.repoType === "meta") parts.push(`organized as a meta-repo (aggregated independent projects)`);
else if (project.repoType === "microservices") parts.push(`organized as a microservices repo`);
else if (project.isMonorepo) parts.push(`organized as a monorepo`);
lines.push(`**${project.name}** is ${parts.join(", ")}.`, "");

if (project.isMonorepo && project.workspaces.length > 0) {
const wsLabel = project.repoType === "meta" ? "Projects"
: project.repoType === "microservices" ? "Services"
: "Workspaces";
lines.push(
`**Workspaces:** ${project.workspaces.map((w) => `\`${w.name}\` (\`${w.path}\`)`).join(", ")}`,
`**${wsLabel}:** ${project.workspaces.map((w) => `\`${w.name}\` (\`${w.path}\`)`).join(", ")}`,
""
);
}
Expand Down
5 changes: 4 additions & 1 deletion src/mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,10 @@ async function toolGetSummary(args: any): Promise<string> {
lines.push(`# ${project.name}`);
lines.push(`Stack: ${fw} | ${orm} | ${project.componentFramework} | ${project.language}`);
if (project.isMonorepo) {
lines.push(`Monorepo: ${project.workspaces.map((w) => w.name).join(", ")}`);
const repoLabel = project.repoType === "meta" ? "Meta-repo"
: project.repoType === "microservices" ? "Microservices"
: "Monorepo";
lines.push(`${repoLabel}: ${project.workspaces.map((w) => w.name).join(", ")}`);
}
lines.push("");
lines.push(
Expand Down
42 changes: 42 additions & 0 deletions src/scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
ORM,
ComponentFramework,
ProjectInfo,
RepoType,
WorkspaceInfo,
} from "./types.js";

Expand Down Expand Up @@ -222,6 +223,8 @@ export async function detectProject(root: string): Promise<ProjectInfo> {
// Treat as implicit monorepo when multiple distinct stacks are found
if (!isMonorepo && workspaces.length >= 2) isMonorepo = true;

const repoType = await classifyRepoType(root, workspaces, isMonorepo);

// Aggregate all workspace deps (always — not just for declared monorepos)
let allDeps = { ...deps };
for (const ws of workspaces) {
Expand Down Expand Up @@ -300,6 +303,7 @@ export async function detectProject(root: string): Promise<ProjectInfo> {
orms,
componentFramework: detectComponentFramework(allDeps, frameworks),
isMonorepo,
repoType,
workspaces,
language,
};
Expand Down Expand Up @@ -342,6 +346,44 @@ async function discoverImplicitWorkspaces(
} catch {}
}

/**
* Classify the repo structure into one of: single, monorepo, microservices, meta.
*
* - meta: .gitmodules exists → git submodules mean independent projects are
* aggregated here (e.g. org-wide umbrella repos)
* - microservices: multiple workspaces each with their own Dockerfile, or infra dirs
* (k8s/, kubernetes/, helm/) are present alongside 2+ workspaces
* - monorepo: multiple workspaces under shared tooling (packages.json workspaces,
* pnpm-workspace.yaml, turbo.json, nx.json, etc.)
* - single: single-project repo with no workspaces
*/
async function classifyRepoType(
root: string,
workspaces: WorkspaceInfo[],
isMonorepo: boolean
): Promise<RepoType> {
// Meta-repo: git submodules are the definitive signal
if (await fileExists(join(root, ".gitmodules"))) return "meta";

if (!isMonorepo || workspaces.length <= 1) return "single";

// Microservices: 2+ workspaces each with a Dockerfile, or infra orchestration at root
const infraDirs = ["k8s", "kubernetes", "helm"];
for (const dir of infraDirs) {
if (await fileExists(join(root, dir))) return "microservices";
}

let dockerfileCount = 0;
for (const ws of workspaces) {
if (await fileExists(join(root, ws.path, "Dockerfile"))) {
dockerfileCount++;
if (dockerfileCount >= 2) return "microservices";
}
}

return "monorepo";
}

async function hasDirectWorkspaceManifest(dir: string): Promise<boolean> {
const directManifestNames = [
"package.json",
Expand Down
3 changes: 3 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,16 @@ export interface KnowledgeMap {
dateRange?: { from: string; to: string };
}

export type RepoType = "single" | "monorepo" | "microservices" | "meta";

export interface ProjectInfo {
root: string;
name: string;
frameworks: Framework[];
orms: ORM[];
componentFramework: ComponentFramework;
isMonorepo: boolean;
repoType: RepoType;
workspaces: WorkspaceInfo[];
language: "typescript" | "javascript" | "python" | "go" | "ruby" | "elixir" | "java" | "kotlin" | "rust" | "php" | "dart" | "swift" | "csharp" | "mixed";
}
Expand Down
46 changes: 46 additions & 0 deletions tests/detectors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,12 +521,58 @@ describe("Framework Detection", async () => {
});
const project = await mods.detectProject(dir);
assert.equal(project.isMonorepo, true);
assert.equal(project.repoType, "monorepo");
assert.ok(project.workspaces.length >= 2);
assert.ok(project.frameworks.includes("hono"));
assert.equal(project.componentFramework, "react");
});
});

describe("Repo Type Classification", async () => {
const mods = await loadModules();

it("classifies single-project repo as 'single'", async () => {
const dir = await writeFixture("repotype-single", {
"package.json": JSON.stringify({ name: "my-app", dependencies: { express: "^4.0.0" } }),
});
const project = await mods.detectProject(dir);
assert.equal(project.repoType, "single");
assert.equal(project.isMonorepo, false);
});

it("classifies meta-repo via .gitmodules", async () => {
const dir = await writeFixture("repotype-meta", {
".gitmodules": `[submodule "frontend-app"]\n\tpath = frontend-app\n\turl = https://github.com/org/frontend-app\n[submodule "backend-api"]\n\tpath = backend-api\n\turl = https://github.com/org/backend-api\n`,
"frontend-app/package.json": JSON.stringify({ name: "frontend-app", dependencies: { react: "^18.0.0" } }),
"backend-api/package.json": JSON.stringify({ name: "backend-api", dependencies: { express: "^4.0.0" } }),
});
const project = await mods.detectProject(dir);
assert.equal(project.repoType, "meta");
});

it("classifies microservices repo via multiple Dockerfiles", async () => {
const dir = await writeFixture("repotype-microservices-docker", {
"auth/package.json": JSON.stringify({ name: "auth-service", dependencies: { express: "^4.0.0" } }),
"auth/Dockerfile": "FROM node:20\nCMD [\"node\", \"index.js\"]",
"payments/package.json": JSON.stringify({ name: "payments-service", dependencies: { fastify: "^4.0.0" } }),
"payments/Dockerfile": "FROM node:20\nCMD [\"node\", \"index.js\"]",
});
const project = await mods.detectProject(dir);
assert.equal(project.repoType, "microservices");
assert.equal(project.isMonorepo, true);
});

it("classifies microservices repo via k8s directory", async () => {
const dir = await writeFixture("repotype-microservices-k8s", {
"k8s/deployment.yaml": "apiVersion: apps/v1\nkind: Deployment",
"api/package.json": JSON.stringify({ name: "api", dependencies: { hono: "^4.0.0" } }),
"worker/package.json": JSON.stringify({ name: "worker", dependencies: { bullmq: "^5.0.0" } }),
});
const project = await mods.detectProject(dir);
assert.equal(project.repoType, "microservices");
});
});

describe("Python Workspace Subdirectory Detection", async () => {
const mods = await loadModules();

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name":"@test/web","dependencies":{"react":"^18.0.0"}}
1 change: 1 addition & 0 deletions tests/fixtures/python-custom-subdir-pyproject/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name":"test","workspaces":["apps/*","services/*"]}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from fastapi import FastAPI
app = FastAPI()

@app.get("/health")
def health():
return {"ok": True}

@app.post("/users")
def create_user():
return {"created": True}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from sqlalchemy import Column, ForeignKey, Integer, String
from sqlalchemy.orm import declarative_base, relationship

Base = declarative_base()

class User(Base):
__tablename__ = "users"

id = Column(Integer, primary_key=True)
email = Column(String, unique=True)
posts = relationship("Post")

class Post(Base):
__tablename__ = "posts"

id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"))
user = relationship("User")
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[project]
name = "custom-api"
version = "0.1.0"
dependencies = [
"fastapi>=0.110.0",
"sqlalchemy>=2.0.0",
"uvicorn>=0.29.0",
]
10 changes: 10 additions & 0 deletions tests/fixtures/python-custom-subdir-root/my-service-api/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from fastapi import FastAPI
app = FastAPI()

@app.get("/health")
def health():
return {"ok": True}

@app.post("/users")
def create_user():
return {"created": True}
18 changes: 18 additions & 0 deletions tests/fixtures/python-custom-subdir-root/my-service-api/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from sqlalchemy import Column, ForeignKey, Integer, String
from sqlalchemy.orm import declarative_base, relationship

Base = declarative_base()

class User(Base):
__tablename__ = "users"

id = Column(Integer, primary_key=True)
email = Column(String, unique=True)
posts = relationship("Post")

class Post(Base):
__tablename__ = "posts"

id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"))
user = relationship("User")
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
fastapi
sqlalchemy
uvicorn
1 change: 1 addition & 0 deletions tests/fixtures/python-custom-subdir-root/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name":"test","dependencies":{"react":"^18.0.0"}}
1 change: 1 addition & 0 deletions tests/fixtures/python-custom-subdir-root/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default function App() { return <main>web</main>; }
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name":"@test/web","dependencies":{"react":"^18.0.0"}}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default function App() { return <main>web</main>; }
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name":"test","workspaces":["apps/*","services/*"]}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from fastapi import FastAPI
app = FastAPI()

@app.get("/health")
def health():
return {"ok": True}

@app.post("/users")
def create_user():
return {"created": True}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from sqlalchemy import Column, ForeignKey, Integer, String
from sqlalchemy.orm import declarative_base, relationship

Base = declarative_base()

class User(Base):
__tablename__ = "users"

id = Column(Integer, primary_key=True)
email = Column(String, unique=True)
posts = relationship("Post")

class Post(Base):
__tablename__ = "posts"

id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"))
user = relationship("User")
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
fastapi
sqlalchemy
uvicorn
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name":"@test/web","dependencies":{"react":"^18.0.0"}}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default function App() { return <main>web</main>; }
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from fastapi import FastAPI
app = FastAPI()

@app.get("/health")
def health():
return {"ok": True}

@app.post("/users")
def create_user():
return {"created": True}
Loading
Loading