Skip to content

Commit 5bbe99a

Browse files
committed
feat: refactor stack detection and metadata handling for improved wizard integration
1 parent 99c1de1 commit 5bbe99a

File tree

8 files changed

+340
-257
lines changed

8 files changed

+340
-257
lines changed

app/api/scan-repo/route.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ import type {
66
RepoScanSummary,
77
RepoStructureSummary,
88
} from "@/types/repo-scan"
9-
import { collectConventionValues, normalizeConventionValue } from "@/lib/convention-values"
9+
import { loadStackQuestionMetadata, normalizeConventionValue } from "@/lib/question-metadata"
1010
import { loadStackConventions } from "@/lib/conventions"
1111
import { inferStackFromScan } from "@/lib/scan-to-wizard"
12+
import { stackQuestion } from "@/lib/wizard-config"
1213

1314
const GITHUB_API_BASE_URL = "https://api.github.com"
1415
const GITHUB_HOSTNAMES = new Set(["github.com", "www.github.com"])
@@ -302,10 +303,10 @@ const getTestingConventionValues = async (stackId: string): Promise<TestingConve
302303
return testingConventionCache.get(normalized)!
303304
}
304305

305-
const { conventions } = await loadStackConventions(normalized)
306+
const metadata = await loadStackQuestionMetadata(normalized)
306307
const values: TestingConventionValues = {
307-
unit: collectConventionValues(conventions, "testingUT"),
308-
e2e: collectConventionValues(conventions, "testingE2E"),
308+
unit: metadata.answersByResponseKey.testingUT ?? [],
309+
e2e: metadata.answersByResponseKey.testingE2E ?? [],
309310
}
310311
testingConventionCache.set(normalized, values)
311312
return values
@@ -873,8 +874,16 @@ export async function GET(request: NextRequest): Promise<NextResponse<RepoScanRe
873874

874875
const detectedStack = inferStackFromScan(summary)
875876
const { conventions, hasStackFile } = await loadStackConventions(detectedStack)
877+
const stackAnswers = stackQuestion?.answers ?? []
878+
const matchedStackAnswer = stackAnswers.find((answer) => answer.value === detectedStack) ?? null
879+
const stackSupported = matchedStackAnswer
880+
? matchedStackAnswer.disabled !== true && matchedStackAnswer.enabled !== false
881+
: false
882+
const stackLabel = matchedStackAnswer?.label ?? null
876883
summary.conventions = {
877884
stack: detectedStack,
885+
stackLabel,
886+
isSupported: stackSupported,
878887
hasCustomConventions: hasStackFile,
879888
structureRelevant: conventions.structureRelevant,
880889
}

app/existing/[repoUrl]/repo-scan-client.tsx

Lines changed: 151 additions & 99 deletions
Large diffs are not rendered by default.

docs/scan-flow.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,17 @@ This document outlines how repository scans are transformed into AI instruction
66

77
1. **Scan the repository** (`app/api/scan-repo/route.ts`)
88
- Detect languages, frameworks, tooling, structure hints, and enriched metadata.
9-
- Use stack conventions (`collectConventionValues`) to cross-check detection lists (e.g., testing tools) so any signal we add in `conventions/<stack>.json` becomes discoverable with minimal code changes.
10-
- 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.
9+
- Load stack question metadata (`loadStackQuestionMetadata`) so detection lists (e.g., testing tools) map onto the same canonical values the wizard exposes.
10+
- Reuse those canonical values to expand stack-specific heuristics (e.g., Python’s Behave directories, Next.js routing shapes) without hard-coding strings inside the scanner.
1111
- Infer the primary stack using `inferStackFromScan`.
1212
- Load stack conventions (`loadStackConventions`) to determine which structure hints matter and whether stack-specific rules exist.
1313
- Attach `summary.conventions` so the UI knows which directories to highlight and whether a conventions file was found.
1414

1515
2. **Build wizard defaults** (`lib/scan-to-wizard.ts`)
1616
- Start with an empty `WizardResponses` object.
17-
- Apply convention defaults from `conventions/<stack>.json` + `default.json`.
18-
- 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.
17+
- Layer in detections from the scan (tooling, testing, naming signals, etc.), matching scan values against the question catalog so defaults stay in sync with the wizard.
1918
- Run convention rules to tweak values based on detected tooling/testing.
20-
- 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.
19+
- Pull default answers directly from the stack’s question set (`loadStackQuestionMetadata`) and fill any remaining empty responses. We track which questions were auto-defaulted (`defaultedQuestionIds`) so the summary can explain why.
2120

2221
3. **Persist and surface responses**
2322
- `lib/scan-prefill.ts` merges the generated responses into local wizard state and stores both `autoFilledMap` and `defaultedMap` in localStorage.
@@ -34,8 +33,9 @@ This document outlines how repository scans are transformed into AI instruction
3433

