From 794a421f231fc9d7a6ae803502e61ee05ace664d Mon Sep 17 00:00:00 2001
From: spivakov83
Date: Wed, 15 Oct 2025 15:59:03 +0300
Subject: [PATCH 01/10] feat: enhance tooling detection and stack inference for
Python projects
---
app/api/scan-repo/route.ts | 17 +++++-
app/existing/[repoUrl]/repo-scan-client.tsx | 37 ++++++++++--
file-templates/agents-template.md | 2 +-
.../copilot-instructions-template.md | 33 +++++------
file-templates/cursor-rules-template.json | 2 +-
lib/scan-to-wizard.ts | 57 +++++++++++++++++--
lib/stack-guidance.ts | 6 +-
lib/template-render.ts | 16 +++++-
8 files changed, 139 insertions(+), 31 deletions(-)
diff --git a/app/api/scan-repo/route.ts b/app/api/scan-repo/route.ts
index b4408a4..09bdb8f 100644
--- a/app/api/scan-repo/route.ts
+++ b/app/api/scan-repo/route.ts
@@ -121,7 +121,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 +141,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 +157,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) {
diff --git a/app/existing/[repoUrl]/repo-scan-client.tsx b/app/existing/[repoUrl]/repo-scan-client.tsx
index 726b22b..64d96c7 100644
--- a/app/existing/[repoUrl]/repo-scan-client.tsx
+++ b/app/existing/[repoUrl]/repo-scan-client.tsx
@@ -14,6 +14,7 @@ import { generateFromRepoScan } from "@/lib/scan-generate"
import FinalOutputView from "@/components/final-output-view"
import RepoScanLoader from "@/components/repo-scan-loader"
import type { GeneratedFileResult } from "@/types/output"
+import { inferStackFromScan } from "@/lib/scan-to-wizard"
const buildQuery = (url: string) => `/api/scan-repo?url=${encodeURIComponent(url)}`
@@ -99,10 +100,38 @@ export default function RepoScanClient({ initialRepoUrl }: RepoScanClientProps)
return []
}
- return Object.entries(scanResult.structure).map(([key, value]) => ({
- key,
- value,
- }))
+ const stack = inferStackFromScan(scanResult)
+ const stackCategory = (() => {
+ if (stack === "python") return "python"
+ if (["nextjs", "react", "angular", "vue", "svelte", "nuxt", "astro", "remix"].includes(stack)) {
+ return "frontend"
+ }
+ return "general"
+ })()
+
+ const metadata: Record<
+ keyof RepoScanSummary["structure"],
+ { label: string; categories: Array<"any" | "frontend" | "python" | "general"> }
+ > = {
+ src: { label: "src", categories: ["any"] },
+ components: { label: "components", categories: ["frontend"] },
+ tests: { label: "tests", categories: ["any"] },
+ apps: { label: "apps", categories: ["frontend", "general"] },
+ packages: { label: "packages", categories: ["frontend", "general"] },
+ }
+
+ return (Object.entries(scanResult.structure) as Array<[keyof RepoScanSummary["structure"], boolean]>)
+ .map(([key, value]) => {
+ const details = metadata[key] ?? { label: key, categories: ["general"] }
+ const showEntry = value || details.categories.includes("any") || details.categories.includes(stackCategory)
+ return showEntry
+ ? {
+ key: details.label,
+ value,
+ }
+ : null
+ })
+ .filter((entry): entry is { key: string; value: boolean } => Boolean(entry))
}, [scanResult])
const handleStartScan = () => {
diff --git a/file-templates/agents-template.md b/file-templates/agents-template.md
index d22f8e9..11c77f1 100644
--- a/file-templates/agents-template.md
+++ b/file-templates/agents-template.md
@@ -58,7 +58,7 @@ This guide provides conventions and best practices for building AI agent applica
### Performance & Monitoring
- Logging: **{{logging}}**
-- Performance considerations: **{{reactPerf}}**
+- Performance focus: **{{reactPerf}}**
- Additional concerns:
- Monitor token usage and cost efficiency.
- Handle API rate limits gracefully.
diff --git a/file-templates/copilot-instructions-template.md b/file-templates/copilot-instructions-template.md
index ac0e0d6..0eda7ff 100644
--- a/file-templates/copilot-instructions-template.md
+++ b/file-templates/copilot-instructions-template.md
@@ -1,6 +1,6 @@
---
# Configuration for Copilot in this project
-applyTo: "**/*.{ts,tsx,js,jsx,md}" # apply to all code files by default
+applyTo: "{{applyToGlob}}" # apply to relevant code files by default
---
# Copilot Instructions
@@ -39,10 +39,10 @@ Regenerate whenever your JSON configuration changes (stack, naming, testing, etc
- Code style: follow **{{codeStyle}}**
### File and Folder Structure
-- Component / UI layout: **{{fileStructure}}**
-- Styling approach: **{{styling}}**
-- State management: **{{stateManagement}}**
-- API layer organization: **{{apiLayer}}**
+- Module / feature layout: **{{fileStructure}}**
+- Styling approach (if applicable): **{{styling}}**
+- State management / shared context: **{{stateManagement}}**
+- API / service layer organization: **{{apiLayer}}**
- Folder strategy: **{{folders}}**
@@ -63,19 +63,20 @@ Regenerate whenever your JSON configuration changes (stack, naming, testing, etc
---
-## 5. Performance & Data Loading
+## 5. Performance & Data Handling
- Data fetching: **{{dataFetching}}**
-- React performance optimizations: **{{reactPerf}}**
+- Performance focus: **{{reactPerf}}**
**Do**
-- Use pagination or limit queries.
-- Memoize expensive computations.
-- Lazy-load non-critical modules.
+- Use pagination or streaming for large datasets.
+- Cache or memoize expensive work when it matters.
+- Offload non-critical processing to background tasks.
**Don’t**
-- Fetch all data at once.
-- Put heavy logic in render without memoization.
+- Load entire datasets eagerly without need.
+- Block hot execution paths with heavy synchronous work.
+- Skip instrumentation that would surface performance regressions.
---
@@ -89,7 +90,7 @@ Regenerate whenever your JSON configuration changes (stack, naming, testing, etc
- Never commit secrets; use environment variables.
- Validate all incoming data (API and client).
- Do not log secrets or PII.
-- Use structured/contextual logs instead of raw `console.log`.
+- Use structured/contextual logs instead of raw print/log statements.
---
@@ -112,10 +113,10 @@ Regenerate whenever your JSON configuration changes (stack, naming, testing, etc
## 8. Copilot Usage Guidance
-- Use Copilot for boilerplate (hooks, component scaffolds).
+- Use Copilot for boilerplate (e.g., scaffolds, repetitive wiring).
- Provide context in comments/prompts.
- Reject completions that break naming, structure, or validation rules.
-- Ask clarifying questions in comments (e.g., “// Should this live in services?”).
+- Ask clarifying questions in comments (e.g., “# Should this live in services?”).
- Prefer completions that respect folder boundaries and import paths.
**Don’t rely on Copilot for**
@@ -130,7 +131,7 @@ Regenerate whenever your JSON configuration changes (stack, naming, testing, etc
Recommended editor configuration:
- Use `.editorconfig` for indentation/line endings.
-- Enable linting/formatting (ESLint, Prettier, or Biome).
+- Enable linting/formatting (ESLint, Prettier, Ruff, Black, etc.).
- Set `editor.formatOnSave = true`.
- Suggested integrations:
- VS Code: `dbaeumer.vscode-eslint`, `esbenp.prettier-vscode`
diff --git a/file-templates/cursor-rules-template.json b/file-templates/cursor-rules-template.json
index a92e874..f806fbe 100644
--- a/file-templates/cursor-rules-template.json
+++ b/file-templates/cursor-rules-template.json
@@ -27,7 +27,7 @@
},
"performance": {
"dataFetching": "{{dataFetching}}",
- "reactOptimizations": "{{reactPerf}}"
+ "focus": "{{reactPerf}}"
},
"security": {
"auth": "{{auth}}",
diff --git a/lib/scan-to-wizard.ts b/lib/scan-to-wizard.ts
index fc6d291..6fe015e 100644
--- a/lib/scan-to-wizard.ts
+++ b/lib/scan-to-wizard.ts
@@ -17,6 +17,8 @@ const pickStack = (scan: RepoScanSummary): string => {
return "react"
}
+export const inferStackFromScan = (scan: RepoScanSummary): string => pickStack(scan)
+
const pickLanguage = (scan: RepoScanSummary): string | null => {
const languages = (scan.languages ?? []).map((l) => l.toLowerCase())
if (languages.includes("typescript")) return "typescript"
@@ -27,6 +29,8 @@ const pickLanguage = (scan: RepoScanSummary): string | null => {
const pickTestingUT = (scan: RepoScanSummary): string | null => {
const testing = (scan.testing ?? []).map((t) => t.toLowerCase())
+ if (testing.includes("pytest")) return "pytest"
+ if (testing.includes("unittest")) return "unittest"
if (testing.includes("vitest")) return "vitest"
if (testing.includes("jest")) return "jest"
return null
@@ -42,6 +46,7 @@ const pickTestingE2E = (scan: RepoScanSummary): string | null => {
const pickStyling = (scan: RepoScanSummary, stack: string): string => {
const tooling = (scan.tooling ?? []).map((t) => t.toLowerCase())
if (tooling.includes("tailwind css") || tooling.includes("tailwind")) return "tailwind"
+ if (stack === "python") return "Not applicable (backend Python project)"
return stack === "nextjs" ? "tailwind" : "cssmodules"
}
@@ -68,7 +73,15 @@ const pickComponentNaming = (scan: RepoScanSummary): string => {
return detected === "camelcase" ? "camelCase" : "PascalCase"
}
-const pickCodeStyle = (scan: RepoScanSummary): string => {
+const pickCodeStyle = (scan: RepoScanSummary, stack: string): string => {
+ if (stack === "python") {
+ const tooling = (scan.tooling ?? []).map((t) => t.toLowerCase())
+ if (tooling.includes("ruff")) return "ruff"
+ if (tooling.includes("black")) return "black"
+ if (tooling.includes("pyproject.toml")) return "pep8"
+ if (tooling.includes("pipenv")) return "pep8"
+ return "pep8"
+ }
const detected = (scan as any).codeStylePreference as string | undefined | null
if (!detected) return "airbnb"
if (detected === "standardjs") return "standardjs"
@@ -77,6 +90,13 @@ const pickCodeStyle = (scan: RepoScanSummary): string => {
}
const pickFileStructure = (scan: RepoScanSummary, stack: string): string => {
+ if (stack === "python") {
+ const hasSrc = scan.structure?.src ?? false
+ const hasPackagesDir = scan.structure?.packages ?? false
+ if (hasSrc) return "src layout (src/)"
+ if (hasPackagesDir) return "packages directory (monorepo-style)"
+ return "top-level modules"
+ }
if (stack === "nextjs") {
// Prefer App Router by default; hybrid/pages if hints present (see enriched fields if any)
const routing = (scan as any).routing as string | undefined
@@ -91,30 +111,35 @@ const pickFileStructure = (scan: RepoScanSummary, stack: string): string => {
const pickStateMgmt = (scan: RepoScanSummary, stack: string): string => {
const detected = ((scan as any).stateMgmt as string | undefined) ?? null
if (detected) return detected
+ if (stack === "python") return "Not applicable"
return stack === "nextjs" ? "zustand" : "context-hooks"
}
const pickDataFetching = (scan: RepoScanSummary, stack: string): string => {
const detected = ((scan as any).dataFetching as string | undefined) ?? null
if (detected) return detected
+ if (stack === "python") return "Not applicable"
return stack === "nextjs" ? "server-components" : "swr"
}
const pickAuth = (scan: RepoScanSummary, stack: string): string => {
const detected = ((scan as any).auth as string | undefined) ?? null
if (detected) return detected
+ if (stack === "python") return "TODO: document auth/session strategy"
return stack === "nextjs" ? "next-auth" : "env"
}
-const pickValidation = (scan: RepoScanSummary): string => {
+const pickValidation = (scan: RepoScanSummary, stack: string): string => {
const detected = ((scan as any).validation as string | undefined) ?? null
if (detected) return detected
+ if (stack === "python") return "TODO: specify validation library (Pydantic, Marshmallow, etc.)"
return "zod"
}
const pickLogging = (scan: RepoScanSummary, stack: string): string => {
const detected = ((scan as any).logging as string | undefined) ?? null
if (detected) return detected
+ if (stack === "python") return "TODO: describe logging approach (structlog, stdlib logging)"
return stack === "nextjs" ? "sentry" : "structured"
}
@@ -135,6 +160,7 @@ const pickPrRules = (scan: RepoScanSummary): string => {
const buildToolingSummary = (scan: RepoScanSummary, stack: string): string => {
const parts = scan.tooling ?? []
if (parts.length > 0) return parts.join(" + ")
+ if (stack === "python") return "pip"
return stack === "nextjs" ? "create-next-app" : stack === "react" ? "vite" : "custom-config"
}
@@ -156,7 +182,7 @@ export function buildResponsesFromScan(scan: RepoScanSummary): ScanToWizardResul
testingUT: pickTestingUT(scan),
testingE2E: pickTestingE2E(scan),
projectPriority: "maintainability",
- codeStyle: pickCodeStyle(scan),
+ codeStyle: pickCodeStyle(scan, stack),
variableNaming: "camelCase",
fileNaming: pickFileNaming(scan),
componentNaming: pickComponentNaming(scan),
@@ -169,12 +195,35 @@ export function buildResponsesFromScan(scan: RepoScanSummary): ScanToWizardResul
dataFetching: pickDataFetching(scan, stack),
reactPerf: "memoHooks",
auth: pickAuth(scan, stack),
- validation: pickValidation(scan),
+ validation: pickValidation(scan, stack),
logging: pickLogging(scan, stack),
commitStyle: pickCommitStyle(scan),
prRules: pickPrRules(scan),
outputFile: null,
}
+ if (stack === "python") {
+ responses.tooling = responses.tooling ?? "pip"
+ responses.language =
+ responses.language && responses.language.toLowerCase() === "python" ? "Python" : responses.language ?? "Python"
+ responses.variableNaming = "snake_case"
+ responses.fileNaming = "snake_case"
+ responses.componentNaming = "Not applicable"
+ responses.exports = "module exports"
+ responses.comments = "docstrings"
+ responses.stateManagement = "Not applicable"
+ responses.apiLayer = "TODO: document primary framework (FastAPI, Django, Flask, etc.)"
+ responses.folders = "by-module"
+ responses.dataFetching = "Not applicable"
+ responses.reactPerf = "Not applicable"
+ responses.testingUT = responses.testingUT ?? "pytest"
+ responses.testingE2E = "TODO: outline integration / end-to-end coverage"
+ responses.styling = "Not applicable (backend Python project)"
+ responses.codeStyle = responses.codeStyle ?? "pep8"
+ responses.auth = responses.auth ?? "TODO: document auth/session strategy"
+ responses.validation = responses.validation ?? "TODO: specify validation library (Pydantic, Marshmallow, etc.)"
+ responses.logging = responses.logging ?? "TODO: describe logging approach (structlog, stdlib logging)"
+ }
+
return { stack, responses }
}
diff --git a/lib/stack-guidance.ts b/lib/stack-guidance.ts
index 1b1f60d..3ef3d19 100644
--- a/lib/stack-guidance.ts
+++ b/lib/stack-guidance.ts
@@ -47,9 +47,9 @@ const stackGuidanceBySlug: Record = {
"Keep links, forms, and nested routes aligned with Remix conventions to benefit from built-in optimizations.",
]),
python: asMarkdownList([
- "Call out whether FastAPI, Django, or Flask is the project's default framework.",
- "Define typing expectations (mypy, Ruff, or dynamic) to keep contributions consistent.",
- "Describe package management commands (Poetry, pip-tools, uv) for installing and locking dependencies.",
+ "TODO: Call out whether FastAPI, Django, or Flask is the project's default framework.",
+ "TODO: Define typing expectations (mypy, Ruff, or dynamic) to keep contributions consistent.",
+ "TODO: Describe package management commands (Poetry, pip-tools, uv) for installing and locking dependencies.",
]),
}
diff --git a/lib/template-render.ts b/lib/template-render.ts
index e924c06..afbed87 100644
--- a/lib/template-render.ts
+++ b/lib/template-render.ts
@@ -5,6 +5,20 @@ import type { WizardResponses } from '@/types/wizard'
import { getTemplateConfig, type TemplateKey } from '@/lib/template-config'
import { getStackGuidance } from '@/lib/stack-guidance'
+const determineApplyToGlob = (responses: WizardResponses, stackSlug?: string): string => {
+ const normalizedStack = (responses.stackSelection || stackSlug || '').trim().toLowerCase()
+
+ if (normalizedStack === 'python') {
+ return '**/*.{py,pyi,md}'
+ }
+
+ if (['nextjs', 'react', 'angular', 'vue', 'svelte', 'nuxt', 'astro', 'remix'].includes(normalizedStack)) {
+ return '**/*.{ts,tsx,js,jsx,md}'
+ }
+
+ return '**/*.{ts,tsx,js,jsx,md}'
+}
+
function mapOutputFileToTemplateType(outputFile: string): string {
const mapping: Record = {
'instructions-md': 'copilot-instructions',
@@ -132,6 +146,7 @@ export async function renderTemplate({
const stackGuidanceSlug = responses.stackSelection || framework
const stackGuidance = getStackGuidance(stackGuidanceSlug)
replaceStaticPlaceholder('stackGuidance', stackGuidance)
+ replaceStaticPlaceholder('applyToGlob', determineApplyToGlob(responses, stackGuidanceSlug))
return {
content: generatedContent,
@@ -139,4 +154,3 @@ export async function renderTemplate({
isJson: isJsonTemplate,
}
}
-
From 354b23228ce0424f62bae1bdaee538e748930f4b Mon Sep 17 00:00:00 2001
From: spivakov83
Date: Wed, 15 Oct 2025 17:00:20 +0300
Subject: [PATCH 02/10] feat: Add conventions for various frameworks and
languages
- Introduced conventions for Next.js, Nuxt, Python, React, Remix, Svelte, and Vue.
- Each convention includes default settings and rules based on tooling and testing frameworks.
- Enhanced the scan flow documentation to outline how repository scans are transformed into AI instruction defaults.
- Updated the wizard summary and storage to accommodate new defaulted question tracking.
- Implemented logic to apply convention rules based on detected tooling and testing during repository scans.
- Added types for conventions to improve type safety and clarity in the codebase.
---
README.md | 3 +
app/api/scan-generate/[fileId]/route.ts | 40 +++
app/api/scan-repo/route.ts | 10 +
app/existing/[repoUrl]/repo-scan-client.tsx | 61 ++--
app/new/stack/stack-summary-page.tsx | 22 +-
components/wizard-completion-summary.tsx | 5 +
conventions/angular.json | 40 +++
conventions/astro.json | 28 ++
conventions/default.json | 31 ++
conventions/nextjs.json | 40 +++
conventions/nuxt.json | 26 ++
conventions/python.json | 62 ++++
conventions/react.json | 24 ++
conventions/remix.json | 24 ++
conventions/svelte.json | 33 ++
conventions/vue.json | 29 ++
docs/scan-flow.md | 49 +++
lib/__tests__/wizard-summary.test.ts | 2 +
lib/conventions.ts | 136 +++++++
lib/scan-generate.ts | 30 +-
lib/scan-prefill.ts | 8 +-
lib/scan-to-wizard.ts | 377 ++++++++++----------
lib/wizard-storage.ts | 1 +
lib/wizard-summary-data.ts | 10 +-
lib/wizard-summary.ts | 3 +
types/conventions.ts | 35 ++
types/repo-scan.ts | 7 +
27 files changed, 906 insertions(+), 230 deletions(-)
create mode 100644 app/api/scan-generate/[fileId]/route.ts
create mode 100644 conventions/angular.json
create mode 100644 conventions/astro.json
create mode 100644 conventions/default.json
create mode 100644 conventions/nextjs.json
create mode 100644 conventions/nuxt.json
create mode 100644 conventions/python.json
create mode 100644 conventions/react.json
create mode 100644 conventions/remix.json
create mode 100644 conventions/svelte.json
create mode 100644 conventions/vue.json
create mode 100644 docs/scan-flow.md
create mode 100644 lib/conventions.ts
create mode 100644 types/conventions.ts
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..c58e8e6
--- /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 } = await buildResponsesFromScan(payload.scan)
+ responses.outputFile = fileId
+
+ const rendered = await renderTemplate({
+ responses,
+ frameworkFromPath: stack,
+ fileNameFromPath: fileId,
+ })
+
+ 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 09bdb8f..25abd3a 100644
--- a/app/api/scan-repo/route.ts
+++ b/app/api/scan-repo/route.ts
@@ -6,6 +6,8 @@ import type {
RepoScanSummary,
RepoStructureSummary,
} from "@/types/repo-scan"
+import { inferStackFromScan } from "@/lib/scan-to-wizard"
+import { loadStackConventions } from "@/lib/conventions"
const GITHUB_API_BASE_URL = "https://api.github.com"
const GITHUB_HOSTNAMES = new Set(["github.com", "www.github.com"])
@@ -790,6 +792,14 @@ 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 64d96c7..1c1b85a 100644
--- a/app/existing/[repoUrl]/repo-scan-client.tsx
+++ b/app/existing/[repoUrl]/repo-scan-client.tsx
@@ -14,9 +14,10 @@ import { generateFromRepoScan } from "@/lib/scan-generate"
import FinalOutputView from "@/components/final-output-view"
import RepoScanLoader from "@/components/repo-scan-loader"
import type { GeneratedFileResult } from "@/types/output"
-import { inferStackFromScan } from "@/lib/scan-to-wizard"
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")
@@ -100,38 +101,12 @@ export default function RepoScanClient({ initialRepoUrl }: RepoScanClientProps)
return []
}
- const stack = inferStackFromScan(scanResult)
- const stackCategory = (() => {
- if (stack === "python") return "python"
- if (["nextjs", "react", "angular", "vue", "svelte", "nuxt", "astro", "remix"].includes(stack)) {
- return "frontend"
- }
- return "general"
- })()
+ const relevantKeys = scanResult.conventions?.structureRelevant ?? ["src", "components", "tests", "apps", "packages"]
- const metadata: Record<
- keyof RepoScanSummary["structure"],
- { label: string; categories: Array<"any" | "frontend" | "python" | "general"> }
- > = {
- src: { label: "src", categories: ["any"] },
- components: { label: "components", categories: ["frontend"] },
- tests: { label: "tests", categories: ["any"] },
- apps: { label: "apps", categories: ["frontend", "general"] },
- packages: { label: "packages", categories: ["frontend", "general"] },
- }
-
- return (Object.entries(scanResult.structure) as Array<[keyof RepoScanSummary["structure"], boolean]>)
- .map(([key, value]) => {
- const details = metadata[key] ?? { label: key, categories: ["general"] }
- const showEntry = value || details.categories.includes("any") || details.categories.includes(stackCategory)
- return showEntry
- ? {
- key: details.label,
- value,
- }
- : null
- })
- .filter((entry): entry is { key: string; value: boolean } => Boolean(entry))
+ return relevantKeys.map((key) => ({
+ key,
+ value: scanResult.structure[key as keyof RepoScanSummary["structure"]] ?? false,
+ }))
}, [scanResult])
const handleStartScan = () => {
@@ -253,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}
diff --git a/app/new/stack/stack-summary-page.tsx b/app/new/stack/stack-summary-page.tsx
index 9996172..80ab27c 100644
--- a/app/new/stack/stack-summary-page.tsx
+++ b/app/new/stack/stack-summary-page.tsx
@@ -39,6 +39,7 @@ export function StackSummaryPage({ stackId, mode }: StackSummaryPageProps) {
const [responses, setResponses] = useState
(null)
const [freeTextResponses, setFreeTextResponses] = useState({})
const [autoFilledMap, setAutoFilledMap] = useState>({})
+ const [defaultedMap, setDefaultedMap] = useState>({})
const [stackLabel, setStackLabel] = useState(null)
const [autoFillNotice, setAutoFillNotice] = useState(null)
const [isLoading, setIsLoading] = useState(true)
@@ -66,6 +67,7 @@ export function StackSummaryPage({ stackId, mode }: StackSummaryPageProps) {
responses: defaultResponses,
freeTextResponses: defaultFreeTextResponses,
autoFilledMap: defaultsMap,
+ defaultedMap: defaultsAppliedMap,
stackLabel: label,
} =
await buildDefaultSummaryData(stackId)
@@ -78,6 +80,7 @@ export function StackSummaryPage({ stackId, mode }: StackSummaryPageProps) {
setResponses(defaultResponses)
setFreeTextResponses(defaultFreeTextResponses)
setAutoFilledMap(defaultsMap)
+ setDefaultedMap(defaultsAppliedMap)
setStackLabel(label)
setAutoFillNotice("We applied the recommended defaults for you. Tweak any section before generating.")
@@ -87,6 +90,7 @@ export function StackSummaryPage({ stackId, mode }: StackSummaryPageProps) {
responses: defaultResponses,
freeTextResponses: defaultFreeTextResponses,
autoFilledMap: defaultsMap,
+ defaultedMap: defaultsAppliedMap,
updatedAt: Date.now(),
})
} else {
@@ -103,6 +107,7 @@ export function StackSummaryPage({ stackId, mode }: StackSummaryPageProps) {
setResponses(null)
setFreeTextResponses({})
setAutoFilledMap({})
+ setDefaultedMap({})
setStackLabel(computedLabel)
setAutoFillNotice(null)
setErrorMessage("We couldn't find saved answers for this stack. Complete the wizard to generate your own summary.")
@@ -118,6 +123,7 @@ export function StackSummaryPage({ stackId, mode }: StackSummaryPageProps) {
setResponses(normalizedResponses)
setFreeTextResponses(storedState.freeTextResponses ?? {})
setAutoFilledMap(storedState.autoFilledMap ?? {})
+ setDefaultedMap(storedState.defaultedMap ?? {})
setStackLabel(storedState.stackLabel ?? computedLabel)
setAutoFillNotice(null)
}
@@ -153,9 +159,10 @@ export function StackSummaryPage({ stackId, mode }: StackSummaryPageProps) {
responses,
freeTextResponses,
autoFilledMap,
+ defaultedMap,
false
)
- }, [wizardSteps, responses, freeTextResponses, autoFilledMap])
+ }, [wizardSteps, responses, freeTextResponses, autoFilledMap, defaultedMap])
const handleGenerate = useCallback(
async (fileOption: FileOutputConfig) => {
@@ -237,8 +244,12 @@ export function StackSummaryPage({ stackId, mode }: StackSummaryPageProps) {
const nextAutoFilledMap = { ...autoFilledMap }
delete nextAutoFilledMap[question.id]
+ const nextDefaultedMap = { ...defaultedMap }
+ delete nextDefaultedMap[question.id]
+
setFreeTextResponses(nextFreeText)
setAutoFilledMap(nextAutoFilledMap)
+ setDefaultedMap(nextDefaultedMap)
setAutoFillNotice(null)
if (stackId) {
@@ -248,19 +259,21 @@ export function StackSummaryPage({ stackId, mode }: StackSummaryPageProps) {
responses,
freeTextResponses: nextFreeText,
autoFilledMap: nextAutoFilledMap,
+ defaultedMap: nextDefaultedMap,
updatedAt: Date.now(),
})
}
handleCloseEdit()
},
- [responses, freeTextResponses, autoFilledMap, stackId, stackLabel, summaryHeader, handleCloseEdit]
+ [responses, freeTextResponses, autoFilledMap, defaultedMap, stackId, stackLabel, summaryHeader, handleCloseEdit]
)
const applyAnswerUpdate = useCallback(
(question: WizardQuestion, answer: WizardAnswer) => {
const currentResponses: Responses = responses ? { ...responses } : {}
const currentAutoMap = { ...autoFilledMap }
+ const currentDefaultedMap = { ...defaultedMap }
const prevValue = currentResponses[question.id]
let nextValue: Responses[keyof Responses] | undefined
@@ -283,8 +296,10 @@ export function StackSummaryPage({ stackId, mode }: StackSummaryPageProps) {
}
delete currentAutoMap[question.id]
+ delete currentDefaultedMap[question.id]
setResponses(currentResponses)
setAutoFilledMap(currentAutoMap)
+ setDefaultedMap(currentDefaultedMap)
setAutoFillNotice(null)
if (stackId) {
@@ -294,6 +309,7 @@ export function StackSummaryPage({ stackId, mode }: StackSummaryPageProps) {
responses: currentResponses,
freeTextResponses,
autoFilledMap: currentAutoMap,
+ defaultedMap: currentDefaultedMap,
updatedAt: Date.now(),
})
}
@@ -302,7 +318,7 @@ export function StackSummaryPage({ stackId, mode }: StackSummaryPageProps) {
handleCloseEdit()
}
},
- [responses, freeTextResponses, autoFilledMap, stackId, stackLabel, summaryHeader, handleCloseEdit]
+ [responses, freeTextResponses, autoFilledMap, defaultedMap, stackId, stackLabel, summaryHeader, handleCloseEdit]
)
if (isLoading) {
diff --git a/components/wizard-completion-summary.tsx b/components/wizard-completion-summary.tsx
index 55a975d..979c7fd 100644
--- a/components/wizard-completion-summary.tsx
+++ b/components/wizard-completion-summary.tsx
@@ -114,6 +114,11 @@ export function WizardCompletionSummary({
{answer}
+ {index === 0 && entry.isDefaultApplied ? (
+
+ Default applied
+
+ ) : null}
{index === 0 && !entry.isReadOnlyOnSummary ? (
.json` + `default.json`.
+ - Layer in detections from the scan (tooling, testing, naming signals, etc.).
+ - Run convention rules to tweak values based on detected tooling/testing.
+ - Pull default answers directly from the stack’s question set (`buildStepsForStack`) and fill any remaining empty responses. We track which questions were auto-defaulted (`defaultedQuestionIds`) so the summary can explain why.
+
+3. **Persist and surface responses**
+ - `lib/scan-prefill.ts` merges the generated responses into local wizard state and stores both `autoFilledMap` and `defaultedMap` in localStorage.
+ - The instructions wizard and stack summary pages read these maps to highlight that values came from a scan or from default recommendations.
+ - `components/wizard-completion-summary.tsx` displays a “Default applied” badge for any question filled solely by defaults.
+
+4. **Generate instruction files**
+ - From the repo-scan UI, clicking “Generate” calls `lib/scan-generate.ts`, which posts to `/api/scan-generate/[fileId]`.
+ - The API reuses `buildResponsesFromScan` server-side to ensure consistency, then renders the target template with `renderTemplate`.
+ - Template rendering pulls `applyToGlob` from conventions so Copilot instructions target stack-appropriate file globs (e.g. `**/*.{py,pyi,md}` for Python).
+
+## Key Data Sources
+
+| Location | Purpose |
+| --- | --- |
+| `conventions/default.json` & `/conventions/.json` | Declarative defaults + rules for each stack (tooling choices, structure hints, apply-to glob, etc.). |
+| `data/stacks.json` | List of stacks exposed to the wizard; each should have a matching conventions file. |
+| `data/questions/.json` | Stack-specific questions with default answers and metadata. These defaults are now honored automatically when scan data is missing. |
+
+## Extending the Flow
+
+- **Add a new stack**: create `conventions/.json`, add questions under `data/questions/.json`, and register the stack in `data/stacks.json`. The pipeline will pick it up automatically.
+- **Add scan heuristics**: update `app/api/scan-repo/route.ts` (e.g., detect tooling) so conventions rules have richer signals to work with.
+- **Adjust defaults**: edit the stack’s question JSON to set a new `isDefault` answer; the scan pipeline will adopt it whenever the repo lacks an explicit signal.
+- **Customize templates**: templates consume the final `WizardResponses`, so any new fields surfaced via conventions should be represented there before referencing them in markdown/JSON output.
+
+## Notable Behaviors
+
+- If a stack lacks a conventions file, the repo-scan page shows a call-to-action to add one. Structure hints fall back to the global defaults.
+- The completion summary distinguishes between values detected from the scan (`isAutoFilled`) and those populated strictly because a question has a default (`isDefaultApplied`).
+- The `/api/scan-generate` endpoint keeps the server-side and client-side generation logic aligned, preventing divergence between UI previews and API output.
diff --git a/lib/__tests__/wizard-summary.test.ts b/lib/__tests__/wizard-summary.test.ts
index 38030cc..f5627c3 100644
--- a/lib/__tests__/wizard-summary.test.ts
+++ b/lib/__tests__/wizard-summary.test.ts
@@ -41,6 +41,7 @@ describe("buildCompletionSummary", () => {
responses,
freeTextResponses,
{},
+ {},
false
)
@@ -68,6 +69,7 @@ describe("buildCompletionSummary", () => {
responses,
freeTextResponses,
{},
+ {},
false
)
diff --git a/lib/conventions.ts b/lib/conventions.ts
new file mode 100644
index 0000000..ac1b75e
--- /dev/null
+++ b/lib/conventions.ts
@@ -0,0 +1,136 @@
+import { readFile } from "fs/promises"
+import path from "path"
+
+import type { RepoScanSummary, RepoStructureSummary } from "@/types/repo-scan"
+import type { ConventionRule, LoadedConvention, StackConventions } from "@/types/conventions"
+import type { WizardResponses } from "@/types/wizard"
+
+const CONVENTIONS_DIR = path.join(process.cwd(), "conventions")
+
+const BUILTIN_FALLBACK: LoadedConvention = {
+ id: "default",
+ applyToGlob: "**/*.{ts,tsx,js,jsx,md}",
+ structureRelevant: ["src", "components", "tests", "apps", "packages"],
+ defaults: {},
+ rules: [],
+ summaryMessage: null,
+}
+
+type ConventionsCacheEntry = {
+ conventions: LoadedConvention
+ hasStackFile: boolean
+}
+
+const cache = new Map()
+
+const readConventionsFile = async (stackId: string): Promise => {
+ try {
+ const filePath = path.join(CONVENTIONS_DIR, `${stackId}.json`)
+ const raw = await readFile(filePath, "utf8")
+ const parsed = JSON.parse(raw) as StackConventions
+ return parsed
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
+ return null
+ }
+ console.error(`Failed to load conventions for stack "${stackId}"`, error)
+ return null
+ }
+}
+
+const mergeConventions = (
+ stackId: string,
+ base: StackConventions | null,
+ specific: StackConventions | null,
+): LoadedConvention => {
+ const applyToGlob = specific?.applyToGlob ?? base?.applyToGlob ?? BUILTIN_FALLBACK.applyToGlob
+ const structureRelevant =
+ specific?.structureRelevant ?? base?.structureRelevant ?? BUILTIN_FALLBACK.structureRelevant
+
+ return {
+ id: stackId,
+ label: specific?.label ?? base?.label,
+ applyToGlob,
+ structureRelevant,
+ defaults: {
+ ...(base?.defaults ?? {}),
+ ...(specific?.defaults ?? {}),
+ },
+ rules: [...(base?.rules ?? []), ...(specific?.rules ?? [])],
+ summaryMessage:
+ specific?.summaryMessage ?? base?.summaryMessage ?? BUILTIN_FALLBACK.summaryMessage ?? null,
+ }
+}
+
+export type LoadedConventionsResult = ConventionsCacheEntry
+
+export const loadStackConventions = async (stackId: string): Promise => {
+ const normalized = stackId.trim().toLowerCase() || "default"
+ if (cache.has(normalized)) {
+ return cache.get(normalized)!
+ }
+
+ const base = await readConventionsFile("default")
+ const specific = normalized !== "default" ? await readConventionsFile(normalized) : base
+ const hasStackFile = Boolean(specific && normalized !== "default")
+
+ const conventions = mergeConventions(normalized, base, specific)
+ const entry: ConventionsCacheEntry = { conventions, hasStackFile }
+ cache.set(normalized, entry)
+ return entry
+}
+
+const matchesConditionList = (haystack: string[] | undefined | null, needles: string[] | undefined): boolean => {
+ if (!needles || needles.length === 0) {
+ return true
+ }
+ if (!Array.isArray(haystack) || haystack.length === 0) {
+ return false
+ }
+ const normalized = haystack.map((value) => value.toLowerCase())
+ return needles.some((needle) => normalized.includes(needle.toLowerCase()))
+}
+
+const structureHas = (
+ structure: RepoStructureSummary | undefined,
+ keys: Array | undefined,
+ expected: boolean,
+): boolean => {
+ if (!keys || keys.length === 0) {
+ return true
+ }
+ if (!structure) {
+ return false
+ }
+ return keys.every((key) => Boolean(structure[key]) === expected)
+}
+
+const ruleMatches = (rule: ConventionRule, scan: RepoScanSummary): boolean => {
+ const condition = rule.if ?? {}
+ return (
+ matchesConditionList(scan.tooling, condition.toolingIncludes) &&
+ matchesConditionList(scan.testing, condition.testingIncludes) &&
+ matchesConditionList(scan.frameworks, condition.frameworksInclude) &&
+ matchesConditionList(scan.languages, condition.languagesInclude) &&
+ (!condition.routingIs || condition.routingIs.includes((scan.routing ?? "") as typeof condition.routingIs[number])) &&
+ structureHas(scan.structure, condition.structureHas, true) &&
+ structureHas(scan.structure, condition.structureMissing, false)
+ )
+}
+
+export const applyConventionRules = (
+ base: WizardResponses,
+ rules: StackConventions["rules"] | undefined,
+ scan: RepoScanSummary,
+): WizardResponses => {
+ if (!rules || rules.length === 0) {
+ return base
+ }
+
+ return rules.reduce((acc, rule) => {
+ if (ruleMatches(rule, scan)) {
+ return { ...acc, ...rule.set }
+ }
+ return acc
+ }, { ...base })
+}
diff --git a/lib/scan-generate.ts b/lib/scan-generate.ts
index 24582af..e89d3e9 100644
--- a/lib/scan-generate.ts
+++ b/lib/scan-generate.ts
@@ -1,10 +1,9 @@
"use client"
-import { generateInstructions } from "@/lib/instructions-api"
import { getFileOptions } from "@/lib/wizard-config"
+import { getMimeTypeForFormat } from "@/lib/wizard-utils"
import type { RepoScanSummary } from "@/types/repo-scan"
import type { GeneratedFileResult } from "@/types/output"
-import { buildResponsesFromScan } from "@/lib/scan-to-wizard"
const fileOptions = getFileOptions()
@@ -14,17 +13,26 @@ export async function generateFromRepoScan(
scan: RepoScanSummary,
outputFileId: OutputFileId
): Promise {
- const { stack, responses } = buildResponsesFromScan(scan)
- responses.outputFile = outputFileId
-
const selected = fileOptions.find((f) => f.id === outputFileId) || null
- const result = await generateInstructions({
- stackSegment: stack,
- outputFileId,
- responses,
- fileFormat: selected?.format,
+ const res = await fetch(`/api/scan-generate/${encodeURIComponent(outputFileId)}`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ scan, format: selected?.format ?? null }),
})
- return result
+ if (!res.ok) {
+ console.error("Failed to generate instructions from scan", await res.text())
+ return null
+ }
+
+ const payload = (await res.json()) as { fileName: string; content: string; mimeType?: string | null }
+
+ return {
+ fileName: payload.fileName,
+ fileContent: payload.content,
+ mimeType: getMimeTypeForFormat(selected?.format) ?? null,
+ }
}
diff --git a/lib/scan-prefill.ts b/lib/scan-prefill.ts
index 34f3ea5..8285600 100644
--- a/lib/scan-prefill.ts
+++ b/lib/scan-prefill.ts
@@ -23,7 +23,7 @@ const normalizeValueForQuestion = (value: string | null, question: WizardQuestio
}
export const prefillWizardFromScan = async (scan: RepoScanSummary) => {
- const { stack, responses: wizardResponses } = buildResponsesFromScan(scan)
+ const { stack, responses: wizardResponses, defaultedQuestionIds } = await buildResponsesFromScan(scan)
const { steps, stackLabel } = await buildStepsForStack(stack)
const responses: Responses = {
@@ -31,6 +31,7 @@ export const prefillWizardFromScan = async (scan: RepoScanSummary) => {
}
const freeTextResponses: FreeTextResponses = {}
const autoFilledMap: Record = {}
+ const defaultedMap: Record = {}
const setIfPresent = (question: WizardQuestion, allResponses: WizardResponses) => {
const key = (question.responseKey ?? question.id) as keyof WizardResponses
@@ -45,6 +46,10 @@ export const prefillWizardFromScan = async (scan: RepoScanSummary) => {
if (question.id !== STACK_QUESTION_ID) {
autoFilledMap[question.id] = true
}
+
+ if (defaultedQuestionIds[question.id]) {
+ defaultedMap[question.id] = true
+ }
}
steps.forEach((step) => {
@@ -59,6 +64,7 @@ export const prefillWizardFromScan = async (scan: RepoScanSummary) => {
responses,
freeTextResponses,
autoFilledMap,
+ defaultedMap,
updatedAt: Date.now(),
})
diff --git a/lib/scan-to-wizard.ts b/lib/scan-to-wizard.ts
index 6fe015e..a2dee93 100644
--- a/lib/scan-to-wizard.ts
+++ b/lib/scan-to-wizard.ts
@@ -1,229 +1,244 @@
+import { applyConventionRules, loadStackConventions } from "@/lib/conventions"
+import { buildStepsForStack } from "@/lib/wizard-summary-data"
import type { RepoScanSummary } from "@/types/repo-scan"
-import type { WizardResponses } from "@/types/wizard"
+import type { LoadedConvention } from "@/types/conventions"
+import type { WizardResponses, WizardStep } from "@/types/wizard"
-const includesAny = (arr: string[] | undefined | null, patterns: RegExp[]) =>
- Array.isArray(arr) && arr.some((v) => patterns.some((p) => p.test(v.toLowerCase())))
+const STACK_FALLBACK = "react"
-const pickStack = (scan: RepoScanSummary): string => {
- const frameworks = (scan.frameworks ?? []).map((f) => f.toLowerCase())
- const languages = (scan.languages ?? []).map((l) => l.toLowerCase())
+const toLowerArray = (values: string[] | undefined | null) =>
+ Array.isArray(values) ? values.map((value) => value.toLowerCase()) : []
- if (frameworks.some((f) => /next\.?js/.test(f))) return "nextjs"
- if (frameworks.includes("react")) return "react"
+const detectStack = (scan: RepoScanSummary): string => {
+ const frameworks = toLowerArray(scan.frameworks)
+ const languages = toLowerArray(scan.languages)
+
+ if (frameworks.some((name) => /next\.?js/.test(name))) return "nextjs"
+ if (frameworks.includes("nuxt")) return "nuxt"
+ if (frameworks.includes("remix")) return "remix"
+ if (frameworks.includes("astro")) return "astro"
if (frameworks.includes("angular")) return "angular"
if (frameworks.includes("vue")) return "vue"
if (frameworks.includes("svelte")) return "svelte"
+ if (frameworks.includes("react")) return "react"
if (languages.includes("python")) return "python"
- return "react"
-}
-
-export const inferStackFromScan = (scan: RepoScanSummary): string => pickStack(scan)
-
-const pickLanguage = (scan: RepoScanSummary): string | null => {
- const languages = (scan.languages ?? []).map((l) => l.toLowerCase())
+ return STACK_FALLBACK
+}
+
+export const inferStackFromScan = (scan: RepoScanSummary): string => detectStack(scan)
+
+const createEmptyResponses = (stack: string): WizardResponses => ({
+ stackSelection: stack,
+ tooling: null,
+ language: null,
+ fileStructure: null,
+ styling: null,
+ testingUT: null,
+ testingE2E: null,
+ projectPriority: null,
+ codeStyle: null,
+ variableNaming: null,
+ fileNaming: null,
+ componentNaming: null,
+ exports: null,
+ comments: null,
+ collaboration: null,
+ stateManagement: null,
+ apiLayer: null,
+ folders: null,
+ dataFetching: null,
+ reactPerf: null,
+ auth: null,
+ validation: null,
+ logging: null,
+ commitStyle: null,
+ prRules: null,
+ outputFile: null,
+})
+
+const detectLanguage = (scan: RepoScanSummary): string | null => {
+ const languages = toLowerArray(scan.languages)
if (languages.includes("typescript")) return "typescript"
if (languages.includes("javascript")) return "javascript"
- if (languages.includes("python")) return "python"
+ if (languages.includes("python")) return "Python"
return scan.language ? String(scan.language) : null
}
-const pickTestingUT = (scan: RepoScanSummary): string | null => {
- const testing = (scan.testing ?? []).map((t) => t.toLowerCase())
+const detectTestingUnit = (scan: RepoScanSummary): string | null => {
+ const testing = toLowerArray(scan.testing)
if (testing.includes("pytest")) return "pytest"
if (testing.includes("unittest")) return "unittest"
if (testing.includes("vitest")) return "vitest"
if (testing.includes("jest")) return "jest"
+ if (testing.includes("jasmine")) return "jasmine-karma"
return null
}
-const pickTestingE2E = (scan: RepoScanSummary): string | null => {
- const testing = (scan.testing ?? []).map((t) => t.toLowerCase())
+const detectTestingE2E = (scan: RepoScanSummary): string | null => {
+ const testing = toLowerArray(scan.testing)
if (testing.includes("playwright")) return "playwright"
if (testing.includes("cypress")) return "cypress"
return null
}
-const pickStyling = (scan: RepoScanSummary, stack: string): string => {
- const tooling = (scan.tooling ?? []).map((t) => t.toLowerCase())
- if (tooling.includes("tailwind css") || tooling.includes("tailwind")) return "tailwind"
- if (stack === "python") return "Not applicable (backend Python project)"
- return stack === "nextjs" ? "tailwind" : "cssmodules"
-}
-
-const pickFileNaming = (scan: RepoScanSummary): string => {
- const detected = (scan as any).fileNamingStyle as string | undefined | null
- if (!detected) return "kebab-case"
- switch (detected) {
- case "kebab-case":
- case "camelCase":
- case "PascalCase":
- case "snake_case":
- return detected
- default:
- return "kebab-case"
+const detectToolingSummary = (scan: RepoScanSummary, stack: string): string | null => {
+ if (scan.tooling && scan.tooling.length > 0) {
+ return scan.tooling.join(" + ")
}
-}
-
-const pickComponentNaming = (scan: RepoScanSummary): string => {
- const detected = (scan as any).componentNamingStyle as string | undefined | null
- if (!detected) return "PascalCase"
- if (detected === "PascalCase" || detected === "camelCase") {
- return detected
- }
- return detected === "camelcase" ? "camelCase" : "PascalCase"
-}
-
-const pickCodeStyle = (scan: RepoScanSummary, stack: string): string => {
- if (stack === "python") {
- const tooling = (scan.tooling ?? []).map((t) => t.toLowerCase())
- if (tooling.includes("ruff")) return "ruff"
- if (tooling.includes("black")) return "black"
- if (tooling.includes("pyproject.toml")) return "pep8"
- if (tooling.includes("pipenv")) return "pep8"
- return "pep8"
- }
- const detected = (scan as any).codeStylePreference as string | undefined | null
- if (!detected) return "airbnb"
- if (detected === "standardjs") return "standardjs"
- if (detected === "airbnb") return "airbnb"
- return "airbnb"
-}
-
-const pickFileStructure = (scan: RepoScanSummary, stack: string): string => {
- if (stack === "python") {
- const hasSrc = scan.structure?.src ?? false
- const hasPackagesDir = scan.structure?.packages ?? false
- if (hasSrc) return "src layout (src/)"
- if (hasPackagesDir) return "packages directory (monorepo-style)"
- return "top-level modules"
- }
- if (stack === "nextjs") {
- // Prefer App Router by default; hybrid/pages if hints present (see enriched fields if any)
- const routing = (scan as any).routing as string | undefined
- if (routing === "pages") return "pages-directory"
- if (routing === "hybrid") return "hybrid-router"
- return "app-directory"
- }
- const st = scan.structure ?? { components: false, tests: false }
- return st.components && st.tests ? "nested" : "flat"
-}
-
-const pickStateMgmt = (scan: RepoScanSummary, stack: string): string => {
- const detected = ((scan as any).stateMgmt as string | undefined) ?? null
- if (detected) return detected
- if (stack === "python") return "Not applicable"
- return stack === "nextjs" ? "zustand" : "context-hooks"
-}
-const pickDataFetching = (scan: RepoScanSummary, stack: string): string => {
- const detected = ((scan as any).dataFetching as string | undefined) ?? null
- if (detected) return detected
- if (stack === "python") return "Not applicable"
- return stack === "nextjs" ? "server-components" : "swr"
-}
+ if (stack === "python") return "pip"
+ if (stack === "nextjs") return "create-next-app"
+ if (stack === "react") return "vite"
+ if (stack === "angular") return "angular-cli"
+ if (stack === "vue") return "vite"
+ if (stack === "svelte") return "sveltekit"
+ if (stack === "nuxt") return "nuxi"
+ if (stack === "astro") return "astro"
+ if (stack === "remix") return "create-remix"
-const pickAuth = (scan: RepoScanSummary, stack: string): string => {
- const detected = ((scan as any).auth as string | undefined) ?? null
- if (detected) return detected
- if (stack === "python") return "TODO: document auth/session strategy"
- return stack === "nextjs" ? "next-auth" : "env"
+ return null
}
-const pickValidation = (scan: RepoScanSummary, stack: string): string => {
- const detected = ((scan as any).validation as string | undefined) ?? null
- if (detected) return detected
- if (stack === "python") return "TODO: specify validation library (Pydantic, Marshmallow, etc.)"
- return "zod"
+const detectFileNaming = (scan: RepoScanSummary): string | null => {
+ const detected = (scan as Record).fileNamingStyle
+ return typeof detected === "string" ? detected : null
}
-const pickLogging = (scan: RepoScanSummary, stack: string): string => {
- const detected = ((scan as any).logging as string | undefined) ?? null
- if (detected) return detected
- if (stack === "python") return "TODO: describe logging approach (structlog, stdlib logging)"
- return stack === "nextjs" ? "sentry" : "structured"
+const detectComponentNaming = (scan: RepoScanSummary): string | null => {
+ const detected = (scan as Record).componentNamingStyle
+ if (typeof detected === "string") {
+ return detected === "camelcase" ? "camelCase" : detected
+ }
+ return null
}
-const pickCommitStyle = (scan: RepoScanSummary): string => {
- const detected = ((scan as any).commitMessageStyle as string | undefined) ?? null
+const detectCommitStyle = (scan: RepoScanSummary): string | null => {
+ const detected = (scan as Record).commitMessageStyle
if (detected === "gitmoji") return "gitmoji"
if (detected === "conventional") return "conventional"
- const codeQuality = ((scan as any).codeQuality as string[] | undefined) ?? []
- return includesAny(codeQuality, [/commitlint/, /semantic-release/, /changesets/]) ? "conventional" : "conventional"
+ return null
}
-const pickPrRules = (scan: RepoScanSummary): string => {
- const ci = ((scan as any).ci as string[] | undefined) ?? []
- // If CI present, lean into code review requirement
- return ci.length > 0 ? "reviewRequired" : "reviewRequired"
+const detectPRRules = (scan: RepoScanSummary): string | null => {
+ const ci = (scan as Record).ci
+ if (Array.isArray(ci) && ci.length > 0) {
+ return "reviewRequired"
+ }
+ return null
}
-const buildToolingSummary = (scan: RepoScanSummary, stack: string): string => {
- const parts = scan.tooling ?? []
- if (parts.length > 0) return parts.join(" + ")
- if (stack === "python") return "pip"
- return stack === "nextjs" ? "create-next-app" : stack === "react" ? "vite" : "custom-config"
+type StackQuestionDefault = {
+ questionId: string
+ responseKey: keyof WizardResponses
+ value: string
+}
+
+const defaultsCache = new Map()
+
+const extractDefaultsFromSteps = (steps: WizardStep[], template: WizardResponses): StackQuestionDefault[] => {
+ const defaults: StackQuestionDefault[] = []
+ steps.forEach((step) => {
+ step.questions.forEach((question) => {
+ const rawKey = question.responseKey ?? question.id
+ if (!rawKey || rawKey === "stackSelection") {
+ return
+ }
+ const defaultAnswer = question.answers.find((answer) => answer.isDefault && !answer.disabled)
+ if (!defaultAnswer) {
+ return
+ }
+ if (!(rawKey in template)) {
+ return
+ }
+ const key = rawKey as keyof WizardResponses
+ defaults.push({
+ questionId: question.id,
+ responseKey: key,
+ value: defaultAnswer.value,
+ })
+ })
+ })
+ return defaults
+}
+
+const loadStackQuestionDefaults = async (
+ stack: string,
+ template: WizardResponses,
+): Promise => {
+ const normalized = stack.trim().toLowerCase()
+ if (defaultsCache.has(normalized)) {
+ return defaultsCache.get(normalized)!
+ }
+
+ const { steps } = await buildStepsForStack(stack)
+ const defaults = extractDefaultsFromSteps(steps, template)
+ defaultsCache.set(normalized, defaults)
+ return defaults
}
-export type ScanToWizardResult = {
+type BuildResult = {
stack: string
responses: WizardResponses
-}
-
-export function buildResponsesFromScan(scan: RepoScanSummary): ScanToWizardResult {
- const stack = pickStack(scan)
- const language = pickLanguage(scan)
-
- const responses: WizardResponses = {
- stackSelection: stack,
- tooling: buildToolingSummary(scan, stack),
- language,
- fileStructure: pickFileStructure(scan, stack),
- styling: pickStyling(scan, stack),
- testingUT: pickTestingUT(scan),
- testingE2E: pickTestingE2E(scan),
- projectPriority: "maintainability",
- codeStyle: pickCodeStyle(scan, stack),
- variableNaming: "camelCase",
- fileNaming: pickFileNaming(scan),
- componentNaming: pickComponentNaming(scan),
- exports: "named",
- comments: "docblocks",
- collaboration: "async",
- stateManagement: pickStateMgmt(scan, stack),
- apiLayer: stack === "nextjs" ? "server-actions" : "http-client",
- folders: "by-feature",
- dataFetching: pickDataFetching(scan, stack),
- reactPerf: "memoHooks",
- auth: pickAuth(scan, stack),
- validation: pickValidation(scan, stack),
- logging: pickLogging(scan, stack),
- commitStyle: pickCommitStyle(scan),
- prRules: pickPrRules(scan),
- outputFile: null,
+ conventions: LoadedConvention
+ hasCustomConventions: boolean
+ defaultedQuestionIds: Record
+}
+
+export const buildResponsesFromScan = async (scan: RepoScanSummary): Promise => {
+ const stack = detectStack(scan)
+ const { conventions, hasStackFile } = await loadStackConventions(stack)
+
+ const base = createEmptyResponses(stack)
+ const withDefaults: WizardResponses = { ...base, ...conventions.defaults }
+
+ withDefaults.tooling = withDefaults.tooling ?? detectToolingSummary(scan, stack)
+ withDefaults.language = withDefaults.language ?? detectLanguage(scan)
+ withDefaults.testingUT = withDefaults.testingUT ?? detectTestingUnit(scan)
+ withDefaults.testingE2E = withDefaults.testingE2E ?? detectTestingE2E(scan)
+ withDefaults.fileNaming = withDefaults.fileNaming ?? detectFileNaming(scan)
+ withDefaults.componentNaming = withDefaults.componentNaming ?? detectComponentNaming(scan)
+ withDefaults.commitStyle = withDefaults.commitStyle ?? detectCommitStyle(scan)
+ withDefaults.prRules = withDefaults.prRules ?? detectPRRules(scan)
+
+ const afterRules = applyConventionRules(withDefaults, conventions.rules, scan)
+ afterRules.stackSelection = stack
+
+ const defaultedQuestionIds: Record = {}
+ const questionDefaults = await loadStackQuestionDefaults(stack, afterRules)
+ questionDefaults.forEach(({ responseKey, questionId, value }) => {
+ const currentValue = afterRules[responseKey]
+ if (currentValue === null || currentValue === undefined || currentValue === "") {
+ afterRules[responseKey] = value
+ defaultedQuestionIds[questionId] = true
+ }
+ })
+
+ if (!afterRules.tooling) {
+ afterRules.tooling = detectToolingSummary(scan, stack)
}
-
- if (stack === "python") {
- responses.tooling = responses.tooling ?? "pip"
- responses.language =
- responses.language && responses.language.toLowerCase() === "python" ? "Python" : responses.language ?? "Python"
- responses.variableNaming = "snake_case"
- responses.fileNaming = "snake_case"
- responses.componentNaming = "Not applicable"
- responses.exports = "module exports"
- responses.comments = "docstrings"
- responses.stateManagement = "Not applicable"
- responses.apiLayer = "TODO: document primary framework (FastAPI, Django, Flask, etc.)"
- responses.folders = "by-module"
- responses.dataFetching = "Not applicable"
- responses.reactPerf = "Not applicable"
- responses.testingUT = responses.testingUT ?? "pytest"
- responses.testingE2E = "TODO: outline integration / end-to-end coverage"
- responses.styling = "Not applicable (backend Python project)"
- responses.codeStyle = responses.codeStyle ?? "pep8"
- responses.auth = responses.auth ?? "TODO: document auth/session strategy"
- responses.validation = responses.validation ?? "TODO: specify validation library (Pydantic, Marshmallow, etc.)"
- responses.logging = responses.logging ?? "TODO: describe logging approach (structlog, stdlib logging)"
+ if (!afterRules.language) {
+ afterRules.language = detectLanguage(scan)
+ }
+ if (!afterRules.testingUT) {
+ afterRules.testingUT = detectTestingUnit(scan)
+ }
+ if (!afterRules.testingE2E) {
+ afterRules.testingE2E = detectTestingE2E(scan)
+ }
+ if (!afterRules.fileNaming) {
+ afterRules.fileNaming = detectFileNaming(scan)
+ }
+ if (!afterRules.componentNaming) {
+ afterRules.componentNaming = detectComponentNaming(scan)
}
- return { stack, responses }
+ return {
+ stack,
+ responses: afterRules,
+ conventions,
+ hasCustomConventions: hasStackFile,
+ defaultedQuestionIds,
+ }
}
+
+export type ScanToWizardResult = BuildResult
diff --git a/lib/wizard-storage.ts b/lib/wizard-storage.ts
index 3809ef4..262f038 100644
--- a/lib/wizard-storage.ts
+++ b/lib/wizard-storage.ts
@@ -6,6 +6,7 @@ export type StoredWizardState = {
responses: Responses
freeTextResponses?: FreeTextResponses
autoFilledMap: Record
+ defaultedMap?: Record
updatedAt: number
}
diff --git a/lib/wizard-summary-data.ts b/lib/wizard-summary-data.ts
index 4787a7e..4fda4ce 100644
--- a/lib/wizard-summary-data.ts
+++ b/lib/wizard-summary-data.ts
@@ -11,6 +11,7 @@ export type DefaultSummaryData = {
responses: Responses
freeTextResponses: FreeTextResponses
autoFilledMap: Record
+ defaultedMap: Record
stackLabel: string
}
@@ -18,12 +19,14 @@ const buildDefaultsForSteps = (steps: WizardStep[], stackId: string): {
responses: Responses
freeTextResponses: FreeTextResponses
autoFilledMap: Record
+ defaultedMap: Record
} => {
const responses: Responses = {
[STACK_QUESTION_ID]: stackId,
}
const freeTextResponses: FreeTextResponses = {}
const autoFilledMap: Record = {}
+ const defaultedMap: Record = {}
steps.forEach((step) => {
step.questions.forEach((question) => {
@@ -43,22 +46,25 @@ const buildDefaultsForSteps = (steps: WizardStep[], stackId: string): {
responses[question.id] = question.allowMultiple
? defaultAnswers.map((answer) => answer.value)
: defaultAnswers[0]?.value
+
+ defaultedMap[question.id] = true
})
})
- return { responses, freeTextResponses, autoFilledMap }
+ return { responses, freeTextResponses, autoFilledMap, defaultedMap }
}
export const buildDefaultSummaryData = async (stackId: string): Promise => {
const { step, label } = await loadStackWizardStep(stackId)
const steps: WizardStep[] = [stacksStep, step, ...getSuffixSteps()]
- const { responses, freeTextResponses, autoFilledMap } = buildDefaultsForSteps(steps, stackId)
+ const { responses, freeTextResponses, autoFilledMap, defaultedMap } = buildDefaultsForSteps(steps, stackId)
return {
steps,
responses,
freeTextResponses,
autoFilledMap,
+ defaultedMap,
stackLabel: label,
}
}
diff --git a/lib/wizard-summary.ts b/lib/wizard-summary.ts
index 97ef63b..4438a86 100644
--- a/lib/wizard-summary.ts
+++ b/lib/wizard-summary.ts
@@ -13,6 +13,7 @@ export type CompletionSummaryEntry = {
answers: string[]
isAutoFilled?: boolean
isReadOnlyOnSummary?: boolean
+ isDefaultApplied?: boolean
}
const buildFileSummaryEntry = (
@@ -57,6 +58,7 @@ export const buildCompletionSummary = (
responses: Responses,
freeTextResponses: FreeTextResponses,
autoFilledMap: Record = {},
+ defaultedMap: Record = {},
includeFileEntry = true
): CompletionSummaryEntry[] => {
const summary: CompletionSummaryEntry[] = includeFileEntry
@@ -94,6 +96,7 @@ export const buildCompletionSummary = (
answers: answerSummaries,
isAutoFilled: Boolean(autoFilledMap[question.id]),
isReadOnlyOnSummary: Boolean(question.isReadOnlyOnSummary),
+ isDefaultApplied: Boolean(defaultedMap[question.id] && answerSummaries.length > 0),
})
})
})
diff --git a/types/conventions.ts b/types/conventions.ts
new file mode 100644
index 0000000..dd860fd
--- /dev/null
+++ b/types/conventions.ts
@@ -0,0 +1,35 @@
+import type { RepoScanSummary, RepoStructureSummary } from "@/types/repo-scan"
+import type { WizardResponses } from "@/types/wizard"
+
+export type ConventionCondition = {
+ toolingIncludes?: string[]
+ testingIncludes?: string[]
+ frameworksInclude?: string[]
+ languagesInclude?: string[]
+ structureHas?: Array
+ structureMissing?: Array
+ routingIs?: Array>
+}
+
+export type ConventionRule = {
+ if: ConventionCondition
+ set: Partial
+}
+
+export type StackConventions = {
+ id: string
+ label?: string
+ applyToGlob?: string
+ structureRelevant?: Array
+ defaults?: Partial
+ rules?: ConventionRule[]
+ summaryMessage?: string | null
+}
+
+export type LoadedConvention = Required> &
+ Omit & {
+ applyToGlob: string
+ structureRelevant: Array
+ defaults: Partial
+ rules: ConventionRule[]
+ }
diff --git a/types/repo-scan.ts b/types/repo-scan.ts
index b367dca..a43a299 100644
--- a/types/repo-scan.ts
+++ b/types/repo-scan.ts
@@ -6,6 +6,12 @@ export type RepoStructureSummary = {
packages: boolean
}
+export type RepoScanConventionsMeta = {
+ stack: string
+ hasCustomConventions: boolean
+ structureRelevant: Array
+}
+
export type RepoScanSummary = {
repo: string
defaultBranch: string
@@ -36,6 +42,7 @@ export type RepoScanSummary = {
componentNamingStyle?: string | null
codeStylePreference?: string | null
commitMessageStyle?: string | null
+ conventions?: RepoScanConventionsMeta | null
}
export type RepoScanErrorResponse = {
From f880c3807a034672aa63e4b60145cffdb9d44e80 Mon Sep 17 00:00:00 2001
From: spivakov83
Date: Wed, 15 Oct 2025 17:50:31 +0300
Subject: [PATCH 03/10] feat: add unit tests for stack detection and
conventions handling in scan-to-wizard
---
.../scan-to-wizard-detection.test.ts | 56 ++++++++
lib/scan-to-wizard.ts | 126 ++++++++++++------
test-results/.last-run.json | 6 +-
.../error-context.md | 93 +++++++++++++
4 files changed, 240 insertions(+), 41 deletions(-)
create mode 100644 lib/__tests__/scan-to-wizard-detection.test.ts
create mode 100644 test-results/repo-scan-repo-scan-succes-fbe6f-erates-instructions-preview-chromium/error-context.md
diff --git a/lib/__tests__/scan-to-wizard-detection.test.ts b/lib/__tests__/scan-to-wizard-detection.test.ts
new file mode 100644
index 0000000..b0165ca
--- /dev/null
+++ b/lib/__tests__/scan-to-wizard-detection.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it } from "vitest"
+
+import { buildResponsesFromScan } from "@/lib/scan-to-wizard"
+import type { RepoScanSummary } from "@/types/repo-scan"
+
+const createBaseScan = (overrides: Partial): RepoScanSummary => ({
+ repo: "owner/repo",
+ defaultBranch: "main",
+ language: null,
+ languages: [],
+ frameworks: [],
+ tooling: [],
+ testing: [],
+ structure: { src: false, components: false, tests: false, apps: false, packages: false },
+ topics: [],
+ warnings: [],
+ ...overrides,
+})
+
+describe("buildResponsesFromScan detection", () => {
+ it("prefers conventions-defined unit testing matches for Python stacks", async () => {
+ const scan = createBaseScan({
+ languages: ["Python"],
+ testing: ["Behave"],
+ })
+
+ const result = await buildResponsesFromScan(scan)
+
+ expect(result.stack).toBe("python")
+ expect(result.responses.testingUT).toBe("behave")
+ })
+
+ it("detects React unit testing tools declared in conventions", async () => {
+ const scan = createBaseScan({
+ frameworks: ["React"],
+ testing: ["jest"],
+ })
+
+ const result = await buildResponsesFromScan(scan)
+
+ expect(result.stack).toBe("react")
+ expect(result.responses.testingUT).toBe("jest")
+ })
+
+ it("detects Angular end-to-end testing tools from conventions", async () => {
+ const scan = createBaseScan({
+ frameworks: ["Angular"],
+ testing: ["Playwright"],
+ })
+
+ const result = await buildResponsesFromScan(scan)
+
+ expect(result.stack).toBe("angular")
+ expect(result.responses.testingE2E).toBe("playwright")
+ })
+})
diff --git a/lib/scan-to-wizard.ts b/lib/scan-to-wizard.ts
index a2dee93..3db3120 100644
--- a/lib/scan-to-wizard.ts
+++ b/lib/scan-to-wizard.ts
@@ -9,6 +9,70 @@ const STACK_FALLBACK = "react"
const toLowerArray = (values: string[] | undefined | null) =>
Array.isArray(values) ? values.map((value) => value.toLowerCase()) : []
+const normalizeString = (value: string) => value.trim().toLowerCase()
+
+const collectConventionValues = (
+ conventions: LoadedConvention,
+ key: keyof WizardResponses,
+): string[] => {
+ const values: string[] = []
+ const pushValue = (candidate: unknown) => {
+ if (typeof candidate !== "string") return
+ const normalizedCandidate = normalizeString(candidate)
+ if (normalizedCandidate.length === 0) return
+ if (values.some((existing) => normalizeString(existing) === normalizedCandidate)) {
+ return
+ }
+ values.push(candidate)
+ }
+
+ pushValue(conventions.defaults[key])
+
+ conventions.rules.forEach((rule) => {
+ pushValue(rule.set?.[key])
+ })
+
+ return values
+}
+
+const detectFromScanList = (
+ scanList: string[] | undefined | null,
+ conventions: LoadedConvention,
+ key: keyof WizardResponses,
+): string | null => {
+ if (!Array.isArray(scanList) || scanList.length === 0) {
+ return null
+ }
+ const candidates = collectConventionValues(conventions, key)
+ if (candidates.length === 0) {
+ return null
+ }
+
+ const normalizedScan = scanList.map((value) => normalizeString(value))
+
+ for (const candidate of candidates) {
+ if (normalizedScan.includes(normalizeString(candidate))) {
+ return candidate
+ }
+ }
+
+ return null
+}
+
+const applyDetectedValue = (
+ target: WizardResponses,
+ key: Key,
+ value: WizardResponses[Key] | null | undefined,
+): void => {
+ if (value === null || value === undefined) {
+ return
+ }
+ if (typeof value === "string" && value.trim() === "") {
+ return
+ }
+ target[key] = value
+}
+
const detectStack = (scan: RepoScanSummary): string => {
const frameworks = toLowerArray(scan.frameworks)
const languages = toLowerArray(scan.languages)
@@ -64,37 +128,21 @@ const detectLanguage = (scan: RepoScanSummary): string | null => {
return scan.language ? String(scan.language) : null
}
-const detectTestingUnit = (scan: RepoScanSummary): string | null => {
- const testing = toLowerArray(scan.testing)
- if (testing.includes("pytest")) return "pytest"
- if (testing.includes("unittest")) return "unittest"
- if (testing.includes("vitest")) return "vitest"
- if (testing.includes("jest")) return "jest"
- if (testing.includes("jasmine")) return "jasmine-karma"
- return null
-}
+const detectTestingUnit = (scan: RepoScanSummary, conventions: LoadedConvention): string | null =>
+ detectFromScanList(scan.testing, conventions, "testingUT")
-const detectTestingE2E = (scan: RepoScanSummary): string | null => {
- const testing = toLowerArray(scan.testing)
- if (testing.includes("playwright")) return "playwright"
- if (testing.includes("cypress")) return "cypress"
- return null
-}
+const detectTestingE2E = (scan: RepoScanSummary, conventions: LoadedConvention): string | null =>
+ detectFromScanList(scan.testing, conventions, "testingE2E")
-const detectToolingSummary = (scan: RepoScanSummary, stack: string): string | null => {
+const detectToolingSummary = (scan: RepoScanSummary, conventions: LoadedConvention): string | null => {
if (scan.tooling && scan.tooling.length > 0) {
return scan.tooling.join(" + ")
}
- if (stack === "python") return "pip"
- if (stack === "nextjs") return "create-next-app"
- if (stack === "react") return "vite"
- if (stack === "angular") return "angular-cli"
- if (stack === "vue") return "vite"
- if (stack === "svelte") return "sveltekit"
- if (stack === "nuxt") return "nuxi"
- if (stack === "astro") return "astro"
- if (stack === "remix") return "create-remix"
+ const defaultTooling = conventions.defaults.tooling
+ if (typeof defaultTooling === "string" && defaultTooling.trim().length > 0) {
+ return defaultTooling
+ }
return null
}
@@ -191,14 +239,14 @@ export const buildResponsesFromScan = async (scan: RepoScanSummary): Promise Use this context when Copilot suggests alternatives: prefer what aligns with **{{projectPriority}}**. --- ## 2. Stack Playbook - Clarify if routes belong in the App Router and whether they run on the server or client. - Use built-in data fetching helpers (Server Components, Route Handlers, Server Actions) before custom fetch logic. - Keep shared UI and server utilities in clearly named directories to support bundler boundaries. --- ## 3. Naming, Style & Structure Rules ### Naming & Exports - Variables, functions, object keys: **camelCase** - Files & modules: **kebab-case** - Components & types: **PascalCase** - Always use **named** export style - Comments/documentation style: **docblocks** - Code style: follow **airbnb** ### File and Folder Structure - Module / feature layout: **app-directory** - Styling approach (if applicable): **tailwind** - State management / shared context: **zustand** - API / service layer organization: **server-actions** - Folder strategy: **by-feature** > Copilot should not generate code outside these structures or naming patterns. --- ## 4. Testing & Quality Assurance - Unit tests: **jest** - E2E / integration: **playwright** **Rules** - Use descriptive test names. - Cover both “happy path” and edge cases. - Keep tests focused and avoid spanning unrelated modules. - Place tests alongside modules or in designated `__tests__` folders. --- ## 5. Performance & Data Handling - Data fetching: **server-components** - Performance focus: **memoHooks** **Do** - Use pagination or streaming for large datasets. - Cache or memoize expensive work when it matters. - Offload non-critical processing to background tasks. **Don’t** - Load entire datasets eagerly without need. - Block hot execution paths with heavy synchronous work. - Skip instrumentation that would surface performance regressions. --- ## 6. Security, Validation, Logging - Secrets/auth handling: **next-auth** - Input validation: **zod** - Logging: **structured** **Rules** - Never commit secrets; use environment variables. - Validate all incoming data (API and client). - Do not log secrets or PII. - Use structured/contextual logs instead of raw print/log statements. --- ## 7. Commit & PR Conventions - Commit style: **conventional** - PR rules: **reviewRequired** **Do** - Follow commit style (`feat: add login`, `fix: correct bug`). - Keep PRs small and focused. - Link issues/tickets. - Update docs for new APIs or breaking changes. **Don’t** - Use vague commit messages like “fix stuff”. - Bundle unrelated changes. --- ## 8. Copilot Usage Guidance - Use Copilot for boilerplate (e.g., scaffolds, repetitive wiring). - Provide context in comments/prompts. - Reject completions that break naming, structure, or validation rules. - Ask clarifying questions in comments (e.g., “# Should this live in services?”). - Prefer completions that respect folder boundaries and import paths. **Don’t rely on Copilot for** - Security-critical code (auth, encryption). - Inferring business logic without requirements. - Blindly accepting untyped/unsafe code. --- ## 9. Editor Setup Recommended editor configuration: - Use `.editorconfig` for indentation/line endings. - Enable linting/formatting (ESLint, Prettier, Ruff, Black, etc.). - Set `editor.formatOnSave = true`. - Suggested integrations: - VS Code: `dbaeumer.vscode-eslint`, `esbenp.prettier-vscode` - JetBrains: ESLint + Prettier plugins - Cursor: use built-in `.instructions.md` support --- ## 10. Caveats & Overrides - Document exceptions with comments. - Experimental features must be flagged. - Always run linters and tests before merging Copilot-generated code. ---"
+```
\ No newline at end of file
From c68f149a2f4f9bd5114f98f9426e60986f529d3e Mon Sep 17 00:00:00 2001
From: spivakov83
Date: Wed, 15 Oct 2025 17:50:38 +0300
Subject: [PATCH 04/10] fix: correct logo alt text and adjust scaling in Hero
component
---
components/Hero.tsx | 2 +-
components/Logo/Logo.tsx | 7 ++++---
2 files changed, 5 insertions(+), 4 deletions(-)
diff --git a/components/Hero.tsx b/components/Hero.tsx
index 006f5e1..b2bb75d 100644
--- a/components/Hero.tsx
+++ b/components/Hero.tsx
@@ -74,7 +74,7 @@ export function Hero() {
className="mx-auto w-full max-w-[420px] md:max-w-[520px]"
variants={itemVariants}
>
-
+
diff --git a/components/Logo/Logo.tsx b/components/Logo/Logo.tsx
index 8e038b0..dc81e80 100644
--- a/components/Logo/Logo.tsx
+++ b/components/Logo/Logo.tsx
@@ -11,13 +11,14 @@ export default function Logo({ width = 350, height = 350 }: LogoProps) {
);
-}
\ No newline at end of file
+}
From a3f88789ae8de9dc7877493bf3c29cf9db1f7b7c Mon Sep 17 00:00:00 2001
From: spivakov83
Date: Wed, 15 Oct 2025 18:04:54 +0300
Subject: [PATCH 05/10] feat: implement Python testing signal detection and
conventions handling
---
app/api/scan-repo/route.ts | 85 ++++++++++++++++++-
.../repo-scan-python-detection.test.ts | 43 ++++++++++
lib/convention-values.ts | 32 +++++++
lib/scan-to-wizard.ts | 31 +------
4 files changed, 160 insertions(+), 31 deletions(-)
create mode 100644 lib/__tests__/repo-scan-python-detection.test.ts
create mode 100644 lib/convention-values.ts
diff --git a/app/api/scan-repo/route.ts b/app/api/scan-repo/route.ts
index 25abd3a..e0d4513 100644
--- a/app/api/scan-repo/route.ts
+++ b/app/api/scan-repo/route.ts
@@ -6,8 +6,9 @@ import type {
RepoScanSummary,
RepoStructureSummary,
} from "@/types/repo-scan"
-import { inferStackFromScan } from "@/lib/scan-to-wizard"
+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"])
@@ -114,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()
@@ -276,6 +280,8 @@ const detectTooling = (paths: string[], pkg: PackageJson | null): { tooling: str
}
}
+ await detectPythonTestingSignals(paths, pkg, testing)
+
return {
tooling: dedupeAndSort(tooling),
testing: dedupeAndSort(testing),
@@ -283,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,
@@ -770,7 +849,7 @@ export async function GET(request: NextRequest): Promise
+ devDependencies?: Record
+ peerDependencies?: Record
+ optionalDependencies?: Record
+}
+
+const createPkg = (deps: Partial): PackageJson => ({ ...deps })
+
+describe("detectPythonTestingSignals", () => {
+ it("adds behave when features directory structure is present", async () => {
+ const testing = new Set()
+ await detectPythonTestingSignals(
+ ["features/example.feature", "features/steps/login_steps.py", "features/environment.py"],
+ null,
+ testing,
+ )
+
+ expect(Array.from(testing)).toContain("behave")
+ })
+
+ it("adds behave when dependency is detected", async () => {
+ const testing = new Set()
+ await detectPythonTestingSignals(
+ [],
+ createPkg({ devDependencies: { behave: "^1.2.3" } }),
+ testing,
+ )
+
+ expect(Array.from(testing)).toContain("behave")
+ })
+
+ it("adds unittest when Python-style test files exist", async () => {
+ const testing = new Set()
+ await detectPythonTestingSignals(["tests/test_example.py", "src/app.py"], null, testing)
+
+ expect(Array.from(testing)).toContain("unittest")
+ })
+})
diff --git a/lib/convention-values.ts b/lib/convention-values.ts
new file mode 100644
index 0000000..5d7ed46
--- /dev/null
+++ b/lib/convention-values.ts
@@ -0,0 +1,32 @@
+import type { LoadedConvention } from "@/types/conventions"
+import type { WizardResponses } from "@/types/wizard"
+
+export const normalizeConventionValue = (value: string): string => value.trim().toLowerCase()
+
+export const collectConventionValues = (
+ conventions: LoadedConvention,
+ key: keyof WizardResponses,
+): string[] => {
+ const values: string[] = []
+ const seen = new Set()
+
+ const pushValue = (candidate: unknown) => {
+ if (typeof candidate !== "string") {
+ return
+ }
+ const normalized = normalizeConventionValue(candidate)
+ if (!normalized || seen.has(normalized)) {
+ return
+ }
+ seen.add(normalized)
+ values.push(candidate)
+ }
+
+ pushValue(conventions.defaults[key])
+
+ conventions.rules.forEach((rule) => {
+ pushValue(rule.set?.[key])
+ })
+
+ return values
+}
diff --git a/lib/scan-to-wizard.ts b/lib/scan-to-wizard.ts
index 3db3120..d09d7e2 100644
--- a/lib/scan-to-wizard.ts
+++ b/lib/scan-to-wizard.ts
@@ -1,3 +1,4 @@
+import { collectConventionValues, normalizeConventionValue } from "@/lib/convention-values"
import { applyConventionRules, loadStackConventions } from "@/lib/conventions"
import { buildStepsForStack } from "@/lib/wizard-summary-data"
import type { RepoScanSummary } from "@/types/repo-scan"
@@ -9,32 +10,6 @@ const STACK_FALLBACK = "react"
const toLowerArray = (values: string[] | undefined | null) =>
Array.isArray(values) ? values.map((value) => value.toLowerCase()) : []
-const normalizeString = (value: string) => value.trim().toLowerCase()
-
-const collectConventionValues = (
- conventions: LoadedConvention,
- key: keyof WizardResponses,
-): string[] => {
- const values: string[] = []
- const pushValue = (candidate: unknown) => {
- if (typeof candidate !== "string") return
- const normalizedCandidate = normalizeString(candidate)
- if (normalizedCandidate.length === 0) return
- if (values.some((existing) => normalizeString(existing) === normalizedCandidate)) {
- return
- }
- values.push(candidate)
- }
-
- pushValue(conventions.defaults[key])
-
- conventions.rules.forEach((rule) => {
- pushValue(rule.set?.[key])
- })
-
- return values
-}
-
const detectFromScanList = (
scanList: string[] | undefined | null,
conventions: LoadedConvention,
@@ -48,10 +23,10 @@ const detectFromScanList = (
return null
}
- const normalizedScan = scanList.map((value) => normalizeString(value))
+ const normalizedScan = scanList.map((value) => normalizeConventionValue(value))
for (const candidate of candidates) {
- if (normalizedScan.includes(normalizeString(candidate))) {
+ if (normalizedScan.includes(normalizeConventionValue(candidate))) {
return candidate
}
}
From ba5723149d6bb9862f494855b80ea44a4acd13e2 Mon Sep 17 00:00:00 2001
From: spivakov83
Date: Wed, 15 Oct 2025 20:25:46 +0300
Subject: [PATCH 06/10] feat: enhance stack detection by integrating convention
values for improved heuristics
---
docs/scan-flow.md | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/docs/scan-flow.md b/docs/scan-flow.md
index c6a091e..f0ccb73 100644
--- a/docs/scan-flow.md
+++ b/docs/scan-flow.md
@@ -6,6 +6,8 @@ This document outlines how repository scans are transformed into AI instruction
1. **Scan the repository** (`app/api/scan-repo/route.ts`)
- Detect languages, frameworks, tooling, structure hints, and enriched metadata.
+ - Use stack conventions (`collectConventionValues`) to cross-check detection lists (e.g., testing tools) so any signal we add in `conventions/.json` becomes discoverable with minimal code changes.
+ - Reuse convention values to expand stack-specific heuristics (e.g., Python’s Behave directories, Next.js routing shapes), so each conventions file remains the source of truth for new detections.
- Infer the primary stack using `inferStackFromScan`.
- Load stack conventions (`loadStackConventions`) to determine which structure hints matter and whether stack-specific rules exist.
- Attach `summary.conventions` so the UI knows which directories to highlight and whether a conventions file was found.
@@ -13,7 +15,7 @@ This document outlines how repository scans are transformed into AI instruction
2. **Build wizard defaults** (`lib/scan-to-wizard.ts`)
- Start with an empty `WizardResponses` object.
- Apply convention defaults from `conventions/.json` + `default.json`.
- - Layer in detections from the scan (tooling, testing, naming signals, etc.).
+ - Layer in detections from the scan (tooling, testing, naming signals, etc.), matching scan values against convention-provided options so stack JSON remains the single source of truth.
- Run convention rules to tweak values based on detected tooling/testing.
- Pull default answers directly from the stack’s question set (`buildStepsForStack`) and fill any remaining empty responses. We track which questions were auto-defaulted (`defaultedQuestionIds`) so the summary can explain why.
@@ -32,13 +34,14 @@ This document outlines how repository scans are transformed into AI instruction
| Location | Purpose |
| --- | --- |
| `conventions/default.json` & `/conventions/.json` | Declarative defaults + rules for each stack (tooling choices, structure hints, apply-to glob, etc.). |
+| `lib/convention-values.ts` | Helpers that normalize and aggregate convention values (e.g., testingUT/testingE2E) for both the scanner and the wizard. |
| `data/stacks.json` | List of stacks exposed to the wizard; each should have a matching conventions file. |
| `data/questions/.json` | Stack-specific questions with default answers and metadata. These defaults are now honored automatically when scan data is missing. |
## Extending the Flow
- **Add a new stack**: create `conventions/.json`, add questions under `data/questions/.json`, and register the stack in `data/stacks.json`. The pipeline will pick it up automatically.
-- **Add scan heuristics**: update `app/api/scan-repo/route.ts` (e.g., detect tooling) so conventions rules have richer signals to work with.
+- **Add scan heuristics**: update `app/api/scan-repo/route.ts` (e.g., detect tooling/testing) so conventions rules have richer signals to work with. Shared helpers mean you can usually expand detection by tweaking the convention file plus a small matcher (see `detectPythonTestingSignals` for the current pattern).
- **Adjust defaults**: edit the stack’s question JSON to set a new `isDefault` answer; the scan pipeline will adopt it whenever the repo lacks an explicit signal.
- **Customize templates**: templates consume the final `WizardResponses`, so any new fields surfaced via conventions should be represented there before referencing them in markdown/JSON output.
From 001a122270cd7ff78aa6cfbf0076b72ea07e49dc Mon Sep 17 00:00:00 2001
From: spivakov83
Date: Wed, 15 Oct 2025 21:00:39 +0300
Subject: [PATCH 07/10] feat: enhance scan generation by adding defaulted
response metadata and updating template rendering
---
app/api/scan-generate/[fileId]/route.ts | 4 +-
docs/scan-flow.md | 1 +
lib/__tests__/template-render.test.ts | 54 +++++++++++
lib/scan-to-wizard.ts | 15 ++-
lib/template-render.ts | 20 +++-
playwright/tests/repo-scan.spec.ts | 2 +-
playwright/tests/wizard-free-text.spec.ts | 16 ++--
playwright/tests/wizard.spec.ts | 25 ++++-
test-results/.last-run.json | 6 +-
.../error-context.md | 93 -------------------
10 files changed, 122 insertions(+), 114 deletions(-)
create mode 100644 lib/__tests__/template-render.test.ts
delete mode 100644 test-results/repo-scan-repo-scan-succes-fbe6f-erates-instructions-preview-chromium/error-context.md
diff --git a/app/api/scan-generate/[fileId]/route.ts b/app/api/scan-generate/[fileId]/route.ts
index c58e8e6..5ad9148 100644
--- a/app/api/scan-generate/[fileId]/route.ts
+++ b/app/api/scan-generate/[fileId]/route.ts
@@ -18,13 +18,14 @@ export async function POST(request: NextRequest, context: RouteContext) {
return NextResponse.json({ error: "Missing scan payload" }, { status: 400 })
}
- const { stack, responses } = await buildResponsesFromScan(payload.scan)
+ 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({
@@ -37,4 +38,3 @@ export async function POST(request: NextRequest, context: RouteContext) {
return NextResponse.json({ error: "Failed to generate instructions from scan" }, { status: 500 })
}
}
-
diff --git a/docs/scan-flow.md b/docs/scan-flow.md
index f0ccb73..6e2f9c2 100644
--- a/docs/scan-flow.md
+++ b/docs/scan-flow.md
@@ -28,6 +28,7 @@ This document outlines how repository scans are transformed into AI instruction
- From the repo-scan UI, clicking “Generate” calls `lib/scan-generate.ts`, which posts to `/api/scan-generate/[fileId]`.
- The API reuses `buildResponsesFromScan` server-side to ensure consistency, then renders the target template with `renderTemplate`.
- Template rendering pulls `applyToGlob` from conventions so Copilot instructions target stack-appropriate file globs (e.g. `**/*.{py,pyi,md}` for Python).
+ - When a field falls back to a stack default because the scan lacked a signal, `renderTemplate` annotates the generated instruction with a note that it came from the default rather than the scan.
## Key Data Sources
diff --git a/lib/__tests__/template-render.test.ts b/lib/__tests__/template-render.test.ts
new file mode 100644
index 0000000..b6ed814
--- /dev/null
+++ b/lib/__tests__/template-render.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from "vitest"
+
+import { renderTemplate } from "@/lib/template-render"
+import type { WizardResponses } from "@/types/wizard"
+
+const buildResponses = (): WizardResponses => ({
+ stackSelection: "react",
+ tooling: "vite",
+ language: "TypeScript",
+ fileStructure: "feature-based",
+ styling: "tailwind",
+ testingUT: "jest",
+ testingE2E: "cypress",
+ projectPriority: "developer velocity",
+ codeStyle: "eslint-config-next",
+ variableNaming: "camelCase",
+ fileNaming: "kebab-case",
+ componentNaming: "PascalCase",
+ exports: "named",
+ comments: "jsdoc",
+ collaboration: "github",
+ stateManagement: "redux",
+ apiLayer: "trpc",
+ folders: "by-feature",
+ dataFetching: "swr",
+ reactPerf: "memoization",
+ auth: "oauth",
+ validation: "zod",
+ logging: "pino",
+ commitStyle: "conventional",
+ prRules: "reviewRequired",
+ outputFile: "instructions-md",
+})
+
+describe("renderTemplate", () => {
+ it("annotates defaulted responses passed from the scan pipeline", async () => {
+ const responses = buildResponses()
+
+ const result = await renderTemplate({
+ responses,
+ frameworkFromPath: "react",
+ fileNameFromPath: "instructions-md",
+ defaultedResponses: {
+ tooling: {
+ label: "Vite",
+ value: "vite",
+ questionId: "react-tooling",
+ },
+ },
+ })
+
+ expect(result.content).toContain("Vite (stack default - not detected via repo scan)")
+ })
+})
diff --git a/lib/scan-to-wizard.ts b/lib/scan-to-wizard.ts
index d09d7e2..861e24e 100644
--- a/lib/scan-to-wizard.ts
+++ b/lib/scan-to-wizard.ts
@@ -154,6 +154,7 @@ type StackQuestionDefault = {
questionId: string
responseKey: keyof WizardResponses
value: string
+ label: string
}
const defaultsCache = new Map()
@@ -178,6 +179,7 @@ const extractDefaultsFromSteps = (steps: WizardStep[], template: WizardResponses
questionId: question.id,
responseKey: key,
value: defaultAnswer.value,
+ label: defaultAnswer.label ?? defaultAnswer.value,
})
})
})
@@ -205,6 +207,7 @@ type BuildResult = {
conventions: LoadedConvention
hasCustomConventions: boolean
defaultedQuestionIds: Record
+ defaultedResponseMeta: Partial>
}
export const buildResponsesFromScan = async (scan: RepoScanSummary): Promise => {
@@ -227,12 +230,21 @@ export const buildResponsesFromScan = async (scan: RepoScanSummary): Promise = {}
+ const defaultedResponseMeta: Partial> = {}
const questionDefaults = await loadStackQuestionDefaults(stack, afterRules)
- questionDefaults.forEach(({ responseKey, questionId, value }) => {
+ questionDefaults.forEach(({ responseKey, questionId, value, label }) => {
const currentValue = afterRules[responseKey]
if (currentValue === null || currentValue === undefined || currentValue === "") {
afterRules[responseKey] = value
defaultedQuestionIds[questionId] = true
+ defaultedResponseMeta[responseKey] = {
+ questionId,
+ label,
+ value,
+ }
}
})
@@ -261,6 +273,7 @@ export const buildResponsesFromScan = async (scan: RepoScanSummary): Promise>
}
export type RenderTemplateResult = {
@@ -46,6 +53,7 @@ export async function renderTemplate({
responses,
frameworkFromPath,
fileNameFromPath,
+ defaultedResponses,
}: RenderTemplateParams): Promise {
const framework = frameworkFromPath && !['general', 'none', 'undefined'].includes(frameworkFromPath)
? frameworkFromPath
@@ -97,13 +105,19 @@ export async function renderTemplate({
const value = responses[key]
+ const defaultMeta = defaultedResponses?.[key]
+
if (value === null || value === undefined || value === '') {
const replacement = isJsonTemplate ? escapeForJson(fallback) : fallback
generatedContent = generatedContent.replace(placeholder, replacement)
} else {
- const replacementValue = String(value)
- const replacement = isJsonTemplate ? escapeForJson(replacementValue) : replacementValue
- generatedContent = generatedContent.replace(placeholder, replacement)
+ const rawValue = String(value)
+ const baseValue = defaultMeta?.label ?? rawValue
+ const displayValue = defaultMeta
+ ? `${baseValue} (stack default - not detected via repo scan)`
+ : baseValue
+ const replacementValue = isJsonTemplate ? escapeForJson(displayValue) : displayValue
+ generatedContent = generatedContent.replace(placeholder, replacementValue)
}
}
diff --git a/playwright/tests/repo-scan.spec.ts b/playwright/tests/repo-scan.spec.ts
index 70eef12..b7c5c03 100644
--- a/playwright/tests/repo-scan.spec.ts
+++ b/playwright/tests/repo-scan.spec.ts
@@ -38,7 +38,7 @@ test('repo scan success path generates instructions preview', async ({ page }) =
await expect(page.getByText('TypeScript').first()).toBeVisible()
await expect(page.getByText('Playwright').first()).toBeVisible()
- await page.route('**/api/generate/**', async (route) => {
+ await page.route('**/api/scan-generate/**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
diff --git a/playwright/tests/wizard-free-text.spec.ts b/playwright/tests/wizard-free-text.spec.ts
index 4bb03db..fbd54d8 100644
--- a/playwright/tests/wizard-free-text.spec.ts
+++ b/playwright/tests/wizard-free-text.spec.ts
@@ -28,16 +28,16 @@ test("wizard accepts custom free text answers and shows them in the summary", as
const customInput = page.getByPlaceholder("Type your custom preference")
await expect(customInput).toBeVisible()
- await customInput.fill(customAnswer)
+ await customInput.click()
+ await customInput.fill("")
+ await customInput.type(customAnswer)
+ await expect(customInput).toHaveValue(customAnswer)
- await expect(questionHeading).toHaveText("What build tooling do you use?")
+ const saveButton = page.getByRole("button", { name: "Save custom answer" })
+ await expect(saveButton).toBeEnabled()
+ await saveButton.click()
- const confirmationMessage = page.getByTestId("custom-answer-confirmation")
- await expect(confirmationMessage).toBeVisible()
- await expect(confirmationMessage).toContainText(customAnswer)
- await expect(confirmationMessage).toContainText(
- "for this question when we generate your context file."
- )
+ await expect(questionHeading).toHaveText("What language do you use?")
await expect.poll(
() =>
diff --git a/playwright/tests/wizard.spec.ts b/playwright/tests/wizard.spec.ts
index cf9cb4a..9dd3a17 100644
--- a/playwright/tests/wizard.spec.ts
+++ b/playwright/tests/wizard.spec.ts
@@ -19,8 +19,29 @@ test('wizard supports filtering, defaults, and reset', async ({ page }) => {
const questionHeading = page.getByTestId('wizard-question-heading')
- await page.getByRole('button', { name: 'Use default (Vite)' }).click()
- await expect(page.getByTestId('answer-option-react-language-typescript')).toBeVisible()
+ const defaultButton = page.getByRole('button', { name: 'Use default (Vite)' })
+ await expect(defaultButton).toBeEnabled()
+ await defaultButton.click()
+ await expect(defaultButton).toBeDisabled()
+
+ await expect.poll(
+ () =>
+ page.evaluate(() => {
+ const raw = window.localStorage.getItem('devcontext:wizard:react')
+ if (!raw) {
+ return null
+ }
+
+ try {
+ const state = JSON.parse(raw)
+ return state.responses?.['react-tooling'] ?? null
+ } catch (error) {
+ console.warn('Unable to parse wizard state', error)
+ return 'PARSE_ERROR'
+ }
+ }),
+ { timeout: 15000 }
+ ).toBe('vite')
await page.getByRole('button', { name: 'Start Over' }).click()
await expect(page.getByTestId('wizard-confirmation-dialog')).toBeVisible()
diff --git a/test-results/.last-run.json b/test-results/.last-run.json
index 0610006..cbcc1fb 100644
--- a/test-results/.last-run.json
+++ b/test-results/.last-run.json
@@ -1,6 +1,4 @@
{
- "status": "failed",
- "failedTests": [
- "6b578dd8037bfa8e4836-6a36192f84de2a39d93f"
- ]
+ "status": "passed",
+ "failedTests": []
}
\ No newline at end of file
diff --git a/test-results/repo-scan-repo-scan-succes-fbe6f-erates-instructions-preview-chromium/error-context.md b/test-results/repo-scan-repo-scan-succes-fbe6f-erates-instructions-preview-chromium/error-context.md
deleted file mode 100644
index b5f0114..0000000
--- a/test-results/repo-scan-repo-scan-succes-fbe6f-erates-instructions-preview-chromium/error-context.md
+++ /dev/null
@@ -1,93 +0,0 @@
-# Page snapshot
-
-```yaml
-- generic [ref=e1]:
- - generic [ref=e2]:
- - banner [ref=e4]:
- - link "DevContext" [ref=e5] [cursor=pointer]:
- - /url: /
- - generic [ref=e7]:
- - generic [ref=e8]:
- - generic [ref=e9]:
- - text: Repository Scan
- - generic [ref=e10]: https://github.com/vercel/next.js
- - generic [ref=e11]: Run a quick analysis of the repository and generate AI-friendly instruction files tailored to what we detect.
- - generic [ref=e13]:
- - generic [ref=e14]:
- - heading "Detected snapshot" [level=3] [ref=e15]
- - paragraph [ref=e16]: We mapped the repository to highlight the primary language, tooling, and structure so you can generate the right instructions in one click.
- - generic [ref=e17]:
- - generic [ref=e18]:
- - generic [ref=e19]:
- - img [ref=e20]
- - text: Primary language
- - paragraph [ref=e23]: TypeScript
- - paragraph [ref=e24]: "Also spotted: JavaScript"
- - generic [ref=e25]:
- - generic [ref=e26]:
- - img [ref=e27]
- - text: Default branch
- - paragraph [ref=e30]: main
- - generic [ref=e31]:
- - generic [ref=e32]:
- - heading "Frameworks" [level=3] [ref=e33]
- - paragraph [ref=e34]: Next.js, React
- - generic [ref=e35]:
- - heading "Tooling" [level=3] [ref=e36]
- - paragraph [ref=e37]: ESLint, Prettier
- - generic [ref=e38]:
- - heading "Testing" [level=3] [ref=e39]
- - paragraph [ref=e40]: Playwright
- - generic [ref=e41]:
- - heading "Structure hints" [level=3] [ref=e42]
- - list [ref=e43]:
- - listitem [ref=e44]:
- - generic [ref=e45]: src
- - generic [ref=e46]: Present
- - listitem [ref=e47]:
- - generic [ref=e48]: components
- - generic [ref=e49]: Present
- - listitem [ref=e50]:
- - generic [ref=e51]: tests
- - generic [ref=e52]: Present
- - listitem [ref=e53]:
- - generic [ref=e54]: apps
- - generic [ref=e55]: Present
- - listitem [ref=e56]:
- - generic [ref=e57]: packages
- - generic [ref=e58]: Missing
- - generic [ref=e59]:
- - generic [ref=e60]:
- - heading "Generate instructions" [level=3] [ref=e61]
- - paragraph [ref=e62]: Choose the file you need—each one opens an Instructions ready preview powered by this scan.
- - generic [ref=e63]:
- - button "Generate copilot-instructions.md" [ref=e64]
- - button "Generate agents.md" [ref=e65]
- - button "Generate .cursor/rules" [ref=e66]
- - generic [ref=e67]:
- - heading "Raw response" [level=3] [ref=e68]
- - generic [ref=e69]: "{ \"repo\": \"vercel/next.js\", \"defaultBranch\": \"main\", \"language\": \"TypeScript\", \"languages\": [ \"TypeScript\", \"JavaScript\" ], \"frameworks\": [ \"Next.js\", \"React\" ], \"tooling\": [ \"ESLint\", \"Prettier\" ], \"testing\": [ \"Playwright\" ], \"structure\": { \"src\": true, \"components\": true, \"tests\": true, \"apps\": true, \"packages\": false }, \"topics\": [ \"nextjs\", \"react\" ], \"warnings\": [] }"
- - button "Open Next.js Dev Tools" [ref=e75] [cursor=pointer]:
- - img [ref=e76]
- - alert [ref=e79]
- - dialog "copilot-instructions.md" [ref=e80]:
- - generic [active] [ref=e81]:
- - banner [ref=e82]:
- - generic [ref=e83]:
- - paragraph [ref=e84]: Instructions ready
- - heading "copilot-instructions.md" [level=2] [ref=e85]
- - generic [ref=e86]:
- - button "Restore original" [disabled]
- - button "Copy" [ref=e87]:
- - img
- - text: Copy
- - button "Download" [ref=e88]:
- - img
- - text: Download
- - button "Close preview" [ref=e89]:
- - img
- - generic [ref=e90]: Close preview
- - textbox "Generated instructions content" [ref=e93]:
- - /placeholder: No content available.
- - text: "--- # Configuration for Copilot in this project applyTo: \"**/*.{ts,tsx,js,jsx,md}\" # apply to relevant code files by default --- # Copilot Instructions ⚠️ This file is **auto-generated**. Do not edit manually unless overriding defaults. Regenerate whenever your JSON configuration changes (stack, naming, testing, etc.). --- ## 1. Project Context & Priorities - Stack: **nextjs** - Build tooling: **ESLint + Prettier** - Language: **typescript** - Primary focus: **maintainability** > Use this context when Copilot suggests alternatives: prefer what aligns with **{{projectPriority}}**. --- ## 2. Stack Playbook - Clarify if routes belong in the App Router and whether they run on the server or client. - Use built-in data fetching helpers (Server Components, Route Handlers, Server Actions) before custom fetch logic. - Keep shared UI and server utilities in clearly named directories to support bundler boundaries. --- ## 3. Naming, Style & Structure Rules ### Naming & Exports - Variables, functions, object keys: **camelCase** - Files & modules: **kebab-case** - Components & types: **PascalCase** - Always use **named** export style - Comments/documentation style: **docblocks** - Code style: follow **airbnb** ### File and Folder Structure - Module / feature layout: **app-directory** - Styling approach (if applicable): **tailwind** - State management / shared context: **zustand** - API / service layer organization: **server-actions** - Folder strategy: **by-feature** > Copilot should not generate code outside these structures or naming patterns. --- ## 4. Testing & Quality Assurance - Unit tests: **jest** - E2E / integration: **playwright** **Rules** - Use descriptive test names. - Cover both “happy path” and edge cases. - Keep tests focused and avoid spanning unrelated modules. - Place tests alongside modules or in designated `__tests__` folders. --- ## 5. Performance & Data Handling - Data fetching: **server-components** - Performance focus: **memoHooks** **Do** - Use pagination or streaming for large datasets. - Cache or memoize expensive work when it matters. - Offload non-critical processing to background tasks. **Don’t** - Load entire datasets eagerly without need. - Block hot execution paths with heavy synchronous work. - Skip instrumentation that would surface performance regressions. --- ## 6. Security, Validation, Logging - Secrets/auth handling: **next-auth** - Input validation: **zod** - Logging: **structured** **Rules** - Never commit secrets; use environment variables. - Validate all incoming data (API and client). - Do not log secrets or PII. - Use structured/contextual logs instead of raw print/log statements. --- ## 7. Commit & PR Conventions - Commit style: **conventional** - PR rules: **reviewRequired** **Do** - Follow commit style (`feat: add login`, `fix: correct bug`). - Keep PRs small and focused. - Link issues/tickets. - Update docs for new APIs or breaking changes. **Don’t** - Use vague commit messages like “fix stuff”. - Bundle unrelated changes. --- ## 8. Copilot Usage Guidance - Use Copilot for boilerplate (e.g., scaffolds, repetitive wiring). - Provide context in comments/prompts. - Reject completions that break naming, structure, or validation rules. - Ask clarifying questions in comments (e.g., “# Should this live in services?”). - Prefer completions that respect folder boundaries and import paths. **Don’t rely on Copilot for** - Security-critical code (auth, encryption). - Inferring business logic without requirements. - Blindly accepting untyped/unsafe code. --- ## 9. Editor Setup Recommended editor configuration: - Use `.editorconfig` for indentation/line endings. - Enable linting/formatting (ESLint, Prettier, Ruff, Black, etc.). - Set `editor.formatOnSave = true`. - Suggested integrations: - VS Code: `dbaeumer.vscode-eslint`, `esbenp.prettier-vscode` - JetBrains: ESLint + Prettier plugins - Cursor: use built-in `.instructions.md` support --- ## 10. Caveats & Overrides - Document exceptions with comments. - Experimental features must be flagged. - Always run linters and tests before merging Copilot-generated code. ---"
-```
\ No newline at end of file
From ab7a956a846ca143fb8f98eb91915eb19c658b29 Mon Sep 17 00:00:00 2001
From: spivakov83
Date: Wed, 15 Oct 2025 21:11:09 +0300
Subject: [PATCH 08/10] fix: update wizard test to save custom answer using
Enter key instead of button click
---
playwright/tests/wizard-free-text.spec.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/playwright/tests/wizard-free-text.spec.ts b/playwright/tests/wizard-free-text.spec.ts
index fbd54d8..d3732be 100644
--- a/playwright/tests/wizard-free-text.spec.ts
+++ b/playwright/tests/wizard-free-text.spec.ts
@@ -35,7 +35,7 @@ test("wizard accepts custom free text answers and shows them in the summary", as
const saveButton = page.getByRole("button", { name: "Save custom answer" })
await expect(saveButton).toBeEnabled()
- await saveButton.click()
+ await customInput.press("Enter")
await expect(questionHeading).toHaveText("What language do you use?")
From 4e9254a101fb1c9123a9571b434d70ff829818ca Mon Sep 17 00:00:00 2001
From: spivakov83
Date: Wed, 15 Oct 2025 21:18:47 +0300
Subject: [PATCH 09/10] changed text
---
app/page.tsx | 2 +-
components/Hero.tsx | 8 ++++----
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/app/page.tsx b/app/page.tsx
index c6ac8f8..3715200 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -10,7 +10,7 @@ import { absoluteUrl } from "@/lib/site-metadata"
const title = "DevContext | Repo-aware AI Coding Guidelines Assistant"
const description =
- "Generate AI-ready Copilot instructions, Cursor rules, and developer onboarding docs with a GitHub-aware coding guidelines workflow."
+ "Build from scratch with a guided wizard or scan a public GitHub repo to generate AI-ready Copilot instructions, Cursor rules, and developer onboarding docs."
export const metadata: Metadata = {
title,
diff --git a/components/Hero.tsx b/components/Hero.tsx
index b2bb75d..f128273 100644
--- a/components/Hero.tsx
+++ b/components/Hero.tsx
@@ -91,14 +91,14 @@ export function Hero() {
className="mx-auto max-w-4xl text-4xl font-semibold tracking-tight text-foreground md:text-6xl md:leading-tight"
variants={itemVariants}
>
- Repo-aware AI coding guidelines assistant for Copilot & Cursor
+ Wizard or Repo Scan. Your context, ready.
- Generate AI-ready Copilot instructions, Cursor rules, and developer onboarding docs in minutes—start from curated stacks or drop a repo into the GitHub analyzer.
+ Start from scratch with a guided wizard or analyze a public GitHub repo. DevContext detects languages, frameworks, tooling, and tests to generate Copilot instructions, Cursor rules, and onboarding docs you can edit and export.
@@ -187,10 +187,10 @@ export function Hero() {
>
- Scan a GitHub repository
+ Scan a public GitHub repository
- Paste an owner/repo or URL and we'll prefill the wizard with detected tech, tooling, and guardrail suggestions.
+ Paste an owner/repo or URL; we'll prefill the wizard with detected languages, frameworks, tooling, and tests.
From 67a0422d8d0a7dbaa5f5207ec877f9a4010263c7 Mon Sep 17 00:00:00 2001
From: spivakov83
Date: Thu, 16 Oct 2025 13:10:55 +0300
Subject: [PATCH 10/10] feat: enhance InstructionsWizard to manage free text
drafts and improve form submission handling
---
.gitignore | 1 +
components/instructions-wizard.tsx | 134 +++++++++++++++++++---
playwright/tests/wizard-free-text.spec.ts | 11 +-
playwright/tests/wizard.spec.ts | 4 +-
4 files changed, 125 insertions(+), 25 deletions(-)
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/components/instructions-wizard.tsx b/components/instructions-wizard.tsx
index 67ee1bb..6952470 100644
--- a/components/instructions-wizard.tsx
+++ b/components/instructions-wizard.tsx
@@ -30,6 +30,7 @@ import { WizardAnswerGrid } from "./wizard-answer-grid"
import { WizardConfirmationDialog } from "./wizard-confirmation-dialog"
const suffixSteps = getSuffixSteps()
+const buildStepSignature = (step: WizardStep) => `${step.id}::${step.questions.map((question) => question.id).join("|")}`
export function InstructionsWizard({
initialStackId,
@@ -46,6 +47,7 @@ export function InstructionsWizard({
initialStackId ? { [STACK_QUESTION_ID]: initialStackId } : {}
)
const [freeTextResponses, setFreeTextResponses] = useState({})
+ const [freeTextDrafts, setFreeTextDrafts] = useState>({})
const [dynamicSteps, setDynamicSteps] = useState(() =>
initialStackStep ? [initialStackStep] : []
)
@@ -65,6 +67,9 @@ export function InstructionsWizard({
const hasAppliedInitialStack = useRef(
initialStackStep && initialStackId ? initialStackId : null
)
+ const lastAppliedStackStepSignature = useRef(
+ initialStackStep ? buildStepSignature(initialStackStep) : null
+ )
const [activeStackLabel, setActiveStackLabel] = useState(initialStackLabel)
const wizardSteps = useMemo(() => [stacksStep, ...dynamicSteps, ...suffixSteps], [dynamicSteps])
@@ -87,7 +92,7 @@ export function InstructionsWizard({
const filterInputId = currentQuestion ? `answer-filter-${currentQuestion.id}` : "answer-filter"
const currentAnswerValue = currentQuestion ? responses[currentQuestion.id] : undefined
- const currentFreeTextValue = useMemo(() => {
+ const savedFreeTextValue = useMemo(() => {
if (!currentQuestion) {
return ""
}
@@ -95,12 +100,24 @@ export function InstructionsWizard({
const value = freeTextResponses[currentQuestion.id]
return typeof value === "string" ? value : ""
}, [currentQuestion, freeTextResponses])
+
+ const draftFreeTextValue = useMemo(() => {
+ if (!currentQuestion) {
+ return ""
+ }
+
+ const value = freeTextDrafts[currentQuestion.id]
+ return typeof value === "string" ? value : ""
+ }, [currentQuestion, freeTextDrafts])
+
+ const currentFreeTextValue =
+ draftFreeTextValue.length > 0 ? draftFreeTextValue : savedFreeTextValue
const freeTextConfig = currentQuestion?.freeText ?? null
const freeTextInputId = currentQuestion ? `free-text-${currentQuestion.id}` : "free-text"
const canSubmitFreeText = Boolean(freeTextConfig?.enabled && currentFreeTextValue.trim().length > 0)
- const hasSavedCustomFreeText = Boolean(freeTextConfig?.enabled && currentFreeTextValue.trim().length > 0)
+ const hasSavedCustomFreeText = Boolean(freeTextConfig?.enabled && savedFreeTextValue.trim().length > 0)
- const savedCustomFreeTextValue = currentFreeTextValue.trim()
+ const savedCustomFreeTextValue = savedFreeTextValue.trim()
const defaultAnswer = useMemo(
() => currentQuestion?.answers.find((answer) => answer.isDefault) ?? null,
@@ -190,12 +207,36 @@ export function InstructionsWizard({
const applyStackStep = useCallback(
(step: WizardStep, label: string | null, options?: { skipFastTrackPrompt?: boolean; stackId?: string }) => {
const skipFastTrackPrompt = options?.skipFastTrackPrompt ?? false
- const nextStackId = options?.stackId
+ const nextStackId = options?.stackId ?? null
+ const stepSignature = buildStepSignature(step)
+ const previousSignature = lastAppliedStackStepSignature.current
+ const isSameStep = previousSignature === stepSignature
setActiveStackLabel(label)
- setDynamicSteps([step])
setIsStackFastTrackPromptVisible(!skipFastTrackPrompt && step.questions.length > 0)
+ if (isSameStep) {
+ if (nextStackId) {
+ setResponses((prev) => {
+ if (prev[STACK_QUESTION_ID] === nextStackId) {
+ return prev
+ }
+
+ return {
+ ...prev,
+ [STACK_QUESTION_ID]: nextStackId,
+ }
+ })
+ }
+
+ lastAppliedStackStepSignature.current = stepSignature
+ return
+ }
+
+ const questionIds = new Set(step.questions.map((question) => question.id))
+
+ setDynamicSteps([step])
+
setResponses((prev) => {
const next: Responses = { ...prev }
@@ -203,12 +244,10 @@ export function InstructionsWizard({
next[STACK_QUESTION_ID] = nextStackId
}
- step.questions.forEach((question) => {
- if (question.id === STACK_QUESTION_ID) {
- return
+ questionIds.forEach((questionId) => {
+ if (questionId !== STACK_QUESTION_ID && questionId in next) {
+ delete next[questionId]
}
-
- delete next[question.id]
})
return next
@@ -222,13 +261,27 @@ export function InstructionsWizard({
let didMutate = false
const next = { ...prev }
- step.questions.forEach((question) => {
- if (question.id === STACK_QUESTION_ID) {
- return
+ questionIds.forEach((questionId) => {
+ if (questionId !== STACK_QUESTION_ID && questionId in next) {
+ delete next[questionId]
+ didMutate = true
}
+ })
+
+ return didMutate ? next : prev
+ })
+
+ setFreeTextDrafts((prev) => {
+ if (Object.keys(prev).length === 0) {
+ return prev
+ }
+
+ let didMutate = false
+ const next = { ...prev }
- if (next[question.id] !== undefined) {
- delete next[question.id]
+ questionIds.forEach((questionId) => {
+ if (questionId !== STACK_QUESTION_ID && questionId in next) {
+ delete next[questionId]
didMutate = true
}
})
@@ -239,6 +292,8 @@ export function InstructionsWizard({
setCurrentStepIndex(1)
setCurrentQuestionIndex(0)
setAutoFilledQuestionMap({})
+
+ lastAppliedStackStepSignature.current = stepSignature
},
[]
)
@@ -415,6 +470,7 @@ export function InstructionsWizard({
})
setFreeTextResponses((prev) => (Object.keys(prev).length > 0 ? {} : prev))
+ setFreeTextDrafts((prev) => (Object.keys(prev).length > 0 ? {} : prev))
markQuestionsAutoFilled(autoFilledIds)
setIsStackFastTrackPromptVisible(false)
@@ -517,7 +573,7 @@ export function InstructionsWizard({
const { value } = event.target
let didChange = false
- setFreeTextResponses((prev) => {
+ setFreeTextDrafts((prev) => {
const existing = prev[question.id]
if (value.length === 0) {
@@ -569,7 +625,9 @@ export function InstructionsWizard({
) => {
const allowAutoAdvance = options?.allowAutoAdvance ?? true
const trimmedValue = rawValue.trim()
- const existingValue = typeof freeTextResponses[question.id] === "string" ? freeTextResponses[question.id] : ""
+ const draftValue = typeof freeTextDrafts[question.id] === "string" ? freeTextDrafts[question.id] : ""
+ const savedValue = typeof freeTextResponses[question.id] === "string" ? freeTextResponses[question.id] : ""
+ const existingValue = savedValue
if (trimmedValue === existingValue) {
if (allowAutoAdvance && trimmedValue.length > 0 && !hasSelectionForQuestion(question)) {
@@ -578,6 +636,18 @@ export function InstructionsWizard({
}, 0)
}
+ if (draftValue.length > 0) {
+ setFreeTextDrafts((prev) => {
+ if (!(question.id in prev)) {
+ return prev
+ }
+
+ const next = { ...prev }
+ delete next[question.id]
+ return next
+ })
+ }
+
return
}
@@ -598,6 +668,26 @@ export function InstructionsWizard({
}
})
+ setFreeTextDrafts((prev) => {
+ if (trimmedValue.length === 0) {
+ if (!(question.id in prev)) {
+ return prev
+ }
+
+ const next = { ...prev }
+ delete next[question.id]
+ return next
+ }
+
+ if (!(question.id in prev)) {
+ return prev
+ }
+
+ const next = { ...prev }
+ delete next[question.id]
+ return next
+ })
+
clearAutoFilledFlag(question.id)
if (allowAutoAdvance && trimmedValue.length > 0 && !hasSelectionForQuestion(question)) {
@@ -660,6 +750,7 @@ export function InstructionsWizard({
const stackIdToClear = selectedStackId
setResponses({})
setFreeTextResponses({})
+ setFreeTextDrafts({})
setDynamicSteps([])
setCurrentStepIndex(0)
setCurrentQuestionIndex(0)
@@ -856,6 +947,7 @@ export function InstructionsWizard({