diff --git a/app/api/scan-repo/route.ts b/app/api/scan-repo/route.ts index 9987296..2b7d18d 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 { buildDependencyAnalysisTasks, hasDependencyDetectionRules } from "@/lib/stack-detection" +import type { DependencyAnalysisTask } from "@/lib/stack-detection" import { loadStackQuestionMetadata, normalizeConventionValue } from "@/lib/question-metadata" import { loadStackConventions } from "@/lib/conventions" import { inferStackFromScan } from "@/lib/scan-to-wizard" @@ -423,6 +425,107 @@ const readTextFile = async ( } } +type DependencyDetectionOutcome = { + frameworks: Set + languages: Set + preferredStacks: Set + primaryLanguage: string | null +} + +const manifestHasDependency = (pkg: PackageJson, name: string): boolean => { + const needle = name.trim().toLowerCase() + if (!needle) { + return false + } + + const sources = [ + pkg.dependencies, + pkg.devDependencies, + pkg.peerDependencies, + pkg.optionalDependencies, + ] + + return sources.some((source) => { + if (!source) { + return false + } + + return Object.keys(source).some((key) => key.toLowerCase() === needle) + }) +} + +const evaluateDependencyAnalysisTasks = async ( + owner: string, + repo: string, + ref: string, + headers: Record, + tasks: DependencyAnalysisTask[], + packageJson: PackageJson | null, +): Promise => { + const outcome: DependencyDetectionOutcome = { + frameworks: new Set(), + languages: new Set(), + preferredStacks: new Set(), + primaryLanguage: null, + } + + for (const task of tasks) { + const needsJson = task.signals.some((signal) => signal.type === "json-dependency") + const needsText = task.signals.some((signal) => signal.type !== "json-dependency") + let manifest: PackageJson | null = null + let content: string | null = null + + if (needsJson && packageJson && task.path.toLowerCase() === "package.json") { + manifest = packageJson + } + + if (needsText || !manifest) { + content = await readTextFile(owner, repo, ref, task.path, headers) + if (content === null) { + continue + } + } + + if (needsJson && !manifest && content) { + try { + manifest = JSON.parse(content) as PackageJson + } catch { + manifest = null + } + } + + const contentLower = content ? content.toLowerCase() : "" + + task.signals.forEach((signal) => { + let matched = false + + if (signal.type === "json-dependency") { + matched = Boolean(manifest && manifestHasDependency(manifest, signal.match)) + } else { + matched = Boolean(contentLower && contentLower.includes(signal.matchLower)) + } + + if (!matched) { + return + } + + signal.addFrameworks.forEach((framework) => outcome.frameworks.add(framework)) + signal.addLanguages.forEach((language) => outcome.languages.add(language)) + + const preferredStack = signal.preferStack ?? signal.stack + if (preferredStack) { + outcome.preferredStacks.add(preferredStack) + } + + if (!outcome.primaryLanguage && signal.setPrimaryLanguage) { + outcome.primaryLanguage = signal.setPrimaryLanguage + } + }) + } + + return outcome +} + type FileStyleKey = "pascal" | "camel" | "kebab" | "snake" const stripExtension = (name: string) => name.replace(/\.[^.]+$/u, "") @@ -459,11 +562,11 @@ const classifyNameStyle = (rawName: string): FileStyleKey | null => { return null } -const pickDominantStyle = (counts: Record): FileStyleKey | null => { - let winner: FileStyleKey | null = null +const pickDominantStyle = (counts: Record): Key | null => { + let winner: Key | null = null let winnerCount = 0 - for (const key of Object.keys(counts) as FileStyleKey[]) { + for (const key of Object.keys(counts) as Key[]) { const value = counts[key] if (value > winnerCount) { winner = key @@ -542,6 +645,130 @@ const analyzeNamingStyles = (paths: string[]) => { } } +type IdentifierStyleKey = "camel" | "snake" | "pascal" + +const classifyIdentifierStyle = (rawName: string): IdentifierStyleKey | null => { + const trimmed = rawName.trim().replace(/^_+/, "").replace(/_+$/u, "") + if (!trimmed) { + return null + } + + if (/^[A-Z0-9_]+$/u.test(trimmed)) { + return null + } + + if (/^[a-z]+(?:_[a-z0-9]+)+$/u.test(trimmed)) { + return "snake" + } + + if (/^[A-Z][A-Za-z0-9]*$/u.test(trimmed)) { + return "pascal" + } + + if (/^[a-z][A-Za-z0-9]*$/u.test(trimmed)) { + return "camel" + } + + if (/^[a-z]+(?:[A-Z][a-z0-9]*)+$/u.test(trimmed)) { + return "camel" + } + + return null +} + +const VARIABLE_ANALYSIS_MAX_FILES = 20 +const VARIABLE_ANALYSIS_MAX_CONTENT_LENGTH = 20000 + +const extractIdentifiersFromJs = (contents: string): string[] => { + const identifiers: string[] = [] + const simpleDeclaration = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)/gu + for (const match of contents.matchAll(simpleDeclaration)) { + const name = match[1] + if (name) { + identifiers.push(name.replace(/\$/g, "")) + } + } + const functionDeclaration = /\bfunction\s+([A-Za-z_$][\w$]*)/gu + for (const match of contents.matchAll(functionDeclaration)) { + const name = match[1] + if (name) { + identifiers.push(name.replace(/\$/g, "")) + } + } + return identifiers +} + +const extractIdentifiersFromPython = (contents: string): string[] => { + const identifiers: string[] = [] + const assignment = /^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*=/gmu + for (const match of contents.matchAll(assignment)) { + const name = match[1] + if (name && !/^self$|^cls$/u.test(name)) { + identifiers.push(name) + } + } + const functionDef = /^\s*def\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\(/gmu + for (const match of contents.matchAll(functionDef)) { + const name = match[1] + if (name) { + identifiers.push(name) + } + } + return identifiers +} + +const analyzeVariableNamingStyle = async ( + owner: string, + repo: string, + ref: string, + paths: string[], + headers: Record, +): Promise => { + const counts: Record = { camel: 0, snake: 0, pascal: 0 } + const candidates: string[] = [] + + for (const filePath of paths) { + if (candidates.length >= VARIABLE_ANALYSIS_MAX_FILES) { + break + } + if (/\.(ts|tsx|js|jsx|mjs|cjs)$/iu.test(filePath) || /\.py$/iu.test(filePath)) { + candidates.push(filePath) + } + } + + for (const candidate of candidates) { + const contents = await readTextFile(owner, repo, ref, candidate, headers) + if (!contents) { + continue + } + const truncated = contents.slice(0, VARIABLE_ANALYSIS_MAX_CONTENT_LENGTH) + const identifiers = + /\.py$/iu.test(candidate) ? extractIdentifiersFromPython(truncated) : extractIdentifiersFromJs(truncated) + if (identifiers.length === 0) { + continue + } + identifiers.forEach((identifier) => { + const style = classifyIdentifierStyle(identifier) + if (style) { + counts[style] += 1 + } + }) + } + + const dominant = pickDominantStyle(counts) + if (!dominant) { + return null + } + + const mapping: Record = { + camel: "camelCase", + snake: "snake_case", + pascal: "PascalCase", + } + + return mapping[dominant] ?? null +} + const detectEnrichedSignals = async ( owner: string, repo: string, @@ -654,6 +881,7 @@ const detectEnrichedSignals = async ( if (hasMatch(/(^|\/)prettier\.config\.(js|cjs|mjs|ts)?$/) || hasMatch(/(^|\/)\.prettierrc(\.[a-z]+)?$/)) editor.push("prettier") const { fileNamingStyle, componentNamingStyle } = analyzeNamingStyles(paths) + const variableNamingStyle = await analyzeVariableNamingStyle(owner, repo, ref, paths, headers) // Code style detection (ESLint presets) let codeStylePreference: string | null = null @@ -704,6 +932,7 @@ const detectEnrichedSignals = async ( codeQuality, editor, fileNamingStyle, + variableNamingStyle, componentNamingStyle, codeStylePreference, commitMessageStyle, @@ -796,7 +1025,7 @@ export async function GET(request: NextRequest): Promise - const languages = Object.entries(languagesJson) + let languages = Object.entries(languagesJson) .sort(([, bytesA], [, bytesB]) => bytesB - bytesA) .map(([name]) => name) @@ -852,18 +1081,52 @@ export async function GET(request: NextRequest): Promise 0) { + const dependencyOutcome = await evaluateDependencyAnalysisTasks( + owner, + repo, + defaultBranch, + headers, + dependencyTasks, + packageJson, + ) + + dependencyOutcome.frameworks.forEach((framework) => frameworkSet.add(framework)) + dependencyOutcome.languages.forEach((language) => languageSet.add(language)) + + if (dependencyOutcome.primaryLanguage) { + preferredPrimaryLanguage = dependencyOutcome.primaryLanguage + } + } + } + + const mergedFrameworks = dedupeAndSort(frameworkSet) + languages = Array.from(languageSet) + if (lowestRateLimit !== null && lowestRateLimit < 5) { warnings.push(`GitHub API rate limit is low (remaining: ${lowestRateLimit}).`) } const enriched = await detectEnrichedSignals(owner, repo, defaultBranch, paths, packageJson, headers) + const sortedLanguages = dedupeAndSort(languages) + const primaryLanguage = preferredPrimaryLanguage + ?? repoJson.language + ?? (sortedLanguages.length > 0 ? sortedLanguages[0] : null) + const summary: RepoScanSummary = { repo: `${owner}/${repo}`, defaultBranch, - language: repoJson.language ?? (languages.length > 0 ? languages[0] : null), - languages: dedupeAndSort(languages), - frameworks, + language: primaryLanguage, + languages: sortedLanguages, + frameworks: mergedFrameworks, tooling, testing, structure, diff --git a/data/stacks.json b/data/stacks.json index a05d18b..3516397 100644 --- a/data/stacks.json +++ b/data/stacks.json @@ -38,7 +38,95 @@ "backend", "scripting", "language" - ] + ], + "detection": { + "dependencyFiles": [ + { + "patterns": [ + "pyproject.toml", + "poetry.lock", + "Pipfile", + "Pipfile.lock" + ], + "signals": [ + { + "match": "fastapi", + "addFrameworks": [ + "FastAPI" + ], + "addLanguages": [ + "Python" + ], + "setPrimaryLanguage": "Python", + "preferStack": "python" + }, + { + "match": "django", + "addFrameworks": [ + "Django" + ], + "addLanguages": [ + "Python" + ], + "setPrimaryLanguage": "Python", + "preferStack": "python" + }, + { + "match": "flask", + "addFrameworks": [ + "Flask" + ], + "addLanguages": [ + "Python" + ], + "setPrimaryLanguage": "Python", + "preferStack": "python" + } + ] + }, + { + "patterns": [ + "requirements.txt", + "requirements/*.txt" + ], + "signals": [ + { + "match": "fastapi", + "addFrameworks": [ + "FastAPI" + ], + "addLanguages": [ + "Python" + ], + "setPrimaryLanguage": "Python", + "preferStack": "python" + }, + { + "match": "django", + "addFrameworks": [ + "Django" + ], + "addLanguages": [ + "Python" + ], + "setPrimaryLanguage": "Python", + "preferStack": "python" + }, + { + "match": "flask", + "addFrameworks": [ + "Flask" + ], + "addLanguages": [ + "Python" + ], + "setPrimaryLanguage": "Python", + "preferStack": "python" + } + ] + } + ] + } }, { "value": "angular", @@ -105,7 +193,60 @@ "routing", "full-stack" ] + }, + { + "value": "java", + "label": "Java / Spring Boot", + "icon": "spring", + "docs": "https://spring.io/projects/spring-boot", + "tags": [ + "backend", + "java" + ], + "disabled": true, + "disabledLabel": "Soon", + "detection": { + "dependencyFiles": [ + { + "patterns": [ + "pom.xml" + ], + "signals": [ + { + "match": "org.springframework.boot", + "addFrameworks": [ + "Spring Boot" + ], + "addLanguages": [ + "Java" + ], + "setPrimaryLanguage": "Java", + "preferStack": "java" + } + ] + }, + { + "patterns": [ + "build.gradle", + "build.gradle.kts" + ], + "signals": [ + { + "match": "org.springframework.boot", + "addFrameworks": [ + "Spring Boot" + ], + "addLanguages": [ + "Java" + ], + "setPrimaryLanguage": "Java", + "preferStack": "java" + } + ] + } + ] + } } ] } -] \ No newline at end of file +] diff --git a/docs/scan-process.md b/docs/scan-process.md new file mode 100644 index 0000000..e0d3aef --- /dev/null +++ b/docs/scan-process.md @@ -0,0 +1,90 @@ +# Repository Scan Process + +This document explains how DevContext scans a GitHub repository and turns signals into stack detection, conventions, and instruction file generation. Use it as a reference when extending detection or debugging results. + +## High‑Level Flow + +1. Fetch repository metadata (default branch, languages). +2. Fetch repository tree and collect file paths. +3. Read key manifests (e.g., `package.json`) and detect tooling/testing/framework hints. +4. Run dependency/packages analyzer driven by `data/stacks.json` to identify frameworks from dependency files (e.g., FastAPI, Django, Spring Boot). +5. Merge signals and build a `RepoScanSummary` with languages, frameworks, tooling, testing, structure, and warnings. +6. Infer the stack (`inferStackFromScan`). Unsupported stacks are marked as such (no React fallback). +7. Build wizard responses from the scan and conventions, then generate instruction files via templates. + +## Inputs and Outputs + +- Input: GitHub repo URL (`owner/repo`). +- Output: `RepoScanSummary` JSON (used by the UI and generator) and optional rendered instruction files (Copilot/Cursor/Agents). + +## Key Stages and Files + +- API route and scan pipeline: `app/api/scan-repo/route.ts` + - Parse repo URL, fetch repo and language data from GitHub. + - Fetch tree (`git/trees?recursive=1`) and build a list of paths. + - Optionally read `package.json` (for Node projects). + - Detect base tooling/testing/frameworks from filenames and dependencies (`detectTooling`). + - Evaluate dependency/package analyzer tasks (see below) to detect frameworks from dependency files. + - Detect enriched signals (package manager, router, styling, CI, naming, etc.). + - File naming is inferred from the repository tree; variable naming samples up to 20 representative code files (JS/TS/Python) to classify the dominant identifier style. + - Merge and return `RepoScanSummary` with warnings. + +- Stack inference and response building: `lib/scan-to-wizard.ts` + - `inferStackFromScan` maps frameworks/languages to a supported stack or `unsupported`. + - Builds wizard responses by applying detected values and stack defaults. + - Applies conventions (`lib/conventions.ts`) to fine‑tune defaults based on scan signals. + +- Template rendering: `app/api/scan-generate/[fileId]/route.ts` and `lib/template-render.ts` + - Renders chosen instruction file using templates and filled responses. + +## Dependency / Packages Analyzer + +This is a declarative system that reads dependency files per stack to refine framework detection. + +- Configuration source: `data/stacks.json` + - Each stack answer can include a `detection.dependencyFiles` section listing patterns and signals. + - Example (Python): detect `fastapi`, `django`, `flask` inside `pyproject.toml`, `poetry.lock`, `requirements*.txt`. + - Example (Java): detect `org.springframework.boot` inside `pom.xml`, `build.gradle(.kts)`. + +- Types: `types/stack-detection.ts` + - Describes dependency files and signals (substring matches, JSON dependency checks, and side effects like setting primary language). + +- Compiler: `lib/stack-detection.ts` + - Compiles patterns to regex and produces analysis tasks for matching repo paths. + +- Evaluation: `app/api/scan-repo/route.ts` + - `buildDependencyAnalysisTasks(paths)` builds work from repo file list. + - For each task, the route reads file content from GitHub and applies configured signals. + - Merges detected frameworks/languages and may override the primary language when strong signals are found. + +## Unsupported Stacks + +- If no known framework is detected and the language isn’t explicitly supported, the stack is set to `unsupported`. +- The UI shows a “Not yet supported” notice and disables generation for that repo (prevents React‑centric output in non‑JS repos). + +## Extending Detection + +- Add or refine signals in `data/stacks.json` under the relevant stack answer’s `detection` field. + - Add new `patterns` for dependency files. + - Add `signals` with `match` values and side effects (`addFrameworks`, `addLanguages`, `setPrimaryLanguage`). +- If you add a new supported stack, also create: + - `data/questions/.json` (wizard questions/defaults) + - `conventions/.json` (defaults and rules) + - Optional template guidance in `lib/stack-guidance.ts`. + +## Field Reference (RepoScanSummary) + +- `language`: Primary language (may be overridden by dependency signals). +- `languages`: All detected languages. +- `frameworks`: Detected frameworks (from dependencies and tooling). +- `tooling`: Detected build and quality tooling. +- `testing`: Detected testing frameworks. +- `structure`: Presence of `src/`, `tests/`, `components/`, `apps/`, `packages/`. +- `fileNamingStyle`: Primary file naming pattern detected from filenames (`kebab-case`, `snake_case`, etc.). +- `variableNamingStyle`: Dominant identifier casing inferred from sampled source files. +- `warnings`: Scanner warnings (e.g., truncated tree, low API rate limit). +- `conventions`: Stack label, support flag, and structure-relevant keys. + +## Related Docs + +- `docs/scan-flow.md`: end‑to‑end wizard flow and how scan results become defaults. diff --git a/lib/scan-to-wizard.ts b/lib/scan-to-wizard.ts index 290a061..0674404 100644 --- a/lib/scan-to-wizard.ts +++ b/lib/scan-to-wizard.ts @@ -5,7 +5,7 @@ import type { RepoScanSummary } from "@/types/repo-scan" import type { LoadedConvention } from "@/types/conventions" import type { WizardResponses } from "@/types/wizard" -const STACK_FALLBACK = "react" +const STACK_FALLBACK = "unsupported" const toLowerArray = (values: string[] | undefined | null) => Array.isArray(values) ? values.map((value) => value.toLowerCase()) : [] @@ -49,6 +49,7 @@ const applyDetectedValue = ( const detectStack = (scan: RepoScanSummary): string => { const frameworks = toLowerArray(scan.frameworks) const languages = toLowerArray(scan.languages) + const primaryLanguage = scan.language ? scan.language.trim().toLowerCase() : null if (frameworks.some((name) => /next\.?js/.test(name))) return "nextjs" if (frameworks.includes("nuxt")) return "nuxt" @@ -59,6 +60,8 @@ const detectStack = (scan: RepoScanSummary): string => { if (frameworks.includes("svelte")) return "svelte" if (frameworks.includes("react")) return "react" if (languages.includes("python")) return "python" + + // No known framework detected; mark as unsupported instead of falling back return STACK_FALLBACK } @@ -68,10 +71,19 @@ 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("java")) return "Java" if (languages.includes("python")) return "Python" return scan.language ? String(scan.language) : null } +const detectApiLayer = (scan: RepoScanSummary): string | null => { + const frameworks = toLowerArray(scan.frameworks) + if (frameworks.includes("fastapi")) return "fastapi" + if (frameworks.includes("django")) return "django" + if (frameworks.includes("flask")) return "flask" + return null +} + const detectTestingUnit = (scan: RepoScanSummary, candidates: string[] | undefined | null): string | null => detectFromScanList(scan.testing, candidates) @@ -91,6 +103,11 @@ const detectFileNaming = (scan: RepoScanSummary): string | null => { return typeof detected === "string" ? detected : null } +const detectVariableNaming = (scan: RepoScanSummary): string | null => { + const detected = (scan as Record).variableNamingStyle + return typeof detected === "string" ? detected : null +} + const detectComponentNaming = (scan: RepoScanSummary): string | null => { const detected = (scan as Record).componentNamingStyle if (typeof detected === "string") { @@ -135,10 +152,12 @@ export const buildResponsesFromScan = async (scan: RepoScanSummary): Promise["type"] + stack: string + addFrameworks: string[] + addLanguages: string[] +} + +type CompiledDependencyFileRule = { + stack: string + patterns: CompiledPattern[] + signals: CompiledDependencySignal[] +} + +export type DependencyAnalysisTask = { + path: string + signals: CompiledDependencySignal[] +} + +const stackQuestionSet = stacksData as DataQuestionSource[] + +const escapeRegex = (value: string) => value.replace(/[-/\\^$+?.()|[\]{}]/g, "\\$&") + +const ensurePattern = (pattern: string): string => { + if (pattern.includes("/")) { + return pattern + } + return `**/${pattern}` +} + +const globToRegex = (pattern: string): RegExp => { + const normalized = ensurePattern(pattern) + .replace(/\*\*/g, "__DOUBLE_STAR__") + .replace(/\*/g, "__SINGLE_STAR__") + .split("/") + .map((segment) => { + if (segment === "__DOUBLE_STAR__") { + return ".*" + } + if (segment === "__SINGLE_STAR__") { + return "[^/]*" + } + return escapeRegex(segment) + }) + .join("/") + .replace(/__DOUBLE_STAR__/g, ".*") + .replace(/__SINGLE_STAR__/g, "[^/]*") + + return new RegExp(`^${normalized}$`, "i") +} + +const compilePatterns = (fileDetection: StackDependencyFileDetection): CompiledPattern[] => { + const patterns: string[] = [] + + if (typeof (fileDetection as { path?: string }).path === "string") { + patterns.push((fileDetection as { path: string }).path) + } + + if (Array.isArray(fileDetection.paths)) { + patterns.push(...fileDetection.paths) + } + + if (Array.isArray(fileDetection.patterns)) { + patterns.push(...fileDetection.patterns) + } + + const uniquePatterns = Array.from(new Set(patterns.map((pattern) => pattern.trim()).filter(Boolean))) + + return uniquePatterns.map((pattern) => ({ + raw: pattern, + regex: globToRegex(pattern), + })) +} + +const compileSignals = (stack: string, signals: StackDependencySignal[]): CompiledDependencySignal[] => + signals.map((signal) => ({ + stack, + match: signal.match, + matchLower: signal.match.toLowerCase(), + type: signal.type ?? "substring", + addFrameworks: Array.isArray(signal.addFrameworks) ? signal.addFrameworks : [], + addLanguages: Array.isArray(signal.addLanguages) ? signal.addLanguages : [], + preferStack: signal.preferStack, + setPrimaryLanguage: signal.setPrimaryLanguage, + })) + +const compiledRules: CompiledDependencyFileRule[] = stackQuestionSet.flatMap((question) => + question.answers.flatMap((answer) => { + const detectionConfig = (answer.detection ?? {}) as StackDetectionConfig + const dependencyFiles = detectionConfig.dependencyFiles ?? [] + + return dependencyFiles + .map((fileDetection) => { + const patterns = compilePatterns(fileDetection) + const signals = compileSignals(answer.value, fileDetection.signals ?? []) + + if (patterns.length === 0 || signals.length === 0) { + return null + } + + return { + stack: answer.value, + patterns, + signals, + } satisfies CompiledDependencyFileRule + }) + .filter((entry): entry is CompiledDependencyFileRule => entry !== null) + }), +) + +export const hasDependencyDetectionRules = compiledRules.length > 0 + +export const buildDependencyAnalysisTasks = (paths: string[]): DependencyAnalysisTask[] => { + if (!hasDependencyDetectionRules || paths.length === 0) { + return [] + } + + const taskMap = new Map() + + paths.forEach((path) => { + const normalizedPath = path.trim() + if (!normalizedPath) { + return + } + + compiledRules.forEach((rule) => { + const matchesRule = rule.patterns.some((pattern) => pattern.regex.test(normalizedPath)) + if (!matchesRule) { + return + } + + const existing = taskMap.get(path) + if (existing) { + existing.push(...rule.signals) + } else { + taskMap.set(path, [...rule.signals]) + } + }) + }) + + return Array.from(taskMap.entries()).map(([path, signals]) => ({ + path, + signals, + })) +} diff --git a/types/repo-scan.ts b/types/repo-scan.ts index 203e881..46c06e9 100644 --- a/types/repo-scan.ts +++ b/types/repo-scan.ts @@ -41,6 +41,7 @@ export type RepoScanSummary = { codeQuality?: string[] editor?: string[] fileNamingStyle?: string | null + variableNamingStyle?: string | null componentNamingStyle?: string | null codeStylePreference?: string | null commitMessageStyle?: string | null diff --git a/types/stack-detection.ts b/types/stack-detection.ts new file mode 100644 index 0000000..b7cc4d2 --- /dev/null +++ b/types/stack-detection.ts @@ -0,0 +1,50 @@ +export type StackDependencySignal = { + /** + * String to match when evaluating the dependency file. + * For substring matches this will be compared case-insensitively. + */ + match: string + /** + * Determines how the match should be evaluated. + * - substring: perform a case-insensitive substring search. + * - json-dependency: interpret the file as a package manifest and + * look for dependency keys that match the provided name. + */ + type?: "substring" | "json-dependency" + /** + * Framework names to add to the detected frameworks list when the match succeeds. + */ + addFrameworks?: string[] + /** + * Language names to add to the detected languages list when the match succeeds. + */ + addLanguages?: string[] + /** + * Optional stack identifier to prefer when this signal matches. + */ + preferStack?: string + /** + * Optional language to mark as the primary language when this signal matches. + */ + setPrimaryLanguage?: string +} + +export type StackDependencyFileDetection = { + /** + * Glob-like patterns (using * and ** wildcards) that identify dependency + * manifest files relevant for this stack. + */ + patterns?: string[] + /** + * Explicit paths that should be evaluated (useful for root-level files). + */ + paths?: string[] + /** + * Signals that should be evaluated against the file contents. + */ + signals: StackDependencySignal[] +} + +export type StackDetectionConfig = { + dependencyFiles?: StackDependencyFileDetection[] +} diff --git a/types/wizard.ts b/types/wizard.ts index 219f4e1..35ab5ab 100644 --- a/types/wizard.ts +++ b/types/wizard.ts @@ -1,5 +1,7 @@ import type { PropsWithChildren } from "react" +import type { StackDetectionConfig } from "@/types/stack-detection" + export type DataAnswerSource = { value: string label: string @@ -15,6 +17,7 @@ export type DataAnswerSource = { enabled?: boolean filename?: string format?: string + detection?: StackDetectionConfig } export type QuestionFreeTextConfig = {