@peripheries/engine 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peripheries/engine",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Build, check, knowledge and deploy for peripheries component repos",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import{existsSync,readFileSync,readdirSync}from"node:fs";import{dirname,join,relative}from"node:path";import{fileURLToPath}from"node:url";import{loadConfig}from"../lib/config.mjs";import{loadSharedLineages,unresolvedMessage}from"../lib/shared-lineages.mjs";import{REPO_ROOT as REPO_ROOT_}from"../lib/root.mjs";const ENGINE_CONFIG=await loadConfig();const GATED_TEMPLATES=new Set(ENGINE_CONFIG.checks.gated.projectTemplates);const GATED_ORGS=new Set(ENGINE_CONFIG.checks.gated.orgTemplates);const REQUIRED_PREFIX="/portal/project/{projectId}";const ALLOWED_PATH_PARAMS=new Set(["projectId"]);const HOST_PROVIDED_PARAMS=new Set(["projectId","clientId","userId","orgId","organizationId","advisorOrgId","pageId","pageType","processId"]);const repoRoot=REPO_ROOT_;const templatesRoot=join(repoRoot,"src","project_templates");const orgsRoot=join(repoRoot,"src","org_templates");const args=process.argv.slice(2);const gateAll=args.includes("--all-gated");const warnOnly=args.includes("--warn-only");const only=args.find(a=>!a.startsWith("--"));let exportedNames=null;let hasComponentJSDoc=null;let resolveComponentFilePath=null;let exportSkipReason=null;try{const parser=await import("../lib/jsdoc-parser.mjs");if(typeof parser.readExportsFromIndex!=="function"){exportSkipReason="jsdoc-parser.mjs exports no readExportsFromIndex"}else{exportedNames=new Map(ENGINE_CONFIG.lineages.flatMap(l=>existsSync(join(repoRoot,l.entry))?parser.readExportsFromIndex(repoRoot,l.entry).map(e=>[e.name,e.source]):[]));hasComponentJSDoc=typeof parser.hasComponentJSDoc==="function"?parser.hasComponentJSDoc:null;resolveComponentFilePath=typeof parser.resolveComponentFilePath==="function"?parser.resolveComponentFilePath:null}}catch(err){exportSkipReason=`could not load the engine's jsdoc parser \u2014 ${err.message}`}const SHARED=await loadSharedLineages(ENGINE_CONFIG);const sharedNames=SHARED.names;const sharedLabel=SHARED.specifiers.length?` or shared lineage (${SHARED.specifiers.join(", ")})`:"";let sharedRefCount=0;const GENERATED_CONTRACT=join(repoRoot,"src","types","host-contract.generated.ts");function generatedContractKeys(source,interfaceName){const open=source.indexOf(`export interface ${interfaceName} {`);if(open===-1)return null;const keys=new Set;let depth=0;for(let i=source.indexOf("{",open);i<source.length;i++){const ch=source[i];if(ch==="{"){depth+=1;continue}if(ch==="}"){depth-=1;if(depth===0)break;continue}if(depth===1&&ch==='"'){const end=source.indexOf('"',i+1);if(end===-1)break;const key=source.slice(i+1,end);if(source.slice(end+1,end+2)===":")keys.add(key);i=end}}return keys}let contractQueries=null;let contractMutations=null;let contractNote=null;if(existsSync(GENERATED_CONTRACT)){const source=readFileSync(GENERATED_CONTRACT,"utf8");contractQueries=generatedContractKeys(source,"Queries");contractMutations=generatedContractKeys(source,"Mutations");if(!contractQueries||!contractMutations){contractNote="host-contract.generated.ts has no Queries/Mutations interface";contractQueries=null;contractMutations=null}}else{contractNote="src/types/host-contract.generated.ts is absent (the host contract is retired)"}function parsePlaceholder(segment){if(segment.startsWith("[")&&segment.endsWith("]"))return segment.slice(1,-1);if(segment.startsWith("{")&&segment.endsWith("}"))return segment.slice(1,-1);return null}function placeholdersIn(path){return path.split("/").filter(Boolean).map(parsePlaceholder).filter(name=>name!==null)}function getRouteParam(params,name){if(!params)return void 0;if(Array.isArray(params)){const prefix=`${name}=`;const hit=params.find(p=>typeof p==="string"&&p.startsWith(prefix));return hit?hit.slice(prefix.length):void 0}return typeof params[name]==="string"?params[name]:void 0}function declaredPageTypes(templateDir){const pagesDir=join(templateDir,"pages");const found=new Map;if(!existsSync(pagesDir))return found;for(const entry of readdirSync(pagesDir,{withFileTypes:true})){if(!entry.isDirectory())continue;const file=join(pagesDir,entry.name,"page.json");if(!existsSync(file))continue;try{const page=JSON.parse(readFileSync(file,"utf8"));if(page.page_type)found.set(page.page_type,entry.name)}catch{}}return found}const errors=[];const warnings=[];for(const spec of SHARED.unresolved)errors.push(SHARED.error??unresolvedMessage(spec));let routeCount=0;let templateCount=0;let orgCount=0;let componentRefCount=0;function flattenRoutes(routes){const out=[];const visit=r=>{out.push(r);(Array.isArray(r?.children)?r.children:[]).forEach(visit)};(routes??[]).forEach(visit);return out}function routeKinds(route){return["component","project","page","redirect"].filter(k=>route?.[k]!==void 0)}const ROLE=/^[a-z0-9][a-z0-9_-]{0,62}$/;const INSTANCES=new Set(["per_client","per_org","shared","chosen"]);function checkComponentName(where,name,problems){if(typeof name!=="string"||name===""){problems.push(`${where} declares no component name.`);return}componentRefCount+=1;if(name.startsWith("/")||name.startsWith("@"))return;if(!exportedNames)return;if(!exportedNames.has(name)){if(sharedNames.has(name)){sharedRefCount+=1;return}problems.push(`${where} component "${name}" is not exported from any lineage barrel${sharedLabel}, so register-components.mjs writes no component_registry row and the host renders "Component '${name}' not found in authorized registries".`);return}if(!hasComponentJSDoc||!resolveComponentFilePath)return;const filePath=resolveComponentFilePath(repoRoot,exportedNames.get(name));if(!filePath){problems.push(`${where} component "${name}" is exported from src/index.ts but its source ("${exportedNames.get(name)}") does not resolve \u2014 it cannot be registered.`);return}if(!hasComponentJSDoc(filePath,name)){problems.push(`${where} component "${name}" carries no \`@component ${name}\` JSDoc block, so register-components.mjs skips it and the host finds no registry row.`)}}const RETIRED="retired host-contract binding; replace with a recipe descriptor";const notInContract=(set,id,kind)=>set&&!set.has(id)?` ("${id}" is not a ${kind} in src/types/host-contract.generated.ts either)`:"";function checkDeclarations(where,node,problems){const queries=node?.data?.queries??{};for(const[friendly,decl]of Object.entries(queries)){const id=typeof decl==="string"?decl:decl?.registry;if(typeof id==="string"&&id!==""){warnings.push(`${where} data.queries.${friendly} \u2192 "${id}": ${RETIRED}${notInContract(contractQueries,id,"query")}.`)}const params=Array.isArray(decl?.params)?decl.params:[];for(const param of params){if(typeof param!=="string"||!param.startsWith("@params."))continue;const root=param.slice("@params.".length).split(".")[0];if(HOST_PROVIDED_PARAMS.has(root))continue;problems.push(`${where} data.queries.${friendly} reads ${param}, but "${root}" is not a key the host puts into ParameterContext. There is no context_parameters allowlist at org level, so it resolves to nothing and the query runs with a missing argument.`)}}for(const[friendly,decl]of Object.entries(node?.actions??{})){const id=typeof decl==="string"?decl:decl?.registry;if(typeof id==="string"&&id!==""){warnings.push(`${where} actions.${friendly} \u2192 "${id}": ${RETIRED} (actions: M2's action declarations)${notInContract(contractMutations,id,"mutation")}.`)}}}const templates=existsSync(templatesRoot)?readdirSync(templatesRoot,{withFileTypes:true}).filter(e=>e.isDirectory()).map(e=>e.name).filter(slug=>!only||slug===only):[];for(const slug of templates){const templateDir=join(templatesRoot,slug);const templateFile=join(templateDir,"template.json");if(!existsSync(templateFile))continue;templateCount+=1;const rel=relative(repoRoot,templateFile);const gated=gateAll||GATED_TEMPLATES.has(slug);const problems=gated?errors:warnings;let template;try{template=JSON.parse(readFileSync(templateFile,"utf8"))}catch(err){problems.push(`${rel}: invalid JSON \u2014 ${err.message}`);continue}const layout=template.layout??{};if(Array.isArray(layout.routes)){const pageTypes2=declaredPageTypes(templateDir);const processes=Array.isArray(template.processes)?template.processes:[];const roles=new Map;for(const[i,entry]of processes.entries()){if(!entry||typeof entry.role!=="string"||!ROLE.test(entry.role)){problems.push(`${rel}: processes[${i}].role must be a lowercase slug.`);continue}if(roles.has(entry.role))problems.push(`${rel}: processes[${i}] repeats role "${entry.role}".`);if(typeof entry.template!=="string"||!entry.template)problems.push(`${rel}: processes[${i}] ("${entry.role}") names no process template.`);if(entry.count!==void 0&&entry.count!=="one"&&entry.count!=="many")problems.push(`${rel}: processes[${i}].count must be "one" or "many".`);roles.set(entry.role,entry)}if(template.slug!==void 0&&(typeof template.slug!=="string"||!/^[a-z0-9][a-z0-9-]{0,62}$/.test(template.slug))){problems.push(`${rel}: slug "${template.slug}" must be a lowercase slug \u2014 org-template routes open the template by it.`)}const bySlug2=new Map;const seenPaths2=new Map;for(const route of flattenRoutes(layout.routes)){routeCount+=1;const path=route.path??route.route;const id=route.slug||path||route.label||"<unnamed>";if(typeof path!=="string"){problems.push(`${rel}: route "${id}" has no path ("" is the project's root).`);continue}if(path.startsWith("/"))problems.push(`${rel}: route "${id}" path "${path}" must be relative to the project (no leading "/").`);const kinds=routeKinds(route).filter(k=>k!=="project");if(kinds.length!==1)problems.push(`${rel}: route "${id}" declares ${kinds.length?kinds.join(" and "):"no kind"} \u2014 exactly one of component, page or redirect.`);if(route.page&&!route.page.id){const role=route.page.process;if(role!==void 0&&!roles.has(role))problems.push(`${rel}: route "${id}" names process role "${role}", which is not in processes.`);if(!route.page.page_type)problems.push(`${rel}: route "${id}" page names no page_type.`);else if(pageTypes2.size>0&&!pageTypes2.has(route.page.page_type)){warnings.push(`${rel}: route "${id}" page_type "${route.page.page_type}" matches no page.json in ${slug}/pages/.`)}if(role&&roles.get(role)?.count==="many"&&!placeholdersIn(path).includes(role)){problems.push(`${rel}: route "${id}" shows a page of the "many" role "${role}" but its path has no {${role}} \u2014 the URL must name the process.`)}}if(route.component&&route.component!=="ProjectPageByType"){componentRefCount+=1;checkComponentName(`${rel}: route "${id}"`,route.component,problems)}if(route.slug){if(bySlug2.has(route.slug))problems.push(`${rel}: duplicate slug "${route.slug}".`);else bySlug2.set(route.slug,route)}if(seenPaths2.has(path))problems.push(`${rel}: duplicate path "${path}" (also on "${seenPaths2.get(path)}").`);else seenPaths2.set(path,id)}if(layout.home_slug&&!bySlug2.has(layout.home_slug)){problems.push(`${rel}: layout.home_slug "${layout.home_slug}" names no route slug.`)}continue}const routes=layout.whitelisted_routes;if(!Array.isArray(routes)||routes.length===0){problems.push(`${rel}: layout.routes (or the older whitelisted_routes) is missing or empty \u2014 no page is reachable.`);continue}const pageTypes=declaredPageTypes(templateDir);const bySlug=new Map;const seenPaths=new Map;const isAppLayout=layout.layout_type==="app";for(const route of routes){routeCount+=1;const id=route.slug||route.path||route.label||"<unnamed>";const path=route.path||route.route;if(!route.slug){if(isAppLayout){problems.push(`${rel}: route "${id}" has no slug. layout_type is 'app', so the platform routes at /v/{slug} and without it the route is unreachable there even though the portal path resolves.`)}}else if(bySlug.has(route.slug)){problems.push(`${rel}: duplicate slug "${route.slug}".`)}else{bySlug.set(route.slug,route);if(!isAppLayout){warnings.push(`${rel}: route "${id}" declares slug "${route.slug}", but layout_type is ${JSON.stringify(layout.layout_type??null)}, not 'app' \u2014 so routesBySlug() is never consulted and the slug is inert. Harmless, but it implies a /v/{slug} surface that is not active.`)}}if(!path){problems.push(`${rel}: route "${id}" has no path.`);continue}const colon=path.split("/").filter(s=>s.startsWith(":"));if(colon.length>0){problems.push(`${rel}: route "${id}" path "${path}" uses ${colon.join(", ")} \u2014 :param is treated as a literal segment and the route will never match. Use {param}.`)}const stray=placeholdersIn(path).filter(p=>!ALLOWED_PATH_PARAMS.has(p));if(stray.length>0){problems.push(`${rel}: route "${id}" path "${path}" declares placeholder(s) ${stray.map(s=>`{${s}}`).join(", ")} \u2014 detail record ids travel as QUERY PARAMETERS (?${stray[0]}=<graph_node_id>), not path segments. The detail page traverses the same subtree as its list and selects the node matching @params.${stray[0]}.`)}if(path!==REQUIRED_PREFIX&&!path.startsWith(`${REQUIRED_PREFIX}/`)){problems.push(`${rel}: route "${id}" path "${path}" must start with "${REQUIRED_PREFIX}" \u2014 findRouteInLayout matches the raw request path and NavigationRenderer rewrites exactly the "/portal/project" prefix, so this can never match.`)}if(route.component==="ProjectPageByType"){if(Array.isArray(route.params)){problems.push(`${rel}: route "${id}" declares params as an array. Use the object form { "pageType": "..." }.`)}const pageType=getRouteParam(route.params,"pageType");if(!pageType){problems.push(`${rel}: route "${id}" targets ProjectPageByType with no params.pageType \u2014 it renders the "Page Type Required" card.`)}else if(pageTypes.size>0&&!pageTypes.has(pageType)){problems.push(`${rel}: route "${id}" pageType "${pageType}" matches no page.json in ${slug}/pages/ \u2014 declared: ${[...pageTypes.keys()].sort().join(", ")||"(none)"}.`)}}if(seenPaths.has(path)){problems.push(`${rel}: duplicate path "${path}" (also on "${seenPaths.get(path)}").`)}else{seenPaths.set(path,id)}}if(layout.home_slug&&!bySlug.has(layout.home_slug)){problems.push(`${rel}: layout.home_slug "${layout.home_slug}" names no route slug \u2014 known: ${[...bySlug.keys()].join(", ")||"(none)"}. The /v/{home_slug} redirect would 404.`)}if(layout.layout_type==="app"&&!layout.home_slug){problems.push(`${rel}: layout_type is "app" but there is no home_slug \u2014 a bare project URL has nowhere to redirect to.`)}const navItems=layout.sidebar?.navigation;if(Array.isArray(navItems)){for(const item of navItems){const navSlug=typeof item==="string"?item:item?.slug;if(!navSlug){problems.push(`${rel}: sidebar.navigation has an entry with no slug.`);continue}const route=bySlug.get(navSlug);if(!route){problems.push(`${rel}: sidebar.navigation references slug "${navSlug}" which is not in whitelisted_routes \u2014 the nav item would render but 404.`)}else if(route.show_in_nav===false){problems.push(`${rel}: sidebar.navigation references "${navSlug}" but that route sets show_in_nav: false \u2014 contradictory. Drill-in routes stay routable and out of nav.`)}}}const ctxParams=layout.context_parameters;if(ctxParams!==void 0&&!Array.isArray(ctxParams)){problems.push(`${rel}: layout.context_parameters must be an array of strings.`)}else if(Array.isArray(ctxParams)&&ctxParams.some(p=>typeof p!=="string")){problems.push(`${rel}: layout.context_parameters must contain only strings.`)}const switcher=layout.sidebar?.context_switcher;if(switcher?.enabled){if(!switcher.param){problems.push(`${rel}: context_switcher is enabled but declares no \`param\`.`)}else if(!Array.isArray(ctxParams)||!ctxParams.includes(switcher.param)){problems.push(`${rel}: context_switcher writes ?${switcher.param}= but "${switcher.param}" is not in layout.context_parameters \u2014 pickAllowedContextParams would refuse to merge it and every context would resolve to the same page.`)}if(!switcher.source?.kind){problems.push(`${rel}: context_switcher.source.kind is required (a resolver id, never a UUID).`)}}const create=switcher?.create;if(create&&create.enabled!==false){if(!create.template_process_id&&!create.template_name){problems.push(`${rel}: context_switcher.create declares neither template_process_id nor template_name, so there is no process template to clone and the create button always fails.`)}if(create.job?.mode==="code"){problems.push(`${rel}: context_switcher.create.job.mode is 'code' \u2014 code-mode jobs go to the per-repo sprite bridge, not the director machine that runs plan-builder. Setup would never run.`)}const fields=Array.isArray(create.fields)?create.fields:[];const keys=new Set;for(const field of fields){if(!field?.key){problems.push(`${rel}: context_switcher.create has a field with no \`key\`.`);continue}if(keys.has(field.key)){problems.push(`${rel}: context_switcher.create declares field "${field.key}" twice.`)}keys.add(field.key);if(field.target&&!["binding","definition","both"].includes(field.target)){problems.push(`${rel}: context_switcher.create field "${field.key}" has target "${field.target}"; must be binding | definition | both.`)}if(field.type&&!["text","textarea"].includes(field.type)){problems.push(`${rel}: context_switcher.create field "${field.key}" has type "${field.type}"; the modal renders text | textarea only.`)}}if(create.name_from&&!keys.has(create.name_from)){problems.push(`${rel}: context_switcher.create.name_from is "${create.name_from}" but no field declares that key \u2014 the record would be created unnamed and the slug would be empty.`)}if(!create.name_from&&fields.length>0){warnings.push(`${rel}: context_switcher.create declares fields but no name_from, so every record is created with the RPC's default name and no slug binding.`)}const labelFrom=switcher.source?.label_from;if(labelFrom?.startsWith("metadata.bindings.")){const bindingKey=labelFrom.slice("metadata.bindings.".length);const writesIt=keys.has(bindingKey)&&fields.some(f=>f.key===bindingKey&&(f.target??"binding")!=="definition");if(!writesIt&&bindingKey!==(create.slug_key??"product_slug")){warnings.push(`${rel}: source.label_from reads bindings.${bindingKey}, but create writes no such binding \u2014 new records would fall back to the process name in the switcher.`)}}}const routedTypes=new Set(routes.map(r=>getRouteParam(r.params,"pageType")).filter(Boolean));for(const[pageType,dir]of pageTypes){if(!routedTypes.has(pageType)){warnings.push(`${rel}: page "${dir}" (page_type "${pageType}") has no route \u2014 unreachable except by direct pageId URL.`)}}}const orgs=existsSync(orgsRoot)?readdirSync(orgsRoot,{withFileTypes:true}).filter(e=>e.isDirectory()).map(e=>e.name).filter(slug=>!only||slug===only):[];for(const slug of orgs){const templateFile=join(orgsRoot,slug,"template.json");if(!existsSync(templateFile))continue;orgCount+=1;const rel=relative(repoRoot,templateFile);const gated=gateAll||GATED_ORGS.has(slug);const problems=gated?errors:warnings;let template;try{template=JSON.parse(readFileSync(templateFile,"utf8"))}catch(err){problems.push(`${rel}: invalid JSON \u2014 ${err.message}`);continue}const layout=template.portal_layout??{};const routes=flattenRoutes(layout.routes??layout.whitelisted_routes);if(!Array.isArray(routes)||routes.length===0){problems.push(`${rel}: portal_layout.routes (or the older whitelisted_routes) is missing or empty \u2014 no portal page is reachable.`);continue}const seenPaths=new Map;const routeByPath=new Map;const projectPrefixes=[];const redirects=[];for(const route of routes){routeCount+=1;const path=route.path||route.route;const id=path||route.label||"<unnamed>";if(!path){problems.push(`${rel}: route "${id}" has no path.`);continue}const colon=path.split("/").filter(s=>s.startsWith(":"));if(colon.length>0){problems.push(`${rel}: route "${path}" uses ${colon.join(", ")} \u2014 :param is treated as a literal segment and the route will never match. Use [param].`)}for(const placeholder of placeholdersIn(path)){if(placeholder.startsWith("...")){warnings.push(`${rel}: route "${path}" uses catch-all "[${placeholder}]", but matchesParameterizedPath compares segment counts \u2014 it matches exactly ONE segment, so deeper paths never resolve through findRouteInLayout.`)}}if(seenPaths.has(path)){problems.push(`${rel}: duplicate path "${path}" (also on "${seenPaths.get(path)}").`)}else{seenPaths.set(path,id);routeByPath.set(path,route)}const kinds=routeKinds(route);if(kinds.length>1)problems.push(`${rel}: route "${path}" declares ${kinds.join(" and ")} \u2014 exactly one kind.`);if(route.project){if(!route.project.template)problems.push(`${rel}: route "${path}" project names no template slug.`);if(!INSTANCES.has(route.project.instance))problems.push(`${rel}: route "${path}" project.instance must be per_client, per_org, shared or chosen.`);projectPrefixes.push(path)}else if(route.redirect){redirects.push([`route "${path}" redirect`,route.redirect])}else if(route.page){if(!route.page.id)problems.push(`${rel}: route "${path}" page in an org template names a page by id.`)}else{checkComponentName(`${rel}: route "${path}"`,route.component,problems)}checkDeclarations(`${rel}: route "${path}"`,route,problems);if(route.right_sidebar?.component){checkComponentName(`${rel}: route "${path}" right_sidebar`,route.right_sidebar.component,problems)}}const resolvesToRoute=target=>{if(seenPaths.has(target))return true;if(projectPrefixes.some(p=>target.startsWith(`${p}/`)))return true;const targetSegments=target.split("/").filter(Boolean);for(const path of seenPaths.keys()){const routeSegments=path.split("/").filter(Boolean);if(routeSegments.length!==targetSegments.length)continue;const matches=routeSegments.every((segment,i)=>parsePlaceholder(segment)!==null||segment===targetSegments[i]);if(matches)return true}return false};const navPaths=[];if(layout.base_route?.path)navPaths.push([`base_route.path`,layout.base_route.path]);for(const[i,item]of(layout.tab_bar?.items??[]).entries()){if(item?.path)navPaths.push([`tab_bar.items[${i}].path`,item.path]);for(const[j,active]of(item?.active_paths??[]).entries()){navPaths.push([`tab_bar.items[${i}].active_paths[${j}]`,active])}}for(const[i,item]of(layout.more_menu?.items??[]).entries()){if(item?.path)navPaths.push([`more_menu.items[${i}].path`,item.path])}for(const[i,item]of(layout.sidebar?.navigation??[]).entries()){if(item?.path)navPaths.push([`sidebar.navigation[${i}].path`,item.path])}navPaths.push(...redirects);for(const[where,path]of navPaths){if(!resolvesToRoute(path)){problems.push(`${rel}: ${where} is "${path}" but no whitelisted_route matches it \u2014 the nav entry renders and then resolves to nothing.`)}}for(const[i,item]of(layout.sidebar?.navigation??[]).entries()){if(!item?.path||!item?.component)continue;const route=routeByPath.get(item.path);if(route&&route.component&&route.component!==item.component){problems.push(`${rel}: sidebar.navigation[${i}] claims component "${item.component}" for "${item.path}" but the whitelisted route declares "${route.component}" \u2014 nav and route disagree.`)}}const chrome=[["base_route",layout.base_route?.component],["app_bar",layout.app_bar?.component],["tab_bar",layout.tab_bar?.component],["more_menu",layout.more_menu?.component],["chat_rail",layout.chat_rail?.component],["right_sidebar",layout.right_sidebar?.component],["sidebar",layout.sidebar?.component],["assistant_overlay.component",layout.assistant_overlay?.component],["assistant_overlay.pill",layout.assistant_overlay?.pill]];for(const[where,name]of chrome){if(name===void 0||name===null)continue;checkComponentName(`${rel}: portal_layout.${where}`,name,problems)}if(layout.right_sidebar){checkDeclarations(`${rel}: portal_layout.right_sidebar`,layout.right_sidebar,problems)}}if(exportSkipReason){console.warn(`[check-template-routes] component-name checks SKIPPED \u2014 ${exportSkipReason}.
2
+ import{existsSync,readFileSync,readdirSync}from"node:fs";import{dirname,join,relative}from"node:path";import{fileURLToPath}from"node:url";import{loadConfig}from"../lib/config.mjs";import{loadSharedLineages,unresolvedMessage}from"../lib/shared-lineages.mjs";import{REPO_ROOT as REPO_ROOT_}from"../lib/root.mjs";const ENGINE_CONFIG=await loadConfig();const GATED_TEMPLATES=new Set(ENGINE_CONFIG.checks.gated.projectTemplates);const GATED_ORGS=new Set(ENGINE_CONFIG.checks.gated.orgTemplates);const REQUIRED_PREFIX="/portal/project/{projectId}";const ALLOWED_PATH_PARAMS=new Set(["projectId"]);const HOST_PROVIDED_PARAMS=new Set(["projectId","clientId","userId","orgId","organizationId","advisorOrgId","pageId","pageType","processId"]);const repoRoot=REPO_ROOT_;const templatesRoot=join(repoRoot,"src","project_templates");const orgsRoot=join(repoRoot,"src","org_templates");const args=process.argv.slice(2);const gateAll=args.includes("--all-gated");const warnOnly=args.includes("--warn-only");const only=args.find(a=>!a.startsWith("--"));let exportedNames=null;let hasComponentJSDoc=null;let resolveComponentFilePath=null;let exportSkipReason=null;try{const parser=await import("../lib/jsdoc-parser.mjs");if(typeof parser.readExportsFromIndex!=="function"){exportSkipReason="jsdoc-parser.mjs exports no readExportsFromIndex"}else{exportedNames=new Map(ENGINE_CONFIG.lineages.flatMap(l=>existsSync(join(repoRoot,l.entry))?parser.readExportsFromIndex(repoRoot,l.entry).map(e=>[e.name,e.source]):[]));hasComponentJSDoc=typeof parser.hasComponentJSDoc==="function"?parser.hasComponentJSDoc:null;resolveComponentFilePath=typeof parser.resolveComponentFilePath==="function"?parser.resolveComponentFilePath:null}}catch(err){exportSkipReason=`could not load the engine's jsdoc parser \u2014 ${err.message}`}const SHARED=await loadSharedLineages(ENGINE_CONFIG);const sharedNames=SHARED.names;const sharedLabel=SHARED.specifiers.length?` or shared lineage (${SHARED.specifiers.join(", ")})`:"";let sharedRefCount=0;const GENERATED_CONTRACT=join(repoRoot,"src","types","host-contract.generated.ts");function generatedContractKeys(source,interfaceName){const open=source.indexOf(`export interface ${interfaceName} {`);if(open===-1)return null;const keys=new Set;let depth=0;for(let i=source.indexOf("{",open);i<source.length;i++){const ch=source[i];if(ch==="{"){depth+=1;continue}if(ch==="}"){depth-=1;if(depth===0)break;continue}if(depth===1&&ch==='"'){const end=source.indexOf('"',i+1);if(end===-1)break;const key=source.slice(i+1,end);if(source.slice(end+1,end+2)===":")keys.add(key);i=end}}return keys}let contractQueries=null;let contractMutations=null;let contractNote=null;if(existsSync(GENERATED_CONTRACT)){const source=readFileSync(GENERATED_CONTRACT,"utf8");contractQueries=generatedContractKeys(source,"Queries");contractMutations=generatedContractKeys(source,"Mutations");if(!contractQueries||!contractMutations){contractNote="host-contract.generated.ts has no Queries/Mutations interface";contractQueries=null;contractMutations=null}}else{contractNote="src/types/host-contract.generated.ts is absent (the host contract is retired)"}function parsePlaceholder(segment){if(segment.startsWith("[")&&segment.endsWith("]"))return segment.slice(1,-1);if(segment.startsWith("{")&&segment.endsWith("}"))return segment.slice(1,-1);return null}function placeholdersIn(path){return path.split("/").filter(Boolean).map(parsePlaceholder).filter(name=>name!==null)}function getRouteParam(params,name){if(!params)return void 0;if(Array.isArray(params)){const prefix=`${name}=`;const hit=params.find(p=>typeof p==="string"&&p.startsWith(prefix));return hit?hit.slice(prefix.length):void 0}return typeof params[name]==="string"?params[name]:void 0}function declaredPageTypes(templateDir){const pagesDir=join(templateDir,"pages");const found=new Map;if(!existsSync(pagesDir))return found;for(const entry of readdirSync(pagesDir,{withFileTypes:true})){if(!entry.isDirectory())continue;const file=join(pagesDir,entry.name,"page.json");if(!existsSync(file))continue;try{const page=JSON.parse(readFileSync(file,"utf8"));if(page.page_type)found.set(page.page_type,entry.name)}catch{}}return found}const errors=[];const warnings=[];for(const spec of SHARED.unresolved)errors.push(SHARED.error??unresolvedMessage(spec));let routeCount=0;let templateCount=0;let orgCount=0;let componentRefCount=0;function flattenRoutes(routes){const out=[];const visit=r=>{out.push(r);(Array.isArray(r?.children)?r.children:[]).forEach(visit)};(routes??[]).forEach(visit);return out}function routeKinds(route){return["component","project","page","redirect"].filter(k=>route?.[k]!==void 0)}function framedKinds(kinds,framed){return kinds.includes("component")&&kinds.includes(framed)?kinds.filter(k=>k!=="component"):kinds}const ROLE=/^[a-z0-9][a-z0-9_-]{0,62}$/;const INSTANCES=new Set(["per_client","per_org","shared","chosen"]);function checkComponentName(where,name,problems){if(typeof name!=="string"||name===""){problems.push(`${where} declares no component name.`);return}componentRefCount+=1;if(name.startsWith("/")||name.startsWith("@"))return;if(!exportedNames)return;if(!exportedNames.has(name)){if(sharedNames.has(name)){sharedRefCount+=1;return}problems.push(`${where} component "${name}" is not exported from any lineage barrel${sharedLabel}, so register-components.mjs writes no component_registry row and the host renders "Component '${name}' not found in authorized registries".`);return}if(!hasComponentJSDoc||!resolveComponentFilePath)return;const filePath=resolveComponentFilePath(repoRoot,exportedNames.get(name));if(!filePath){problems.push(`${where} component "${name}" is exported from src/index.ts but its source ("${exportedNames.get(name)}") does not resolve \u2014 it cannot be registered.`);return}if(!hasComponentJSDoc(filePath,name)){problems.push(`${where} component "${name}" carries no \`@component ${name}\` JSDoc block, so register-components.mjs skips it and the host finds no registry row.`)}}const RETIRED="retired host-contract binding; replace with a recipe descriptor";const notInContract=(set,id,kind)=>set&&!set.has(id)?` ("${id}" is not a ${kind} in src/types/host-contract.generated.ts either)`:"";function checkDeclarations(where,node,problems){const queries=node?.data?.queries??{};for(const[friendly,decl]of Object.entries(queries)){const id=typeof decl==="string"?decl:decl?.registry;if(typeof id==="string"&&id!==""){warnings.push(`${where} data.queries.${friendly} \u2192 "${id}": ${RETIRED}${notInContract(contractQueries,id,"query")}.`)}const params=Array.isArray(decl?.params)?decl.params:[];for(const param of params){if(typeof param!=="string"||!param.startsWith("@params."))continue;const root=param.slice("@params.".length).split(".")[0];if(HOST_PROVIDED_PARAMS.has(root))continue;problems.push(`${where} data.queries.${friendly} reads ${param}, but "${root}" is not a key the host puts into ParameterContext. There is no context_parameters allowlist at org level, so it resolves to nothing and the query runs with a missing argument.`)}}for(const[friendly,decl]of Object.entries(node?.actions??{})){const id=typeof decl==="string"?decl:decl?.registry;if(typeof id==="string"&&id!==""){warnings.push(`${where} actions.${friendly} \u2192 "${id}": ${RETIRED} (actions: M2's action declarations)${notInContract(contractMutations,id,"mutation")}.`)}}}const templates=existsSync(templatesRoot)?readdirSync(templatesRoot,{withFileTypes:true}).filter(e=>e.isDirectory()).map(e=>e.name).filter(slug=>!only||slug===only):[];for(const slug of templates){const templateDir=join(templatesRoot,slug);const templateFile=join(templateDir,"template.json");if(!existsSync(templateFile))continue;templateCount+=1;const rel=relative(repoRoot,templateFile);const gated=gateAll||GATED_TEMPLATES.has(slug);const problems=gated?errors:warnings;let template;try{template=JSON.parse(readFileSync(templateFile,"utf8"))}catch(err){problems.push(`${rel}: invalid JSON \u2014 ${err.message}`);continue}const layout=template.layout??{};if(Array.isArray(layout.routes)){const pageTypes2=declaredPageTypes(templateDir);const processes=Array.isArray(template.processes)?template.processes:[];const roles=new Map;for(const[i,entry]of processes.entries()){if(!entry||typeof entry.role!=="string"||!ROLE.test(entry.role)){problems.push(`${rel}: processes[${i}].role must be a lowercase slug.`);continue}if(roles.has(entry.role))problems.push(`${rel}: processes[${i}] repeats role "${entry.role}".`);if(typeof entry.template!=="string"||!entry.template)problems.push(`${rel}: processes[${i}] ("${entry.role}") names no process template.`);if(entry.count!==void 0&&entry.count!=="one"&&entry.count!=="many")problems.push(`${rel}: processes[${i}].count must be "one" or "many".`);roles.set(entry.role,entry)}if(template.slug!==void 0&&(typeof template.slug!=="string"||!/^[a-z0-9][a-z0-9-]{0,62}$/.test(template.slug))){problems.push(`${rel}: slug "${template.slug}" must be a lowercase slug \u2014 org-template routes open the template by it.`)}const bySlug2=new Map;const seenPaths2=new Map;for(const route of flattenRoutes(layout.routes)){routeCount+=1;const path=route.path??route.route;const id=route.slug||path||route.label||"<unnamed>";if(typeof path!=="string"){problems.push(`${rel}: route "${id}" has no path ("" is the project's root).`);continue}if(path.startsWith("/"))problems.push(`${rel}: route "${id}" path "${path}" must be relative to the project (no leading "/").`);const kinds=framedKinds(routeKinds(route).filter(k=>k!=="project"),"page");if(kinds.length!==1)problems.push(`${rel}: route "${id}" declares ${kinds.length?kinds.join(" and "):"no kind"} \u2014 exactly one of component, page or redirect (a component may frame a page).`);if(route.page&&!route.page.id){const role=route.page.process;if(role!==void 0&&!roles.has(role))problems.push(`${rel}: route "${id}" names process role "${role}", which is not in processes.`);if(!route.page.page_type)problems.push(`${rel}: route "${id}" page names no page_type.`);else if(pageTypes2.size>0&&!pageTypes2.has(route.page.page_type)){warnings.push(`${rel}: route "${id}" page_type "${route.page.page_type}" matches no page.json in ${slug}/pages/.`)}if(role&&roles.get(role)?.count==="many"&&!placeholdersIn(path).includes(role)){problems.push(`${rel}: route "${id}" shows a page of the "many" role "${role}" but its path has no {${role}} \u2014 the URL must name the process.`)}}if(route.component&&route.component!=="ProjectPageByType"){componentRefCount+=1;checkComponentName(`${rel}: route "${id}"`,route.component,problems)}if(route.slug){if(bySlug2.has(route.slug))problems.push(`${rel}: duplicate slug "${route.slug}".`);else bySlug2.set(route.slug,route)}if(seenPaths2.has(path))problems.push(`${rel}: duplicate path "${path}" (also on "${seenPaths2.get(path)}").`);else seenPaths2.set(path,id)}if(layout.home_slug&&!bySlug2.has(layout.home_slug)){problems.push(`${rel}: layout.home_slug "${layout.home_slug}" names no route slug.`)}continue}const routes=layout.whitelisted_routes;if(!Array.isArray(routes)||routes.length===0){problems.push(`${rel}: layout.routes (or the older whitelisted_routes) is missing or empty \u2014 no page is reachable.`);continue}const pageTypes=declaredPageTypes(templateDir);const bySlug=new Map;const seenPaths=new Map;const isAppLayout=layout.layout_type==="app";for(const route of routes){routeCount+=1;const id=route.slug||route.path||route.label||"<unnamed>";const path=route.path||route.route;if(!route.slug){if(isAppLayout){problems.push(`${rel}: route "${id}" has no slug. layout_type is 'app', so the platform routes at /v/{slug} and without it the route is unreachable there even though the portal path resolves.`)}}else if(bySlug.has(route.slug)){problems.push(`${rel}: duplicate slug "${route.slug}".`)}else{bySlug.set(route.slug,route);if(!isAppLayout){warnings.push(`${rel}: route "${id}" declares slug "${route.slug}", but layout_type is ${JSON.stringify(layout.layout_type??null)}, not 'app' \u2014 so routesBySlug() is never consulted and the slug is inert. Harmless, but it implies a /v/{slug} surface that is not active.`)}}if(!path){problems.push(`${rel}: route "${id}" has no path.`);continue}const colon=path.split("/").filter(s=>s.startsWith(":"));if(colon.length>0){problems.push(`${rel}: route "${id}" path "${path}" uses ${colon.join(", ")} \u2014 :param is treated as a literal segment and the route will never match. Use {param}.`)}const stray=placeholdersIn(path).filter(p=>!ALLOWED_PATH_PARAMS.has(p));if(stray.length>0){problems.push(`${rel}: route "${id}" path "${path}" declares placeholder(s) ${stray.map(s=>`{${s}}`).join(", ")} \u2014 detail record ids travel as QUERY PARAMETERS (?${stray[0]}=<graph_node_id>), not path segments. The detail page traverses the same subtree as its list and selects the node matching @params.${stray[0]}.`)}if(path!==REQUIRED_PREFIX&&!path.startsWith(`${REQUIRED_PREFIX}/`)){problems.push(`${rel}: route "${id}" path "${path}" must start with "${REQUIRED_PREFIX}" \u2014 findRouteInLayout matches the raw request path and NavigationRenderer rewrites exactly the "/portal/project" prefix, so this can never match.`)}if(route.component==="ProjectPageByType"){if(Array.isArray(route.params)){problems.push(`${rel}: route "${id}" declares params as an array. Use the object form { "pageType": "..." }.`)}const pageType=getRouteParam(route.params,"pageType");if(!pageType){problems.push(`${rel}: route "${id}" targets ProjectPageByType with no params.pageType \u2014 it renders the "Page Type Required" card.`)}else if(pageTypes.size>0&&!pageTypes.has(pageType)){problems.push(`${rel}: route "${id}" pageType "${pageType}" matches no page.json in ${slug}/pages/ \u2014 declared: ${[...pageTypes.keys()].sort().join(", ")||"(none)"}.`)}}if(seenPaths.has(path)){problems.push(`${rel}: duplicate path "${path}" (also on "${seenPaths.get(path)}").`)}else{seenPaths.set(path,id)}}if(layout.home_slug&&!bySlug.has(layout.home_slug)){problems.push(`${rel}: layout.home_slug "${layout.home_slug}" names no route slug \u2014 known: ${[...bySlug.keys()].join(", ")||"(none)"}. The /v/{home_slug} redirect would 404.`)}if(layout.layout_type==="app"&&!layout.home_slug){problems.push(`${rel}: layout_type is "app" but there is no home_slug \u2014 a bare project URL has nowhere to redirect to.`)}const navItems=layout.sidebar?.navigation;if(Array.isArray(navItems)){for(const item of navItems){const navSlug=typeof item==="string"?item:item?.slug;if(!navSlug){problems.push(`${rel}: sidebar.navigation has an entry with no slug.`);continue}const route=bySlug.get(navSlug);if(!route){problems.push(`${rel}: sidebar.navigation references slug "${navSlug}" which is not in whitelisted_routes \u2014 the nav item would render but 404.`)}else if(route.show_in_nav===false){problems.push(`${rel}: sidebar.navigation references "${navSlug}" but that route sets show_in_nav: false \u2014 contradictory. Drill-in routes stay routable and out of nav.`)}}}const ctxParams=layout.context_parameters;if(ctxParams!==void 0&&!Array.isArray(ctxParams)){problems.push(`${rel}: layout.context_parameters must be an array of strings.`)}else if(Array.isArray(ctxParams)&&ctxParams.some(p=>typeof p!=="string")){problems.push(`${rel}: layout.context_parameters must contain only strings.`)}const switcher=layout.sidebar?.context_switcher;if(switcher?.enabled){if(!switcher.param){problems.push(`${rel}: context_switcher is enabled but declares no \`param\`.`)}else if(!Array.isArray(ctxParams)||!ctxParams.includes(switcher.param)){problems.push(`${rel}: context_switcher writes ?${switcher.param}= but "${switcher.param}" is not in layout.context_parameters \u2014 pickAllowedContextParams would refuse to merge it and every context would resolve to the same page.`)}if(!switcher.source?.kind){problems.push(`${rel}: context_switcher.source.kind is required (a resolver id, never a UUID).`)}}const create=switcher?.create;if(create&&create.enabled!==false){if(!create.template_process_id&&!create.template_name){problems.push(`${rel}: context_switcher.create declares neither template_process_id nor template_name, so there is no process template to clone and the create button always fails.`)}if(create.job?.mode==="code"){problems.push(`${rel}: context_switcher.create.job.mode is 'code' \u2014 code-mode jobs go to the per-repo sprite bridge, not the director machine that runs plan-builder. Setup would never run.`)}const fields=Array.isArray(create.fields)?create.fields:[];const keys=new Set;for(const field of fields){if(!field?.key){problems.push(`${rel}: context_switcher.create has a field with no \`key\`.`);continue}if(keys.has(field.key)){problems.push(`${rel}: context_switcher.create declares field "${field.key}" twice.`)}keys.add(field.key);if(field.target&&!["binding","definition","both"].includes(field.target)){problems.push(`${rel}: context_switcher.create field "${field.key}" has target "${field.target}"; must be binding | definition | both.`)}if(field.type&&!["text","textarea"].includes(field.type)){problems.push(`${rel}: context_switcher.create field "${field.key}" has type "${field.type}"; the modal renders text | textarea only.`)}}if(create.name_from&&!keys.has(create.name_from)){problems.push(`${rel}: context_switcher.create.name_from is "${create.name_from}" but no field declares that key \u2014 the record would be created unnamed and the slug would be empty.`)}if(!create.name_from&&fields.length>0){warnings.push(`${rel}: context_switcher.create declares fields but no name_from, so every record is created with the RPC's default name and no slug binding.`)}const labelFrom=switcher.source?.label_from;if(labelFrom?.startsWith("metadata.bindings.")){const bindingKey=labelFrom.slice("metadata.bindings.".length);const writesIt=keys.has(bindingKey)&&fields.some(f=>f.key===bindingKey&&(f.target??"binding")!=="definition");if(!writesIt&&bindingKey!==(create.slug_key??"product_slug")){warnings.push(`${rel}: source.label_from reads bindings.${bindingKey}, but create writes no such binding \u2014 new records would fall back to the process name in the switcher.`)}}}const routedTypes=new Set(routes.map(r=>getRouteParam(r.params,"pageType")).filter(Boolean));for(const[pageType,dir]of pageTypes){if(!routedTypes.has(pageType)){warnings.push(`${rel}: page "${dir}" (page_type "${pageType}") has no route \u2014 unreachable except by direct pageId URL.`)}}}const orgs=existsSync(orgsRoot)?readdirSync(orgsRoot,{withFileTypes:true}).filter(e=>e.isDirectory()).map(e=>e.name).filter(slug=>!only||slug===only):[];for(const slug of orgs){const templateFile=join(orgsRoot,slug,"template.json");if(!existsSync(templateFile))continue;orgCount+=1;const rel=relative(repoRoot,templateFile);const gated=gateAll||GATED_ORGS.has(slug);const problems=gated?errors:warnings;let template;try{template=JSON.parse(readFileSync(templateFile,"utf8"))}catch(err){problems.push(`${rel}: invalid JSON \u2014 ${err.message}`);continue}const layout=template.portal_layout??{};const routes=flattenRoutes(layout.routes??layout.whitelisted_routes);if(!Array.isArray(routes)||routes.length===0){problems.push(`${rel}: portal_layout.routes (or the older whitelisted_routes) is missing or empty \u2014 no portal page is reachable.`);continue}const seenPaths=new Map;const routeByPath=new Map;const projectPrefixes=[];const redirects=[];for(const route of routes){routeCount+=1;const path=route.path||route.route;const id=path||route.label||"<unnamed>";if(!path){problems.push(`${rel}: route "${id}" has no path.`);continue}const colon=path.split("/").filter(s=>s.startsWith(":"));if(colon.length>0){problems.push(`${rel}: route "${path}" uses ${colon.join(", ")} \u2014 :param is treated as a literal segment and the route will never match. Use [param].`)}for(const placeholder of placeholdersIn(path)){if(placeholder.startsWith("...")){warnings.push(`${rel}: route "${path}" uses catch-all "[${placeholder}]", but matchesParameterizedPath compares segment counts \u2014 it matches exactly ONE segment, so deeper paths never resolve through findRouteInLayout.`)}}if(seenPaths.has(path)){problems.push(`${rel}: duplicate path "${path}" (also on "${seenPaths.get(path)}").`)}else{seenPaths.set(path,id);routeByPath.set(path,route)}const kinds=framedKinds(routeKinds(route),"project");if(kinds.length>1)problems.push(`${rel}: route "${path}" declares ${routeKinds(route).join(" and ")} \u2014 exactly one kind (a component may frame a project).`);if(route.project){if(route.component!==void 0)checkComponentName(`${rel}: route "${path}"`,route.component,problems);if(!route.project.template)problems.push(`${rel}: route "${path}" project names no template slug.`);if(!INSTANCES.has(route.project.instance))problems.push(`${rel}: route "${path}" project.instance must be per_client, per_org, shared or chosen.`);projectPrefixes.push(path)}else if(route.redirect){redirects.push([`route "${path}" redirect`,route.redirect])}else if(route.page){if(!route.page.id)problems.push(`${rel}: route "${path}" page in an org template names a page by id.`)}else{checkComponentName(`${rel}: route "${path}"`,route.component,problems)}checkDeclarations(`${rel}: route "${path}"`,route,problems);if(route.right_sidebar?.component){checkComponentName(`${rel}: route "${path}" right_sidebar`,route.right_sidebar.component,problems)}}const resolvesToRoute=target=>{if(seenPaths.has(target))return true;if(projectPrefixes.some(p=>target.startsWith(`${p}/`)))return true;const targetSegments=target.split("/").filter(Boolean);for(const path of seenPaths.keys()){const routeSegments=path.split("/").filter(Boolean);if(routeSegments.length!==targetSegments.length)continue;const matches=routeSegments.every((segment,i)=>parsePlaceholder(segment)!==null||segment===targetSegments[i]);if(matches)return true}return false};const navPaths=[];if(layout.base_route?.path)navPaths.push([`base_route.path`,layout.base_route.path]);for(const[i,item]of(layout.tab_bar?.items??[]).entries()){if(item?.path)navPaths.push([`tab_bar.items[${i}].path`,item.path]);for(const[j,active]of(item?.active_paths??[]).entries()){navPaths.push([`tab_bar.items[${i}].active_paths[${j}]`,active])}}for(const[i,item]of(layout.more_menu?.items??[]).entries()){if(item?.path)navPaths.push([`more_menu.items[${i}].path`,item.path])}for(const[i,item]of(layout.sidebar?.navigation??[]).entries()){if(item?.path)navPaths.push([`sidebar.navigation[${i}].path`,item.path])}navPaths.push(...redirects);for(const[where,path]of navPaths){if(!resolvesToRoute(path)){problems.push(`${rel}: ${where} is "${path}" but no whitelisted_route matches it \u2014 the nav entry renders and then resolves to nothing.`)}}for(const[i,item]of(layout.sidebar?.navigation??[]).entries()){if(!item?.path||!item?.component)continue;const route=routeByPath.get(item.path);if(route&&route.component&&route.component!==item.component){problems.push(`${rel}: sidebar.navigation[${i}] claims component "${item.component}" for "${item.path}" but the whitelisted route declares "${route.component}" \u2014 nav and route disagree.`)}}const chrome=[["base_route",layout.base_route?.component],["app_bar",layout.app_bar?.component],["tab_bar",layout.tab_bar?.component],["more_menu",layout.more_menu?.component],["chat_rail",layout.chat_rail?.component],["right_sidebar",layout.right_sidebar?.component],["sidebar",layout.sidebar?.component],["assistant_overlay.component",layout.assistant_overlay?.component],["assistant_overlay.pill",layout.assistant_overlay?.pill]];for(const[where,name]of chrome){if(name===void 0||name===null)continue;checkComponentName(`${rel}: portal_layout.${where}`,name,problems)}if(layout.right_sidebar){checkDeclarations(`${rel}: portal_layout.right_sidebar`,layout.right_sidebar,problems)}}if(exportSkipReason){console.warn(`[check-template-routes] component-name checks SKIPPED \u2014 ${exportSkipReason}.
3
3
  `)}if(contractNote){console.warn(`[check-template-routes] note: ${contractNote}; registry bindings are warnings only.
4
4
  `)}const notGated=[...templates.filter(s=>!gateAll&&!GATED_TEMPLATES.has(s)),...orgs.filter(s=>!gateAll&&!GATED_ORGS.has(s))];if(warnings.length>0){console.warn(`[check-template-routes] ${warnings.length} warning(s):
5
5
  `);for(const w of warnings)console.warn(` \xB7 ${w}`);if(notGated.length>0){console.warn(`