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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,4 @@ pnpm-debug.log*

# vercel
.vercel/
test-results/
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ Build high-signal agent and instruction files from community-proven best practic
4. Answer topic prompts across general, architecture, performance, security, commits, and more—or lean on the recommended defaults when you need a fast decision.
5. Review a completion summary that highlights what made it into your file and which areas still need decisions.

## Architecture docs
- [Scan → Wizard Flow](docs/scan-flow.md) – how repository scans feed conventions, defaults, and final instruction files.

## Community knowledge base
- Every topic originates from the developer community—playbooks, real-world retrospectives, and shared tooling habits.
- JSON entries in `data/` capture those insights: each answer carries labels, examples, pros/cons, tags, and authoritative `docs` links.
Expand Down
40 changes: 40 additions & 0 deletions app/api/scan-generate/[fileId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server"

import { buildResponsesFromScan } from "@/lib/scan-to-wizard"
import { renderTemplate } from "@/lib/template-render"
import { getMimeTypeForFormat } from "@/lib/wizard-utils"
import type { RepoScanSummary } from "@/types/repo-scan"

type RouteContext = {
params: Promise<{ fileId: string }>
}

export async function POST(request: NextRequest, context: RouteContext) {
try {
const { fileId } = await context.params
const payload = (await request.json()) as { scan?: RepoScanSummary | null; format?: string | null }

if (!payload?.scan) {
return NextResponse.json({ error: "Missing scan payload" }, { status: 400 })
}

const { stack, responses, defaultedResponseMeta } = await buildResponsesFromScan(payload.scan)
responses.outputFile = fileId

const rendered = await renderTemplate({
responses,
frameworkFromPath: stack,
fileNameFromPath: fileId,
defaultedResponses: defaultedResponseMeta,
})

return NextResponse.json({
fileName: rendered.fileName,
content: rendered.content,
mimeType: getMimeTypeForFormat(payload.format ?? undefined) ?? null,
})
} catch (error) {
console.error("Failed to generate instructions from scan", error)
return NextResponse.json({ error: "Failed to generate instructions from scan" }, { status: 500 })
}
}
110 changes: 107 additions & 3 deletions app/api/scan-repo/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import type {
RepoScanSummary,
RepoStructureSummary,
} from "@/types/repo-scan"
import { collectConventionValues, normalizeConventionValue } from "@/lib/convention-values"
import { loadStackConventions } from "@/lib/conventions"
import { inferStackFromScan } from "@/lib/scan-to-wizard"

const GITHUB_API_BASE_URL = "https://api.github.com"
const GITHUB_HOSTNAMES = new Set(["github.com", "www.github.com"])
Expand Down Expand Up @@ -112,7 +115,10 @@ const detectStructure = (paths: string[]): RepoStructureSummary => {
}
}