3534
| Location | Purpose |
3635
| --- | --- |
37-
| `conventions/default.json` & `/conventions/<stack>.json` | Declarative defaults + rules for each stack (tooling choices, structure hints, apply-to glob, etc.). |
38-
| `lib/convention-values.ts` | Helpers that normalize and aggregate convention values (e.g., testingUT/testingE2E) for both the scanner and the wizard. |
36+
| `conventions/default.json` & `/conventions/<stack>.json` | Declarative rules, `applyToGlob`, and structure hints for each stack. |
37+
| `lib/question-metadata.ts` | Caches question answers/defaults per stack so both the scanner and wizard share a single source of truth. |
38+
| `lib/wizard-responses.ts` | Produces empty `WizardResponses` objects and guards response keys. |
3939
| `data/stacks.json` | List of stacks exposed to the wizard; each should have a matching conventions file. |
4040
| `data/questions/<stack>.json` | Stack-specific questions with default answers and metadata. These defaults are now honored automatically when scan data is missing. |
4141

lib/convention-values.ts

Lines changed: 0 additions & 32 deletions
This file was deleted.

lib/question-metadata.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { buildStepsForStack } from "@/lib/wizard-summary-data"
2+
import { isWizardResponseKey } from "@/lib/wizard-responses"
3+
import type { WizardResponses, WizardStep } from "@/types/wizard"
4+
5+
export const normalizeConventionValue = (value: string): string => value.trim().toLowerCase()
6+
7+
export type StackQuestionDefault = {
8+
questionId: string
9+
responseKey: keyof WizardResponses
10+
value: string
11+
label: string
12+
}
13+
14+
export type StackQuestionMetadata = {
15+
defaults: StackQuestionDefault[]
16+
answersByResponseKey: Partial<Record<keyof WizardResponses, string[]>>
17+
}
18+
19+
const metadataCache = new Map<string, StackQuestionMetadata>()
20+
21+
const extractQuestionMetadata = (steps: WizardStep[], stack: string): StackQuestionMetadata => {
22+
const defaults: StackQuestionDefault[] = []
23+
const answersByResponseKey: Partial<Record<keyof WizardResponses, string[]>> = {}
24+
25+
steps.forEach((step) => {
26+
step.questions.forEach((question) => {
27+
const rawKey = question.responseKey ?? question.id
28+
if (!rawKey || rawKey === "stackSelection" || !isWizardResponseKey(rawKey)) {
29+
return
30+
}
31+
32+
const key = rawKey as keyof WizardResponses
33+
const enabledAnswers = question.answers.filter((answer) => !answer.disabled)
34+
35+
if (enabledAnswers.length > 0) {
36+
const seen = new Set<string>()
37+
answersByResponseKey[key] = enabledAnswers.reduce<string[]>((list, answer) => {
38+
const value = typeof answer.value === "string" ? answer.value : ""
39+
if (!value || seen.has(value.toLowerCase())) {
40+
return list
41+
}
42+
seen.add(value.toLowerCase())
43+
list.push(value)
44+
return list
45+
}, [])
46+
}
47+
48+
const defaultAnswer = enabledAnswers.find((answer) => answer.isDefault)
49+
if (defaultAnswer && typeof defaultAnswer.value === "string" && defaultAnswer.value.trim().length > 0) {
50+
defaults.push({
51+
questionId: question.id,
52+
responseKey: key,
53+
value: defaultAnswer.value,
54+
label: defaultAnswer.label ?? defaultAnswer.value,
55+
})
56+
}
57+
})
58+
})
59+
60+
return { defaults, answersByResponseKey }
61+
}
62+
63+
export const loadStackQuestionMetadata = async (stack: string): Promise<StackQuestionMetadata> => {
64+
const normalized = stack.trim().toLowerCase()
65+
if (metadataCache.has(normalized)) {
66+
return metadataCache.get(normalized)!
67+
}
68+
69+
const { steps } = await buildStepsForStack(stack)
70+
const metadata = extractQuestionMetadata(steps, stack)
71+
metadataCache.set(normalized, metadata)
72+
return metadata
73+
}

lib/scan-to-wizard.ts

Lines changed: 25 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { collectConventionValues, normalizeConventionValue } from "@/lib/convention-values"
21
import { applyConventionRules, loadStackConventions } from "@/lib/conventions"
3-
import { buildStepsForStack } from "@/lib/wizard-summary-data"
2+
import { loadStackQuestionMetadata, normalizeConventionValue } from "@/lib/question-metadata"
3+
import { createEmptyResponses } from "@/lib/wizard-responses"
44
import type { RepoScanSummary } from "@/types/repo-scan"
55
import type { LoadedConvention } from "@/types/conventions"
6-
import type { WizardResponses, WizardStep } from "@/types/wizard"
6+
import type { WizardResponses } from "@/types/wizard"
77

