Enhance repo scanning and tag handling by integrating meshforge.yaml parsing. Added support for environment capabilities and meshforge configuration in scan and tag operations. Updated related functions to aggregate and filter environment names and tags based on the meshforge profile.

This commit is contained in:
Ben Allfree
2026-04-11 21:27:12 -07:00
parent ad81010e29
commit 0d1f983324
10 changed files with 491 additions and 76 deletions
+185
View File
@@ -0,0 +1,185 @@
import type { MeshforgeConfig } from '@/convex/lib/meshforgeYaml'
export type { MeshforgeConfig }
/** Escape a string for literal use inside a RegExp pattern. */
export function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/**
* Convert a raw string to snake_case.
* CamelCase boundaries and non-alphanumeric runs become underscores.
* e.g. "clientRole" → "client_role", "client-role" → "client_role"
*/
export function toSnakeCase(s: string): string {
return s
.replace(/([A-Z])/g, '_$1')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
}
/** Convert to lowerCamelCase. e.g. "client_role" → "clientRole" */
export function toCamelCase(s: string): string {
const parts = toSnakeCase(s).split('_').filter(Boolean)
if (!parts.length) return ''
return parts[0] + parts.slice(1).map(p => p.charAt(0).toUpperCase() + p.slice(1)).join('')
}
/** Convert to PascalCase. e.g. "client_role" → "ClientRole" */
export function toPascalCase(s: string): string {
return toSnakeCase(s)
.split('_')
.filter(Boolean)
.map(p => p.charAt(0).toUpperCase() + p.slice(1))
.join('')
}
/**
* Build the effective target RegExp given the currently selected tag and profile config.
*
* Precedence:
* 1. targets.include_template — expanded using captures from the tag matched against
* tags.include (when the tag matches and the template is set)
* 2. targets.include — used as a static regex
* 3. null — no target filter
*
* Placeholder syntax in include_template:
* ${captureName} raw value of the named capture group
* ${captureName_snake} snake_case transform
* ${captureName_camel} lowerCamelCase transform
* ${captureName_pascal} PascalCase transform
* ${1}, ${2}, … numbered capture groups
* Each substituted segment is regex-escaped before insertion.
*/
export function buildTargetRegex(tag: string, config: MeshforgeConfig): RegExp | null {
const tagsInclude = config.tags?.include
const template = config.targets?.include_template
if (template && tagsInclude) {
let tagMatch: RegExpExecArray | null = null
try {
tagMatch = new RegExp(tagsInclude).exec(tag)
} catch {
// malformed regex — fall through
}
if (tagMatch) {
const pattern = template.replace(/\$\{([^}]+)\}/g, (_, placeholder: string) => {
// Numbered capture: ${1}, ${2}, …
const num = Number(placeholder)
if (Number.isInteger(num) && num >= 1) {
return escapeRegex(tagMatch![num] ?? '')
}
// Detect trailing case suffix
let baseName = placeholder
let suffix: 'snake' | 'camel' | 'pascal' | '' = ''
if (placeholder.endsWith('_snake')) {
baseName = placeholder.slice(0, -6)
suffix = 'snake'
} else if (placeholder.endsWith('_camel')) {
baseName = placeholder.slice(0, -6)
suffix = 'camel'
} else if (placeholder.endsWith('_pascal')) {
baseName = placeholder.slice(0, -7)
suffix = 'pascal'
}
const rawCapture = tagMatch!.groups?.[baseName] ?? ''
let value: string
if (suffix === 'snake') value = toSnakeCase(rawCapture)
else if (suffix === 'camel') value = toCamelCase(rawCapture)
else if (suffix === 'pascal') value = toPascalCase(rawCapture)
else value = rawCapture
return escapeRegex(value)
})
try {
return new RegExp(pattern)
} catch {
// malformed expanded pattern — fall through
}
}
}
if (config.targets?.include) {
try {
return new RegExp(config.targets.include)
} catch {
return null
}
}
return null
}
/** Filter tag list using config.tags.include. Returns full list when no filter is set. */
export function filterTagNames(tags: string[], config: MeshforgeConfig): string[] {
const inc = config.tags?.include
if (!inc) return tags
let rx: RegExp
try {
rx = new RegExp(inc)
} catch {
return tags
}
return tags.filter(t => rx.test(t))
}
/**
* Filter env names using the effective target regex and capability requirements.
* Returns full list when no filter is configured.
*
* @param envNames All env names from the scan
* @param config Parsed meshforge.yaml (or null → no filtering)
* @param capabilities envCapabilities map from the scan
* @param currentTag Currently selected tag (used for include_template interpolation)
*/
export function filterEnvNames(
envNames: string[],
config: MeshforgeConfig | null,
capabilities: Record<string, string[]>,
currentTag: string
): string[] {
if (!config) return envNames
const rx = buildTargetRegex(currentTag, config)
const reqCaps = config.targets?.require_capabilities ?? []
const isFiltering = rx !== null || reqCaps.length > 0
if (isFiltering) {
console.debug(
'[meshforge] filterEnvNames — tag=%o regex=%o require_capabilities=%o',
currentTag,
rx?.source ?? '(none)',
reqCaps
)
}
if (
reqCaps.length > 0 &&
envNames.length > 0 &&
envNames.every(n => (capabilities[n]?.length ?? 0) === 0)
) {
console.warn(
'[meshforge] envCapabilities is empty for every env — this repoRefScan was likely completed before capability scanning shipped. Open the repo again (ensureScan will rescan) or wait for in_progress to finish.'
)
}
return envNames.filter(name => {
if (rx && !rx.test(name)) {
console.debug('[meshforge] ✗ %o — rejected by regex (%o)', name, rx.source)
return false
}
if (reqCaps.length > 0) {
const envCaps = capabilities[name] ?? []
const missing = reqCaps.filter(c => !envCaps.includes(c))
if (missing.length > 0) {
console.debug('[meshforge] ✗ %o — missing capabilities %o (has %o)', name, missing, envCaps)
return false
}
}
if (isFiltering) {
console.debug('[meshforge] ✓ %o', name)
}
return true
})
}
-43
View File
@@ -8,21 +8,6 @@ function encodeTreePath(ref: string) {
return ref.split("/").map(encodeURIComponent).join("/")
}
const DEMO_REPOS: { label: string; owner: string; repo: string; githubUrl: string }[] = [
{
label: "meshtastic/firmware",
owner: "meshtastic",
repo: "firmware",
githubUrl: "https://github.com/meshtastic/firmware",
},
{
label: "meshcore-dev/MeshCore",
owner: "meshcore-dev",
repo: "MeshCore",
githubUrl: "https://github.com/meshcore-dev/MeshCore",
},
]
export default function HomePage() {
const navigate = useNavigate()
const [input, setInput] = useState("")
@@ -77,34 +62,6 @@ export default function HomePage() {
<Button className="w-full bg-cyan-600 hover:bg-cyan-700" type="button" onClick={go}>
Open a firmware repo
</Button>
<div className="pt-2 space-y-2">
<p className="text-xs text-slate-500 text-center">Try a demo</p>
<ul className="space-y-2">
{DEMO_REPOS.map(d => (
<li
key={d.githubUrl}
className="flex flex-col sm:flex-row gap-1 sm:gap-3 sm:items-center sm:justify-between rounded-lg border border-slate-800/80 bg-slate-900/40 px-3 py-2"
>
<Button
type="button"
variant="ghost"
className="text-slate-300 hover:text-cyan-400 text-sm justify-start h-auto py-1 px-0 font-normal"
onClick={() => navigate(`/${encodeURIComponent(d.owner)}/${encodeURIComponent(d.repo)}`)}
>
Open {d.label}
</Button>
<a
className="text-xs text-slate-600 hover:text-slate-400 sm:text-right shrink-0"
href={d.githubUrl}
target="_blank"
rel="noreferrer"
>
{d.githubUrl.replace(/^https:\/\//, "")}
</a>
</li>
))}
</ul>
</div>
</div>
</div>
</div>
+38 -13
View File
@@ -23,6 +23,7 @@ import DeviceFlasher from "../components/DeviceFlasher"
import { normalizeBuildKey } from "../lib/buildKey"
import { buildFailurePresentation } from "../lib/formatBuildErrorSummary"
import { homepageHref, homepageLabel } from "../lib/githubHomepage"
import { filterEnvNames, filterTagNames, type MeshforgeConfig } from "../lib/meshforgeApplyProfile"
import { resolveReadmeRelativeUrl } from "../lib/readmeAssetUrl"
import { buildTreeSplatPath, parseTreeSplat } from "../lib/repoTreeUrl"
@@ -74,8 +75,11 @@ export default function RepoPage() {
if (tagData === undefined || !tagData.row) return
const tags = tagData.row.tags
if (tags.length === 0) return
const sorted = sortTagNames(tags.map(t => t.name))
const latest = sorted[0]
const allSorted = sortTagNames(tags.map(t => t.name))
const cfg = tagData.row.meshforgeConfig as MeshforgeConfig | null | undefined
const candidates = cfg ? filterTagNames(allSorted, cfg) : allSorted
// Fall back to unfiltered list when the profile leaves nothing (e.g. no matching tags yet)
const latest = (candidates.length > 0 ? candidates : allSorted)[0]
if (!latest) return
navigate(`/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(latest, null)}`, { replace: true })
}, [owner, repo, sourceRef, tagData, navigate, ownerParam, repoParam])
@@ -170,8 +174,6 @@ export default function RepoPage() {
)
const envNames = scan?.scanStatus === "complete" ? (scan.envNames ?? []) : []
const resolvedTargetEnv =
hasRef && targetFromUrl && envNames.length > 0 && envNames.includes(targetFromUrl) ? targetFromUrl : ""
const [tagDraft, setTagDraft] = useState("")
useEffect(() => {
@@ -199,6 +201,27 @@ export default function RepoPage() {
return sortTagNames(merged)
}, [tagData?.row?.tags, sourceRef])
// meshforgeConfig comes from the default branch (stored on the tag list, available before
// a tag is selected so the tag dropdown itself can be filtered).
const meshforgeConfig = (tagData?.row?.meshforgeConfig ?? null) as MeshforgeConfig | null
const envCapabilities = (scan?.scanStatus === "complete" ? scan.envCapabilities : null) as Record<
string,
string[]
> | null
const filteredTagOptions = useMemo(
() => filterTagNames(tagOptions, meshforgeConfig ?? {}),
[tagOptions, meshforgeConfig]
)
const filteredEnvNames = useMemo(
() => filterEnvNames(envNames, meshforgeConfig, envCapabilities ?? {}, tagDraft),
[envNames, meshforgeConfig, envCapabilities, tagDraft]
)
const resolvedTargetEnv =
hasRef && targetFromUrl && filteredEnvNames.length > 0 && filteredEnvNames.includes(targetFromUrl)
? targetFromUrl
: ""
const [envDraft, setEnvDraft] = useState("")
useEffect(() => {
if (!hasRef) {
@@ -389,7 +412,7 @@ export default function RepoPage() {
!resolvedSha ||
Boolean(refError) ||
!resolvedTargetEnv ||
!envNames.includes(resolvedTargetEnv) ||
!filteredEnvNames.includes(resolvedTargetEnv) ||
!scanReady
const showCiCard = build && (build.status !== "failed" || witnessedCiFailure)
@@ -402,8 +425,10 @@ export default function RepoPage() {
? "Scanning…"
: scan.scanStatus === "failed"
? "Scan failed"
: envNames.length === 0
? "No targets"
: filteredEnvNames.length === 0
? envNames.length === 0
? "No targets"
: "No targets match profile"
: "--target--"
const backToRepoPath = `/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(sourceRef, resolvedTargetEnv || null)}`
@@ -619,7 +644,7 @@ export default function RepoPage() {
label="Tag"
layout="inline"
id="mesh-forge-tag"
options={tagOptions}
options={filteredTagOptions}
value={tagDraft}
placeholder="--tag--"
clearSelectionLabel="Clear tag"
@@ -629,18 +654,18 @@ export default function RepoPage() {
navigate(`/${ownerParam}/${repoParam}`)
return
}
if (tagOptions.includes(v)) {
if (filteredTagOptions.includes(v)) {
navigate(`/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(v, targetFromUrl)}`)
}
}}
disabled={tagOptions.length === 0}
disabled={filteredTagOptions.length === 0}
/>
{hasRef && scanReady && envNames.length > 0 ? (
{hasRef && scanReady && filteredEnvNames.length > 0 ? (
<ComboboxField
label="Target"
layout="inline"
id="mesh-forge-target"
options={envNames}
options={filteredEnvNames}
value={envDraft}
filterNormalize
placeholder="--target--"
@@ -654,7 +679,7 @@ export default function RepoPage() {
})
return
}
if (envNames.includes(v)) {
if (filteredEnvNames.includes(v)) {
navigate(`/${ownerParam}/${repoParam}/tree/${buildTreeSplatPath(sourceRef, v)}`)
}
}}