const detectTooling = (paths: string[], pkg: PackageJson | null): { tooling: string[]; testing: string[]; frameworks: string[] } => {
const detectTooling = async (
paths: string[],
pkg: PackageJson | null,
): Promise<{ tooling: string[]; testing: string[]; frameworks: string[] }> => {
const tooling = new Set<string>()
const testing = new Set<string>()
const frameworks = new Set<string>()
Expand All @@ -121,7 +127,10 @@ const detectTooling = (paths: string[], pkg: PackageJson | null): { tooling: str

const matchers: Array<{ pattern: RegExp; value: string; target: Set<string> }> = [
{ pattern: /^requirements\.txt$/, value: "pip", target: tooling },
{ pattern: /^pyproject\.toml$/, value: "Poetry", target: tooling },
{ pattern: /^poetry\.lock$/, value: "Poetry", target: tooling },
{ pattern: /^pipfile$/, value: "Pipenv", target: tooling },
{ pattern: /^pipfile\.lock$/, value: "Pipenv", target: tooling },
{ pattern: /^pyproject\.toml$/, value: "PyProject", target: tooling },
{ pattern: /pom\.xml$/, value: "Maven", target: tooling },
{ pattern: /build\.gradle(\.kts)?$/, value: "Gradle", target: tooling },
{ pattern: /(^|\/)dockerfile$/, value: "Docker", target: tooling },
Expand All @@ -138,13 +147,25 @@ const detectTooling = (paths: string[], pkg: PackageJson | null): { tooling: str
{ pattern: /vite\.config\.(js|cjs|mjs|ts)?$/, value: "Vite", target: tooling },
{ pattern: /rollup\.config\.(js|cjs|mjs|ts)?$/, value: "Rollup", target: tooling },
{ pattern: /tailwind\.config\.(js|cjs|mjs|ts)?$/, value: "Tailwind CSS", target: tooling },
{ pattern: /(^|\/)ruff\.toml$/, value: "Ruff", target: tooling },
{ pattern: /(^|\/)\.ruff\.toml$/, value: "Ruff", target: tooling },
{ pattern: /(^|\/)black\.toml$/, value: "black", target: tooling },
{ pattern: /(^|\/)\.flake8$/, value: "flake8", target: tooling },
{ pattern: /(^|\/)flake8\.cfg$/, value: "flake8", target: tooling },
{ pattern: /(^|\/)mypy\.ini$/, value: "mypy", target: tooling },
{ pattern: /(^|\/)\.mypy\.ini$/, value: "mypy", target: tooling },
{ pattern: /(^|\/)setup\.cfg$/, value: "setup.cfg", target: tooling },
{ pattern: /(^|\/)\.pre-commit-config\.ya?ml$/, value: "pre-commit", target: tooling },
{ pattern: /jest\.config\.(js|cjs|mjs|ts|json)?$/, value: "Jest", target: testing },
{ pattern: /vitest\.(config|setup)/, value: "Vitest", target: testing },
{ pattern: /(^|\/)cypress\//, value: "Cypress", target: testing },
{ pattern: /cypress\.config\.(js|cjs|mjs|ts)?$/, value: "Cypress", target: testing },
{ pattern: /playwright\.config\.(js|cjs|mjs|ts)?$/, value: "Playwright", target: testing },
{ pattern: /karma\.conf(\.js)?$/, value: "Karma", target: testing },
{ pattern: /mocha\./, value: "Mocha", target: testing },
{ pattern: /(^|\/)pytest\.ini$/, value: "pytest", target: testing },
{ pattern: /(^|\/)conftest\.py$/, value: "pytest", target: testing },
{ pattern: /(^|\/)tox\.ini$/, value: "tox", target: testing },
]

for (const { pattern, value, target } of matchers) {
Expand Down Expand Up @@ -259,13 +280,88 @@ const detectTooling = (paths: string[], pkg: PackageJson | null): { tooling: str
}
}

await detectPythonTestingSignals(paths, pkg, testing)

return {
tooling: dedupeAndSort(tooling),
testing: dedupeAndSort(testing),
frameworks: dedupeAndSort(frameworks),
}
}

type TestingConventionValues = {
unit: string[]
e2e: string[]
}

const testingConventionCache = new Map<string, TestingConventionValues>()

const getTestingConventionValues = async (stackId: string): Promise<TestingConventionValues> => {
const normalized = stackId.trim().toLowerCase()
if (testingConventionCache.has(normalized)) {
return testingConventionCache.get(normalized)!
}

const { conventions } = await loadStackConventions(normalized)
const values: TestingConventionValues = {
unit: collectConventionValues(conventions, "testingUT"),
e2e: collectConventionValues(conventions, "testingE2E"),
}
testingConventionCache.set(normalized, values)
return values
}

const findConventionValue = (values: string[], target: string): string | null => {
const normalizedTarget = normalizeConventionValue(target)
return values.find((value) => normalizeConventionValue(value) === normalizedTarget) ?? null
}

const BEHAVE_DEPENDENCIES = ["behave", "behave-django", "behave-webdriver"]

export const detectPythonTestingSignals = async (
paths: string[],
pkg: PackageJson | null,
testing: Set<string>,
): Promise<void> => {
const { unit } = await getTestingConventionValues("python")
if (unit.length === 0) {
return
}

const behaveValue = findConventionValue(unit, "behave")
const unittestValue = findConventionValue(unit, "unittest")

if (!behaveValue && !unittestValue) {
return
}

const lowerCasePaths = paths.map((path) => path.toLowerCase())

if (behaveValue) {
const hasFeaturesDir = lowerCasePaths.some((path) => path.startsWith("features/") || path.includes("/features/"))
const hasStepsDir = lowerCasePaths.some((path) => path.includes("/steps/"))
const hasEnvironment = lowerCasePaths.some((path) => path.endsWith("/environment.py") || path.endsWith("environment.py"))
const hasDependency = pkg ? dependencyHas(pkg, BEHAVE_DEPENDENCIES) : false

if (hasDependency || (hasFeaturesDir && (hasStepsDir || hasEnvironment))) {
testing.add(behaveValue)
}
}

if (unittestValue) {
const hasUnitFiles = lowerCasePaths.some((path) => {
if (!/(^|\/)(tests?|testcases|specs)\//.test(path)) {
return false
}
return /(^|\/)(test_[^/]+|[^/]+_test)\.py$/.test(path)
})

if (hasUnitFiles) {
testing.add(unittestValue)
}
}
}

const readPackageJson = async (
owner: string,
repo: string,
Expand Down Expand Up @@ -753,7 +849,7 @@ export async function GET(request: NextRequest): Promise<NextResponse<RepoScanRe

const packageJson = hasPackageJson ? await readPackageJson(owner, repo, defaultBranch, headers) : null

const { tooling, testing, frameworks } = detectTooling(paths, packageJson)
const { tooling, testing, frameworks } = await detectTooling(paths, packageJson)

if (lowestRateLimit !== null && lowestRateLimit < 5) {
warnings.push(`GitHub API rate limit is low (remaining: ${lowestRateLimit}).`)
Expand All @@ -775,6 +871,14 @@ export async function GET(request: NextRequest): Promise<NextResponse<RepoScanRe
...enriched,
}

const detectedStack = inferStackFromScan(summary)
const { conventions, hasStackFile } = await loadStackConventions(detectedStack)
summary.conventions = {
stack: detectedStack,
hasCustomConventions: hasStackFile,
structureRelevant: conventions.structureRelevant,
}

return NextResponse.json<RepoScanSummary>(summary)
} catch (error) {
console.error("Unexpected error while scanning repository", error)
Expand Down
30 changes: 28 additions & 2 deletions app/existing/[repoUrl]/repo-scan-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import RepoScanLoader from "@/components/repo-scan-loader"
import type { GeneratedFileResult } from "@/types/output"

const buildQuery = (url: string) => `/api/scan-repo?url=${encodeURIComponent(url)}`
const CONVENTIONS_DOC_URL =
process.env.NEXT_PUBLIC_CONVENTIONS_URL ?? "https://github.com/devcontext-ai/devcontext/tree/main/conventions"

const formatList = (values: string[]) => (values.length > 0 ? values.join(", ") : "Not detected")

Expand Down Expand Up @@ -99,9 +101,11 @@ export default function RepoScanClient({ initialRepoUrl }: RepoScanClientProps)
return []
}

return Object.entries(scanResult.structure).map(([key, value]) => ({
const relevantKeys = scanResult.conventions?.structureRelevant ?? ["src", "components", "tests", "apps", "packages"]

return relevantKeys.map((key) => ({
key,
value,
value: scanResult.structure[key as keyof RepoScanSummary["structure"]] ?? false,
}))
}, [scanResult])

Expand Down Expand Up @@ -224,6 +228,28 @@ export default function RepoScanClient({ initialRepoUrl }: RepoScanClientProps)
</p>
</section>
<section className="grid gap-6 md:grid-cols-2">
{scanResult.conventions && !scanResult.conventions.hasCustomConventions ? (
<div className="md:col-span-2 rounded-2xl border border-dashed border-border/60 bg-background/70 p-5">
<div className="flex flex-col gap-2 text-sm text-muted-foreground">
<span>
We don’t have conventions for <span className="font-semibold text-foreground">{scanResult.conventions.stack}</span> yet.
</span>
<span>
Add a new <code className="rounded bg-muted px-1 py-0.5 text-xs">conventions/{scanResult.conventions.stack}.json</code> file to customize detection and defaults.
</span>
<div>
<Link
href={CONVENTIONS_DOC_URL}
target="_blank"
rel="noreferrer"
className="inline-flex items-center rounded-full border border-border/60 px-3 py-1 text-xs font-semibold text-primary hover:border-primary/60"
>
View conventions directory
</Link>
</div>
</div>
</div>
) : null}
<div className="rounded-2xl border border-border/60 bg-background/70 p-5">
<div className="flex items-center gap-2 text-sm font-semibold">
<CheckCircle2 className="size-4 text-primary" aria-hidden="true" />
Expand Down
Loading