88
const STACK_FALLBACK = "react"
99

@@ -12,14 +12,12 @@ const toLowerArray = (values: string[] | undefined | null) =>
1212

1313
const detectFromScanList = (
1414
scanList: string[] | undefined | null,
15-
conventions: LoadedConvention,
16-
key: keyof WizardResponses,
15+
candidates: string[] | undefined | null,
1716
): string | null => {
1817
if (!Array.isArray(scanList) || scanList.length === 0) {
1918
return null
2019
}
21-
const candidates = collectConventionValues(conventions, key)
22-
if (candidates.length === 0) {
20+
if (!Array.isArray(candidates) || candidates.length === 0) {
2321
return null
2422
}
2523

@@ -66,35 +64,6 @@ const detectStack = (scan: RepoScanSummary): string => {
6664

6765
export const inferStackFromScan = (scan: RepoScanSummary): string => detectStack(scan)
6866

69-
const createEmptyResponses = (stack: string): WizardResponses => ({
70-
stackSelection: stack,
71-
tooling: null,
72-
language: null,
73-
fileStructure: null,
74-
styling: null,
75-
testingUT: null,
76-
testingE2E: null,
77-
projectPriority: null,
78-
codeStyle: null,
79-
variableNaming: null,
80-
fileNaming: null,
81-
componentNaming: null,
82-
exports: null,
83-
comments: null,
84-
collaboration: null,
85-
stateManagement: null,
86-
apiLayer: null,
87-
folders: null,
88-
dataFetching: null,
89-
reactPerf: null,
90-
auth: null,
91-
validation: null,
92-
logging: null,
93-
commitStyle: null,
94-
prRules: null,
95-
outputFile: null,
96-
})
97-
9867
const detectLanguage = (scan: RepoScanSummary): string | null => {
9968
const languages = toLowerArray(scan.languages)
10069
if (languages.includes("typescript")) return "typescript"
@@ -103,22 +72,17 @@ const detectLanguage = (scan: RepoScanSummary): string | null => {
10372
return scan.language ? String(scan.language) : null
10473
}
10574

106-
const detectTestingUnit = (scan: RepoScanSummary, conventions: LoadedConvention): string | null =>
107-
detectFromScanList(scan.testing, conventions, "testingUT")
75+
const detectTestingUnit = (scan: RepoScanSummary, candidates: string[] | undefined | null): string | null =>
76+
detectFromScanList(scan.testing, candidates)
10877

109-
const detectTestingE2E = (scan: RepoScanSummary, conventions: LoadedConvention): string | null =>
110-
detectFromScanList(scan.testing, conventions, "testingE2E")
78+
const detectTestingE2E = (scan: RepoScanSummary, candidates: string[] | undefined | null): string | null =>
79+
detectFromScanList(scan.testing, candidates)
11180

112-
const detectToolingSummary = (scan: RepoScanSummary, conventions: LoadedConvention): string | null => {
81+
const detectToolingSummary = (scan: RepoScanSummary): string | null => {
11382
if (scan.tooling && scan.tooling.length > 0) {
11483
return scan.tooling.join(" + ")
11584
}
11685

117-
const defaultTooling = conventions.defaults.tooling
118-
if (typeof defaultTooling === "string" && defaultTooling.trim().length > 0) {
119-
return defaultTooling
120-
}
121-
12286
return null
12387
}
12488

@@ -150,57 +114,6 @@ const detectPRRules = (scan: RepoScanSummary): string | null => {
150114
return null
151115
}
152116

153-
type StackQuestionDefault = {
154-
questionId: string
155-
responseKey: keyof WizardResponses
156-
value: string
157-
label: string
158-
}
159-
160-
const defaultsCache = new Map<string, StackQuestionDefault[]>()
161-
162-
const extractDefaultsFromSteps = (steps: WizardStep[], template: WizardResponses): StackQuestionDefault[] => {
163-
const defaults: StackQuestionDefault[] = []
164-
steps.forEach((step) => {
165-
step.questions.forEach((question) => {
166-
const rawKey = question.responseKey ?? question.id
167-
if (!rawKey || rawKey === "stackSelection") {
168-
return
169-
}
170-
const defaultAnswer = question.answers.find((answer) => answer.isDefault && !answer.disabled)
171-
if (!defaultAnswer) {
172-
return
173-
}
174-
if (!(rawKey in template)) {
175-
return
176-
}
177-
const key = rawKey as keyof WizardResponses
178-
defaults.push({
179-
questionId: question.id,
180-
responseKey: key,
181-
value: defaultAnswer.value,
182-
label: defaultAnswer.label ?? defaultAnswer.value,
183-
})
184-
})
185-
})
186-
return defaults
187-
}
188-
189-
const loadStackQuestionDefaults = async (
190-
stack: string,
191-
template: WizardResponses,
192-
): Promise<StackQuestionDefault[]> => {
193-
const normalized = stack.trim().toLowerCase()
194-
if (defaultsCache.has(normalized)) {
195-
return defaultsCache.get(normalized)!
196-
}
197-
198-
const { steps } = await buildStepsForStack(stack)
199-
const defaults = extractDefaultsFromSteps(steps, template)
200-
defaultsCache.set(normalized, defaults)
201-
return defaults
202-
}
203-
204117
type BuildResult = {
205118
stack: string
206119
responses: WizardResponses
@@ -213,28 +126,28 @@ type BuildResult = {
213126
export const buildResponsesFromScan = async (scan: RepoScanSummary): Promise<BuildResult> => {
214127
const stack = detectStack(scan)
215128
const { conventions, hasStackFile } = await loadStackConventions(stack)
129+
const { defaults: questionDefaults, answersByResponseKey } = await loadStackQuestionMetadata(stack)
216130

217131
const base = createEmptyResponses(stack)
218-
const withDefaults: WizardResponses = { ...base, ...conventions.defaults }
219-
220-
applyDetectedValue(withDefaults, "tooling", detectToolingSummary(scan, conventions))
221-
applyDetectedValue(withDefaults, "language", detectLanguage(scan))
222-
applyDetectedValue(withDefaults, "testingUT", detectTestingUnit(scan, conventions))
223-
applyDetectedValue(withDefaults, "testingE2E", detectTestingE2E(scan, conventions))
224-
applyDetectedValue(withDefaults, "fileNaming", detectFileNaming(scan))
225-
applyDetectedValue(withDefaults, "componentNaming", detectComponentNaming(scan))
226-
applyDetectedValue(withDefaults, "commitStyle", detectCommitStyle(scan))
227-
applyDetectedValue(withDefaults, "prRules", detectPRRules(scan))
228-
229-
const afterRules = applyConventionRules(withDefaults, conventions.rules, scan)
132+
const withDetected: WizardResponses = { ...base }
133+
134+
applyDetectedValue(withDetected, "tooling", detectToolingSummary(scan))
135+
applyDetectedValue(withDetected, "language", detectLanguage(scan))
136+
applyDetectedValue(withDetected, "testingUT", detectTestingUnit(scan, answersByResponseKey.testingUT))
137+
applyDetectedValue(withDetected, "testingE2E", detectTestingE2E(scan, answersByResponseKey.testingE2E))
138+
applyDetectedValue(withDetected, "fileNaming", detectFileNaming(scan))
139+
applyDetectedValue(withDetected, "componentNaming", detectComponentNaming(scan))
140+
applyDetectedValue(withDetected, "commitStyle", detectCommitStyle(scan))
141+
applyDetectedValue(withDetected, "prRules", detectPRRules(scan))
142+
143+
const afterRules = applyConventionRules(withDetected, conventions.rules, scan)
230144
afterRules.stackSelection = stack
231145

232146
const defaultedQuestionIds: Record<string, boolean> = {}
233147
const defaultedResponseMeta: Partial<Record<
234148
keyof WizardResponses,
235149
{ questionId: string; label: string; value: string }
236150
>> = {}
237-
const questionDefaults = await loadStackQuestionDefaults(stack, afterRules)
238151
questionDefaults.forEach(({ responseKey, questionId, value, label }) => {
239152
const currentValue = afterRules[responseKey]
240153
if (currentValue === null || currentValue === undefined || currentValue === "") {
@@ -248,17 +161,14 @@ export const buildResponsesFromScan = async (scan: RepoScanSummary): Promise<Bui
248161
}
249162
})
250163

251-
if (!afterRules.tooling) {
252-
applyDetectedValue(afterRules, "tooling", detectToolingSummary(scan, conventions))
253-
}
254164
if (!afterRules.language) {
255165
applyDetectedValue(afterRules, "language", detectLanguage(scan))
256166
}
257167
if (!afterRules.testingUT) {
258-
applyDetectedValue(afterRules, "testingUT", detectTestingUnit(scan, conventions))
168+
applyDetectedValue(afterRules, "testingUT", detectTestingUnit(scan, answersByResponseKey.testingUT))
259169
}
260170
if (!afterRules.testingE2E) {
261-
applyDetectedValue(afterRules, "testingE2E", detectTestingE2E(scan, conventions))
171+
applyDetectedValue(afterRules, "testingE2E", detectTestingE2E(scan, answersByResponseKey.testingE2E))
262172
}
263173
if (!afterRules.fileNaming) {
264174
applyDetectedValue(afterRules, "fileNaming", detectFileNaming(scan))

0 commit comments

Comments
 (0)