diff --git a/.gitignore b/.gitignore index 7e0a349..176cb21 100644 --- a/.gitignore +++ b/.gitignore @@ -117,3 +117,4 @@ pnpm-debug.log* # vercel .vercel/ +test-results/ diff --git a/README.md b/README.md index a2ff3cd..58236f2 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app/api/scan-generate/[fileId]/route.ts b/app/api/scan-generate/[fileId]/route.ts new file mode 100644 index 0000000..5ad9148 --- /dev/null +++ b/app/api/scan-generate/[fileId]/route.ts @@ -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 }) + } +} diff --git a/app/api/scan-repo/route.ts b/app/api/scan-repo/route.ts index b4408a4..e0d4513 100644 --- a/app/api/scan-repo/route.ts +++ b/app/api/scan-repo/route.ts @@ -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"]) @@ -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() const testing = new Set() const frameworks = new Set() @@ -121,7 +127,10 @@ const detectTooling = (paths: string[], pkg: PackageJson | null): { tooling: str const matchers: Array<{ pattern: RegExp; value: string; target: Set }> = [ { 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 }, @@ -138,6 +147,15 @@ 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 }, @@ -145,6 +163,9 @@ const detectTooling = (paths: string[], pkg: PackageJson | null): { tooling: str { 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) { @@ -259,6 +280,8 @@ const detectTooling = (paths: string[], pkg: PackageJson | null): { tooling: str } } + await detectPythonTestingSignals(paths, pkg, testing) + return { tooling: dedupeAndSort(tooling), testing: dedupeAndSort(testing), @@ -266,6 +289,79 @@ const detectTooling = (paths: string[], pkg: PackageJson | null): { tooling: str } } +type TestingConventionValues = { + unit: string[] + e2e: string[] +} + +const testingConventionCache = new Map() + +const getTestingConventionValues = async (stackId: string): Promise => { + 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, +): Promise => { + 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, @@ -753,7 +849,7 @@ export async function GET(request: NextRequest): Promise(summary) } catch (error) { console.error("Unexpected error while scanning repository", error) diff --git a/app/existing/[repoUrl]/repo-scan-client.tsx b/app/existing/[repoUrl]/repo-scan-client.tsx index 726b22b..1c1b85a 100644 --- a/app/existing/[repoUrl]/repo-scan-client.tsx +++ b/app/existing/[repoUrl]/repo-scan-client.tsx @@ -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") @@ -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]) @@ -224,6 +228,28 @@ export default function RepoScanClient({ initialRepoUrl }: RepoScanClientProps)

+ {scanResult.conventions && !scanResult.conventions.hasCustomConventions ? ( +
+
+ + We don’t have conventions for {scanResult.conventions.stack} yet. + + + Add a new conventions/{scanResult.conventions.stack}.json file to customize detection and defaults. + +
+ + View conventions directory + +
+
+
+ ) : null}