@peripheries/engine 0.11.0 → 0.12.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,4 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import{existsSync,readFileSync,readdirSync,statSync}from"node:fs";import{dirname,join,relative,resolve}from"node:path";import{fileURLToPath}from"node:url";import{FOREIGN,HISTORICAL,PLACEHOLDER,markerWindow}from"../lib/citation-markers.mjs";import{declBody,topLevelFields,topLevelKeys}from"../lib/contract-shape.mjs";import{assertNonVacuous}from"../lib/floor.mjs";import{REPO_ROOT as REPO_ROOT_}from"../lib/root.mjs";const REPO_ROOT=REPO_ROOT_;const DOC_ROOTS=[".claude/skills",".claude/commands"];const DOC_FILES=[".claude/CLAUDE.md"];const ROOTS=String.raw`src|scripts|template|docs|test|design|outlines|\.claude|\.github`;const PATH_RE=new RegExp(String.raw`(?<![A-Za-z0-9._@/-])((?:${ROOTS})/[A-Za-z0-9._/*{},-]*[A-Za-z0-9_*}])`,"g");const PARENT_RELATIVE_RE=/(?<![A-Za-z0-9._-])((?:\.\.\/)+[A-Za-z0-9._/-]*[A-Za-z0-9_])/g;const IMPORTISH=/\b(import|require|from)\b/;const NPM_RE=/npm run (?!--)([A-Za-z0-9:_-]+)/g;const OTHER_PM_RE=/(?<![A-Za-z0-9-])(pnpm|yarn)\s+(?!--)([A-Za-z0-9:@/._-]+)/g;const EXT_BOUNDARY=/\.(json|ts|tsx|mjs|mts|js|jsx|md|html|css|ya?ml)\./;function filePart(ref){const m=EXT_BOUNDARY.exec(ref);return m?ref.slice(0,m.index+m[0].length-1):ref}const TRY_EXT=["",".ts",".tsx",".mjs",".mts",".js",".json",".md"];const TRY_INDEX=["index.ts","index.tsx","index.mjs","SKILL.md"];function resolves(p,base=REPO_ROOT){const abs=resolve(base,p);for(const ext of TRY_EXT)if(existsSync(abs+ext))return true;for(const idx of TRY_INDEX)if(existsSync(join(abs,idx)))return true;return false}function walk(dir,out=[]){if(!existsSync(dir))return out;for(const entry of readdirSync(dir)){const full=join(dir,entry);if(statSync(full).isDirectory())walk(full,out);else if(entry.endsWith(".md"))out.push(full)}return out}function allowedMissing(text){const fm=/^---\n([\s\S]*?)\n---/.exec(text);if(!fm)return new Set;const inline=/^allowed_missing:\s*\[(.*)\]\s*$/m.exec(fm[1]);if(inline)return new Set(inline[1].split(",").map(s=>s.trim().replace(/^['"]|['"]$/g,"")));const block=/^allowed_missing:\s*\n((?:\s+-\s+.*\n?)+)/m.exec(fm[1]);if(!block)return new Set;return new Set([...block[1].matchAll(/-\s+(.*)/g)].map(m=>m[1].trim()))}const scripts=new Set(Object.keys(JSON.parse(readFileSync(join(REPO_ROOT,"package.json"),"utf8")).scripts??{}));const files=[...DOC_ROOTS.flatMap(d=>walk(resolve(REPO_ROOT,d))),...DOC_FILES.map(f=>resolve(REPO_ROOT,f)).filter(existsSync)];const findings=[];let checkedPaths=0;let checkedScripts=0;let exempted=0;for(const file of files){const where=relative(REPO_ROOT,file);if(where==="scripts/check-skills.mjs")continue;const text=readFileSync(file,"utf8");const allowed=allowedMissing(text);const lines=text.split("\n");let inFence=false;lines.forEach((line,i)=>{const at=`${where}:${i+1}`;if(line.trim().startsWith("```")){inFence=!inFence;return}const window=markerWindow(lines,i);for(const m of line.matchAll(NPM_RE)){const name=m[1];checkedScripts++;if(scripts.has(name))continue;if(HISTORICAL.test(window)||FOREIGN.test(window)||allowed.has(name)){exempted++;continue}findings.push({at,ref:`npm run ${name}`,why:"no such script in package.json",line})}for(const m of line.matchAll(OTHER_PM_RE)){if(HISTORICAL.test(window)||FOREIGN.test(window)||allowed.has(m[0])){exempted++;continue}findings.push({at,ref:m[0],why:`this repo is npm-only \u2014 say \`npm run \u2026\`, or mark the line as the host's`,line})}for(const m of inFence||IMPORTISH.test(line)?[]:line.matchAll(PARENT_RELATIVE_RE)){const ref=filePart(m[1]);if(/[*{]/.test(ref))continue;if(/(^|\/)(__output__|__snapshots__|dist|dist-bundles|node_modules)(\/|$)/.test(ref))continue;if(/^(\.\.\/)+$/.test(ref)||!/[A-Za-z0-9]/.test(ref.replace(/[./]/g,"")))continue;checkedPaths++;if(resolves(ref,dirname(file)))continue;if(allowed.has(ref)){exempted++;continue}if(PLACEHOLDER.test(ref)){exempted++;continue}if(HISTORICAL.test(window)||FOREIGN.test(window)){exempted++;continue}findings.push({at,ref,why:"does not exist relative to this file",line})}for(const m of line.matchAll(PATH_RE)){const ref=filePart(m[1]);if(/[*{]/.test(ref))continue;if(/(^|\/)(__output__|__snapshots__|dist|dist-bundles|node_modules)(\/|$)/.test(ref))continue;checkedPaths++;if(resolves(ref))continue;if(allowed.has(ref)){exempted++;continue}if(PLACEHOLDER.test(ref)){exempted++;continue}if(HISTORICAL.test(window)||FOREIGN.test(window)){exempted++;continue}findings.push({at,ref,why:"does not exist in this repo",line})}})}const CONTRACT_SRC="src/types/host-contract.ts";const contractSrc=existsSync(join(REPO_ROOT,CONTRACT_SRC))?readFileSync(join(REPO_ROOT,CONTRACT_SRC),"utf8"):null;let contractChecked=0;if(contractSrc){const exported=new Set([...contractSrc.matchAll(/export\s+(?:interface|type)\s+([A-Z]\w+)/g)].map(m=>m[1]));for(const file of files){const where=relative(REPO_ROOT,file);const text=readFileSync(file,"utf8");for(const block of text.matchAll(/```(?:ts|tsx|typescript)\n([\s\S]*?)```/g)){for(const decl of block[1].matchAll(/(?:export\s+)?(?:interface|type)\s+([A-Z]\w+)/g)){const name=decl[1];if(!exported.has(name))continue;const docFields=topLevelFields(declBody(block[1],name));const srcFields=topLevelFields(declBody(contractSrc,name));if(docFields===null||srcFields===null)continue;contractChecked++;const dk=[...docFields.keys()].sort();const sk=[...srcFields.keys()].sort();if(dk.join("|")!==sk.join("|")){findings.push({at:where,ref:name,why:`quoted shape has drifted from ${CONTRACT_SRC} \u2014 doc has [${dk.join(", ")}], source has [${sk.join(", ")}]`,line:`(fenced ts block declaring ${name})`});continue}for(const[field,srcType]of srcFields){const alias=/^([A-Z]\w+)$/.exec(srcType.trim());if(!alias)continue;const docInline=docFields.get(field)??"";if(!docInline.includes("{"))continue;const nestedDoc=topLevelKeys(docInline.slice(docInline.indexOf("{")+1,docInline.lastIndexOf("}")));const nestedSrc=topLevelKeys(declBody(contractSrc,alias[1]));if(nestedDoc===null||nestedSrc===null)continue;contractChecked++;if(nestedDoc.join("|")===nestedSrc.join("|"))continue;findings.push({at:where,ref:`${name}.${field}`,why:`inlined ${alias[1]} has drifted from ${CONTRACT_SRC} \u2014 doc has [${nestedDoc.join(", ")}], source has [${nestedSrc.join(", ")}]`,line:`(fenced ts block declaring ${name})`})}}}}}if(findings.length>0){console.error(`[skills] ${findings.length} dangling reference(s) in agent documentation:
|
|
3
3
|
`);for(const f of findings){console.error(` ${f.at}`);console.error(` "${f.ref}" \u2014 ${f.why}`);console.error(` ${f.line.trim().slice(0,120)}
|
|
4
|
-
`)}console.error('Fix the prose. If the reference is deliberate history, say so ON THE SAME LINE\n("retired 2026-08-08", "deleted", "no longer exists"); if it is a path in the HOST\nrepo, say "the host\'s \u2026". Both are recognised and exempt. Adding a path to a skill\'s\nfront-matter `allowed_missing:` is the last resort, not the first.');process.exit(1)}assertNonVacuous("skills",{paths:{count:checkedPaths,unit:"path citation(s)"},scripts:{count:checkedScripts,unit:"npm script(s)"}
|
|
4
|
+
`)}console.error('Fix the prose. If the reference is deliberate history, say so ON THE SAME LINE\n("retired 2026-08-08", "deleted", "no longer exists"); if it is a path in the HOST\nrepo, say "the host\'s \u2026". Both are recognised and exempt. Adding a path to a skill\'s\nfront-matter `allowed_missing:` is the last resort, not the first.');process.exit(1)}assertNonVacuous("skills",{paths:{count:checkedPaths,unit:"path citation(s)"},scripts:{count:checkedScripts,unit:"npm script(s)"},...contractSrc?{contractTypes:{count:contractChecked,unit:"quoted contract type(s)"}}:{}});console.log(`[skills] ok \u2014 ${checkedPaths} path citation(s) and ${checkedScripts} npm script(s) across ${files.length} document(s) resolve; ${exempted} exempt (historical / foreign / placeholder).`);if(contractSrc){console.log(`[skills] ok \u2014 ${contractChecked} quoted contract type(s) match ${CONTRACT_SRC} (field names and optionality; not type text \u2014 see this script's header).`)}else{console.log(`[skills] note: ${CONTRACT_SRC} is absent (the host contract is retired); quoted contract types skipped.`)}
|
|
@@ -1,6 +1,6 @@
|
|
|
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 contractSkipReason=null;if(existsSync(GENERATED_CONTRACT)){const source=readFileSync(GENERATED_CONTRACT,"utf8");contractQueries=generatedContractKeys(source,"Queries");contractMutations=generatedContractKeys(source,"Mutations");if(!contractQueries||!contractMutations){contractSkipReason="host-contract.generated.ts has no Queries/Mutations interface";contractQueries=null;contractMutations=null}}else{contractSkipReason="src/types/host-contract.generated.ts is absent \u2014 run scripts/sync-host-types.mjs"}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.`)}}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===""){problems.push(`${where} data.queries.${friendly} declares no registry id.`)}else if(contractQueries&&!contractQueries.has(id)){problems.push(`${where} data.queries.${friendly} \u2192 "${id}" is not a query in src/types/host-contract.generated.ts, so the binding resolves to nothing (and register-components.mjs hard-fails on it \u2014 ground rule 4).`)}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===""){problems.push(`${where} actions.${friendly} declares no registry id.`)}else if(contractMutations&&!contractMutations.has(id)){problems.push(`${where} actions.${friendly} \u2192 "${id}" is not a mutation in src/types/host-contract.generated.ts \u2014 the action binds nothing and the component reports unbound.`)}}}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}.
|
|
3
|
-
`)}if(
|
|
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}.
|
|
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(`
|
|
6
6
|
Not gated: ${notGated.join(", ")} \u2014 reported but not enforced. Run with --all-gated to enforce.`)}console.warn("")}if(errors.length>0){console.error(`[check-template-routes] ${errors.length} error(s):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import{readFileSync,existsSync,readdirSync}from"node:fs";import{join,dirname,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 repoRoot=REPO_ROOT_;const templatesRoot=join(repoRoot,"src","project_templates");const args=process.argv.slice(2);const only=args.find(a=>!a.startsWith("--"));const gateAll=args.includes("--all-gated");const warnOnly=args.includes("--warn-only");const ENGINE_CONFIG=await loadConfig();const GATED_TEMPLATES=new Set(ENGINE_CONFIG.checks.gated.projectTemplates);const STATE_SYSTEMS=new Set(["eng_data","eng_process","eng_model"]);const MAX_NODES_CEILING=500;const DEFAULT_MAX_NODES=50;const RESERVED_KEYS=new Set(["_traversal","params"]);const HOST_PROVIDED_PARAMS=new Set(["projectId","clientId","userId","orgId","organizationId","advisorOrgId","pageId","pageType","processId"]);let exportedNames=null;let hasComponentJSDoc=null;let resolveComponentFilePath=null;let a1SkipReason=null;try{const parser=await import("../lib/jsdoc-parser.mjs");if(typeof parser.readExportsFromIndex!=="function"){a1SkipReason="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){a1SkipReason=`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 e10SkipReason=null;if(existsSync(GENERATED_CONTRACT)){const source=readFileSync(GENERATED_CONTRACT,"utf8");contractQueries=generatedContractKeys(source,"Queries");contractMutations=generatedContractKeys(source,"Mutations");if(!contractQueries||!contractMutations){e10SkipReason="host-contract.generated.ts has no Queries/Mutations interface";contractQueries=null;contractMutations=null}}else{e10SkipReason="src/types/host-contract.generated.ts is absent \u2014 run sync-host-types.mjs"}function collectTokens(value,out=[]){if(typeof value==="string"){if(value.startsWith("@"))out.push(value)}else if(Array.isArray(value)){for(const v of value)collectTokens(v,out)}else if(value&&typeof value==="object"){for(const v of Object.values(value))collectTokens(v,out)}return out}function collectInterpolations(value,out=[]){if(typeof value==="string"){for(const match of value.matchAll(/\$\{(?:context\.)?([^}]+)\}/g)){out.push({raw:match[0],path:match[1]})}}else if(Array.isArray(value)){for(const v of value)collectInterpolations(v,out)}else if(value&&typeof value==="object"){for(const v of Object.values(value))collectInterpolations(v,out)}return out}function pageFilesFor(templateSlug){const pagesDir=join(templatesRoot,templateSlug,"pages");const shipped=[];const stale=[];if(!existsSync(pagesDir))return{shipped,stale};for(const entry of readdirSync(pagesDir,{withFileTypes:true})){if(!entry.isDirectory())continue;const dir=join(pagesDir,entry.name);for(const file of readdirSync(dir)){if(file==="page.json")shipped.push(join(dir,file));else if(/^page\..+\.json$/.test(file))stale.push(join(dir,file))}}return{shipped,stale}}const templates=existsSync(templatesRoot)?readdirSync(templatesRoot,{withFileTypes:true}).filter(e=>e.isDirectory()).map(e=>e.name).filter(slug=>!only||slug===only):[];const errors=[];const warnings=[];for(const spec of SHARED.unresolved)errors.push(SHARED.error??unresolvedMessage(spec));let fileCount=0;let staleCount=0;let cellCount=0;let documentPageCount=0;let tokenCount=0;let patternBCells=0;function checkComponentName(where,name,problems){if(!name){problems.push(`${where} has no component.`);return}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 file ("${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 W5_SEEN=new Map;function checkRegistryIds(where,label,cell,problems){if(!contractQueries||!contractMutations)return;const queries=cell.data?.queries??{};for(const[friendly,decl]of Object.entries(queries)){const id=typeof decl==="string"?decl:decl?.registry;if(typeof id!=="string"||id===""){if(decl&&typeof decl==="object"&&decl.graph_node_id)continue;const key=`${label}::${friendly}`;const seen=W5_SEEN.get(key);if(seen){seen.count+=1;continue}const record={count:1,index:warnings.length};W5_SEEN.set(key,record);warnings.push(`${label}: data.queries.${friendly} declares neither \`registry\` nor \`graph_node_id\` (keys: ${Object.keys(decl??{}).join(", ")||"none"}), so getStaticDataFromDeclarations resolves it to null. Harmless only because these cells bind through the traversal binding_key "${friendly}" instead.`);continue}if(!contractQueries.has(id)){problems.push(`${where} data.queries.${friendly} \u2192 "${id}" is not a query in src/types/host-contract.generated.ts, so the binding resolves to nothing (and register-components.mjs hard-fails on it \u2014 ground rule 4).`)}}for(const[friendly,decl]of Object.entries(cell.actions??{})){const id=typeof decl==="string"?decl:decl?.registry;if(typeof id!=="string"||id===""){problems.push(`${where} actions.${friendly} declares no registry id.`);continue}if(!contractMutations.has(id)){problems.push(`${where} actions.${friendly} \u2192 "${id}" is not a mutation in src/types/host-contract.generated.ts \u2014 the action binds nothing and the cell reports unbound.`)}}}function auditPage(file,slug,tag,allowedParams,problems){const rel=relative(repoRoot,file);const label=tag?`${rel} [${tag}]`:rel;let page;try{page=JSON.parse(readFileSync(file,"utf8"))}catch(err){problems.push(`${label}: invalid JSON \u2014 ${err.message}`);return}const declared=new Set(Object.keys(page.metadata?.binding_sources??{}));const cells=page.content?.cells??[];const pageLayout=page.layout??{};let structureChildren=null;let documentPages=0;if(pageLayout.type==="document"){documentPages+=1;structureChildren=new Set;const structure=Array.isArray(pageLayout.structure)?pageLayout.structure:null;if(!structure){problems.push(`${label}: layout.type is "document" but there is no layout.structure \u2014 StructureRenderer renders nothing.`)}else{const visit=node=>{if(!node)return;if(typeof node==="string"){structureChildren.add(node);return}if(node.cellId)structureChildren.add(node.cellId);if(node.id&&!node.cellId){problems.push(`${label}: structure node uses "id": "${node.id}" \u2014 the renderer reads "cellId" and ignores "id", so this node resolves to nothing and renders blank. Rename the key.`)}for(const child of node.children??[])visit(child)};for(const node of structure)visit(node)}}documentPageCount+=documentPages;const seenIds=new Set;for(const cell of cells){cellCount+=1;const where=`${label}: cell "${cell.id}"`;if(cell.id===void 0||cell.id===null||cell.id===""){problems.push(`${label}: a cell has no id.`)}else if(seenIds.has(cell.id)){problems.push(`${label}: duplicate cell id "${cell.id}".`)}else{seenIds.add(cell.id)}checkComponentName(where,cell.component,problems);checkRegistryIds(where,label,cell,problems);if(structureChildren&&cell.id&&!structureChildren.has(cell.id)){problems.push(`${where} is not claimed by any layout.structure[].children, so StructureRenderer clears it and the cell vanishes with no error.`)}const span=cell.layout?.span;if(pageLayout.type==="grid"&&span!==void 0){const columns=pageLayout.columns??12;if(!Number.isInteger(span)||span<1||span>columns){warnings.push(`${where} layout.span is ${JSON.stringify(span)} on a ${columns}-column grid \u2014 expected an integer 1..${columns}.`)}}const tokens=collectTokens(cell.props??{});tokenCount+=tokens.length;const traversal=cell.traversal;const keys=tokens.map(t=>t.slice(1).split(".")[0]);const usesPatternB=keys.includes("_traversal");if(usesPatternB)patternBCells+=1;const dataTokens=tokens.filter(t=>!t.startsWith("@params."));if(dataTokens.length>0&&!traversal){problems.push(`${where} has ${dataTokens.length} @token(s) but no traversal block \u2014 usePageCellTraversalData skips it and every token resolves to nothing.`)}if(tokens.length===0&&traversal){problems.push(`${where} declares traversal but has no @tokens \u2014 a BFS per render feeding nothing.`)}for(const token of tokens){if(!token.startsWith("@params."))continue;const param=token.slice("@params.".length).split(".")[0];if(HOST_PROVIDED_PARAMS.has(param))continue;if(allowedParams?.has(param))continue;problems.push(`${where} reads ${token} but "${param}" is neither host-provided nor in layout.context_parameters of ${slug}/template.json. pickAllowedContextParams drops an undeclared query parameter, so this resolves to nothing and the cell renders its empty state.`)}for(const{raw,path}of collectInterpolations(cell.props??{})){const root=path.split(".")[0];if(HOST_PROVIDED_PARAMS.has(root))continue;if(allowedParams?.has(root))continue;problems.push(`${where} interpolates ${raw} but "${root}" is neither host-provided nor in layout.context_parameters of ${slug}/template.json. On a miss the host leaves the literal "${raw}" in the prop, which reads as a real value downstream.`)}for(const[i,key]of keys.entries()){if(RESERVED_KEYS.has(key))continue;if(!declared.has(key)){problems.push(`${where} token ${tokens[i]} \u2192 key "${key}" is not in metadata.binding_sources, so no producer is documented for it.`)}}if(!traversal)continue;if(!Array.isArray(traversal.edgeTypes)||traversal.edgeTypes.length===0){problems.push(`${where} traversal has no edgeTypes. There is no RPC default \u2014 the walk returns nothing.`)}if(traversal.include_state!==void 0){if(!Array.isArray(traversal.include_state)){problems.push(`${where} traversal.include_state must be an array.`)}else{for(const system of traversal.include_state){if(!STATE_SYSTEMS.has(system)){problems.push(`${where} traversal.include_state contains ${JSON.stringify(system)} \u2014 expected one of ${[...STATE_SYSTEMS].join(" | ")}. resolveStateSystems drops it and the cell renders no state for that system.`)}}}}if(usesPatternB&&!traversal.anchor){problems.push(`${where} consumes @_traversal but declares no traversal.anchor. Auto-wire matches @<binding_key> tokens in props to create the data_flow source\u2192cell edge, and a Pattern B cell has none \u2014 without an anchor the edge must be hand-wired per instance.`)}if(traversal.maxNodes!==void 0){const n=traversal.maxNodes;if(!Number.isInteger(n)||n<=0){problems.push(`${where} traversal.maxNodes must be a positive integer, got ${JSON.stringify(n)}.`)}else if(n>MAX_NODES_CEILING){problems.push(`${where} traversal.maxNodes ${n} exceeds the host ceiling of ${MAX_NODES_CEILING}; it would be clamped.`)}}if(usesPatternB&&!traversal.include_state){warnings.push(`${where} consumes @_traversal.nodes with no include_state \u2014 nodes arrive with content only (no status, no criteria). Correct for a label-only view; otherwise add include_state.`)}if(usesPatternB&&traversal.maxNodes===void 0&&(traversal.maxDepth??3)>1){warnings.push(`${where} walks to depth ${traversal.maxDepth??3} at the default cap of ${DEFAULT_MAX_NODES} nodes, so it may truncate silently. Set traversal.maxNodes.`)}}}for(const slug of templates){const templateFile=join(templatesRoot,slug,"template.json");let allowedParams=null;if(existsSync(templateFile)){try{const template=JSON.parse(readFileSync(templateFile,"utf8"));const declaredParams=template.layout?.context_parameters;if(Array.isArray(declaredParams))allowedParams=new Set(declaredParams)}catch{}}const gated=gateAll||GATED_TEMPLATES.has(slug);const problems=gated?errors:warnings;const{shipped,stale}=pageFilesFor(slug);for(const file of stale){staleCount+=1;problems.push(`${relative(repoRoot,file)} exists beside page.json. page.json is the single source \u2014 the page-templates layer reads it and nothing in scripts/, src/ or test/ reads this file, so it is edited and never shipped. Merge it into page.json and delete it.`)}for(const file of shipped){fileCount+=1;auditPage(file,slug,null,allowedParams,problems)}for(const file of stale){auditPage(file,slug,"stale",allowedParams,warnings)}}for(const rec of W5_SEEN.values()){if(rec.count>1)warnings[rec.index]+=` Repeated on ${rec.count} cells.`}if(a1SkipReason){console.warn(`[check-wired-bindings] A1 SKIPPED \u2014 ${a1SkipReason}. Component names unchecked.
|
|
3
|
-
`)}if(
|
|
2
|
+
import{readFileSync,existsSync,readdirSync}from"node:fs";import{join,dirname,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 repoRoot=REPO_ROOT_;const templatesRoot=join(repoRoot,"src","project_templates");const args=process.argv.slice(2);const only=args.find(a=>!a.startsWith("--"));const gateAll=args.includes("--all-gated");const warnOnly=args.includes("--warn-only");const ENGINE_CONFIG=await loadConfig();const GATED_TEMPLATES=new Set(ENGINE_CONFIG.checks.gated.projectTemplates);const STATE_SYSTEMS=new Set(["eng_data","eng_process","eng_model"]);const MAX_NODES_CEILING=500;const DEFAULT_MAX_NODES=50;const RESERVED_KEYS=new Set(["_traversal","params"]);const HOST_PROVIDED_PARAMS=new Set(["projectId","clientId","userId","orgId","organizationId","advisorOrgId","pageId","pageType","processId"]);let exportedNames=null;let hasComponentJSDoc=null;let resolveComponentFilePath=null;let a1SkipReason=null;try{const parser=await import("../lib/jsdoc-parser.mjs");if(typeof parser.readExportsFromIndex!=="function"){a1SkipReason="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){a1SkipReason=`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 collectTokens(value,out=[]){if(typeof value==="string"){if(value.startsWith("@"))out.push(value)}else if(Array.isArray(value)){for(const v of value)collectTokens(v,out)}else if(value&&typeof value==="object"){for(const v of Object.values(value))collectTokens(v,out)}return out}function collectInterpolations(value,out=[]){if(typeof value==="string"){for(const match of value.matchAll(/\$\{(?:context\.)?([^}]+)\}/g)){out.push({raw:match[0],path:match[1]})}}else if(Array.isArray(value)){for(const v of value)collectInterpolations(v,out)}else if(value&&typeof value==="object"){for(const v of Object.values(value))collectInterpolations(v,out)}return out}function pageFilesFor(templateSlug){const pagesDir=join(templatesRoot,templateSlug,"pages");const shipped=[];const stale=[];if(!existsSync(pagesDir))return{shipped,stale};for(const entry of readdirSync(pagesDir,{withFileTypes:true})){if(!entry.isDirectory())continue;const dir=join(pagesDir,entry.name);for(const file of readdirSync(dir)){if(file==="page.json")shipped.push(join(dir,file));else if(/^page\..+\.json$/.test(file))stale.push(join(dir,file))}}return{shipped,stale}}const templates=existsSync(templatesRoot)?readdirSync(templatesRoot,{withFileTypes:true}).filter(e=>e.isDirectory()).map(e=>e.name).filter(slug=>!only||slug===only):[];const errors=[];const warnings=[];for(const spec of SHARED.unresolved)errors.push(SHARED.error??unresolvedMessage(spec));let fileCount=0;let staleCount=0;let cellCount=0;let documentPageCount=0;let tokenCount=0;let patternBCells=0;function checkComponentName(where,name,problems){if(!name){problems.push(`${where} has no component.`);return}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 file ("${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 W5_SEEN=new Map;const RETIRED="retired host-contract binding; replace with a recipe descriptor";function checkRegistryIds(where,label,cell,problems){const notInContract=(set,id,kind)=>set&&!set.has(id)?` ("${id}" is not a ${kind} in src/types/host-contract.generated.ts either)`:"";const queries=cell.data?.queries??{};for(const[friendly,decl]of Object.entries(queries)){const id=typeof decl==="string"?decl:decl?.registry;if(typeof id!=="string"||id===""){if(decl&&typeof decl==="object"&&decl.graph_node_id)continue;const key=`${label}::${friendly}`;const seen=W5_SEEN.get(key);if(seen){seen.count+=1;continue}const record={count:1,index:warnings.length};W5_SEEN.set(key,record);warnings.push(`${label}: data.queries.${friendly} declares neither \`registry\` nor \`graph_node_id\` (keys: ${Object.keys(decl??{}).join(", ")||"none"}), so getStaticDataFromDeclarations resolves it to null. Harmless only because these cells bind through the traversal binding_key "${friendly}" instead.`);continue}warnings.push(`${where} data.queries.${friendly} \u2192 "${id}": ${RETIRED}${notInContract(contractQueries,id,"query")}.`)}for(const[friendly,decl]of Object.entries(cell.actions??{})){const id=typeof decl==="string"?decl:decl?.registry;if(typeof id!=="string"||id==="")continue;warnings.push(`${where} actions.${friendly} \u2192 "${id}": ${RETIRED} (actions: M2's action declarations)${notInContract(contractMutations,id,"mutation")}.`)}}function auditPage(file,slug,tag,allowedParams,problems){const rel=relative(repoRoot,file);const label=tag?`${rel} [${tag}]`:rel;let page;try{page=JSON.parse(readFileSync(file,"utf8"))}catch(err){problems.push(`${label}: invalid JSON \u2014 ${err.message}`);return}const declared=new Set(Object.keys(page.metadata?.binding_sources??{}));const cells=page.content?.cells??[];const pageLayout=page.layout??{};let structureChildren=null;let documentPages=0;if(pageLayout.type==="document"){documentPages+=1;structureChildren=new Set;const structure=Array.isArray(pageLayout.structure)?pageLayout.structure:null;if(!structure){problems.push(`${label}: layout.type is "document" but there is no layout.structure \u2014 StructureRenderer renders nothing.`)}else{const visit=node=>{if(!node)return;if(typeof node==="string"){structureChildren.add(node);return}if(node.cellId)structureChildren.add(node.cellId);if(node.id&&!node.cellId){problems.push(`${label}: structure node uses "id": "${node.id}" \u2014 the renderer reads "cellId" and ignores "id", so this node resolves to nothing and renders blank. Rename the key.`)}for(const child of node.children??[])visit(child)};for(const node of structure)visit(node)}}documentPageCount+=documentPages;const seenIds=new Set;for(const cell of cells){cellCount+=1;const where=`${label}: cell "${cell.id}"`;if(cell.id===void 0||cell.id===null||cell.id===""){problems.push(`${label}: a cell has no id.`)}else if(seenIds.has(cell.id)){problems.push(`${label}: duplicate cell id "${cell.id}".`)}else{seenIds.add(cell.id)}checkComponentName(where,cell.component,problems);checkRegistryIds(where,label,cell,problems);if(structureChildren&&cell.id&&!structureChildren.has(cell.id)){problems.push(`${where} is not claimed by any layout.structure[].children, so StructureRenderer clears it and the cell vanishes with no error.`)}const span=cell.layout?.span;if(pageLayout.type==="grid"&&span!==void 0){const columns=pageLayout.columns??12;if(!Number.isInteger(span)||span<1||span>columns){warnings.push(`${where} layout.span is ${JSON.stringify(span)} on a ${columns}-column grid \u2014 expected an integer 1..${columns}.`)}}const tokens=collectTokens(cell.props??{});tokenCount+=tokens.length;const traversal=cell.traversal;const keys=tokens.map(t=>t.slice(1).split(".")[0]);const usesPatternB=keys.includes("_traversal");if(usesPatternB)patternBCells+=1;const dataTokens=tokens.filter(t=>!t.startsWith("@params."));if(dataTokens.length>0&&!traversal){problems.push(`${where} has ${dataTokens.length} @token(s) but no traversal block \u2014 usePageCellTraversalData skips it and every token resolves to nothing.`)}if(tokens.length===0&&traversal){problems.push(`${where} declares traversal but has no @tokens \u2014 a BFS per render feeding nothing.`)}for(const token of tokens){if(!token.startsWith("@params."))continue;const param=token.slice("@params.".length).split(".")[0];if(HOST_PROVIDED_PARAMS.has(param))continue;if(allowedParams?.has(param))continue;problems.push(`${where} reads ${token} but "${param}" is neither host-provided nor in layout.context_parameters of ${slug}/template.json. pickAllowedContextParams drops an undeclared query parameter, so this resolves to nothing and the cell renders its empty state.`)}for(const{raw,path}of collectInterpolations(cell.props??{})){const root=path.split(".")[0];if(HOST_PROVIDED_PARAMS.has(root))continue;if(allowedParams?.has(root))continue;problems.push(`${where} interpolates ${raw} but "${root}" is neither host-provided nor in layout.context_parameters of ${slug}/template.json. On a miss the host leaves the literal "${raw}" in the prop, which reads as a real value downstream.`)}for(const[i,key]of keys.entries()){if(RESERVED_KEYS.has(key))continue;if(!declared.has(key)){problems.push(`${where} token ${tokens[i]} \u2192 key "${key}" is not in metadata.binding_sources, so no producer is documented for it.`)}}if(!traversal)continue;if(!Array.isArray(traversal.edgeTypes)||traversal.edgeTypes.length===0){problems.push(`${where} traversal has no edgeTypes. There is no RPC default \u2014 the walk returns nothing.`)}if(traversal.include_state!==void 0){if(!Array.isArray(traversal.include_state)){problems.push(`${where} traversal.include_state must be an array.`)}else{for(const system of traversal.include_state){if(!STATE_SYSTEMS.has(system)){problems.push(`${where} traversal.include_state contains ${JSON.stringify(system)} \u2014 expected one of ${[...STATE_SYSTEMS].join(" | ")}. resolveStateSystems drops it and the cell renders no state for that system.`)}}}}if(usesPatternB&&!traversal.anchor){problems.push(`${where} consumes @_traversal but declares no traversal.anchor. Auto-wire matches @<binding_key> tokens in props to create the data_flow source\u2192cell edge, and a Pattern B cell has none \u2014 without an anchor the edge must be hand-wired per instance.`)}if(traversal.maxNodes!==void 0){const n=traversal.maxNodes;if(!Number.isInteger(n)||n<=0){problems.push(`${where} traversal.maxNodes must be a positive integer, got ${JSON.stringify(n)}.`)}else if(n>MAX_NODES_CEILING){problems.push(`${where} traversal.maxNodes ${n} exceeds the host ceiling of ${MAX_NODES_CEILING}; it would be clamped.`)}}if(usesPatternB&&!traversal.include_state){warnings.push(`${where} consumes @_traversal.nodes with no include_state \u2014 nodes arrive with content only (no status, no criteria). Correct for a label-only view; otherwise add include_state.`)}if(usesPatternB&&traversal.maxNodes===void 0&&(traversal.maxDepth??3)>1){warnings.push(`${where} walks to depth ${traversal.maxDepth??3} at the default cap of ${DEFAULT_MAX_NODES} nodes, so it may truncate silently. Set traversal.maxNodes.`)}}}for(const slug of templates){const templateFile=join(templatesRoot,slug,"template.json");let allowedParams=null;if(existsSync(templateFile)){try{const template=JSON.parse(readFileSync(templateFile,"utf8"));const declaredParams=template.layout?.context_parameters;if(Array.isArray(declaredParams))allowedParams=new Set(declaredParams)}catch{}}const gated=gateAll||GATED_TEMPLATES.has(slug);const problems=gated?errors:warnings;const{shipped,stale}=pageFilesFor(slug);for(const file of stale){staleCount+=1;problems.push(`${relative(repoRoot,file)} exists beside page.json. page.json is the single source \u2014 the page-templates layer reads it and nothing in scripts/, src/ or test/ reads this file, so it is edited and never shipped. Merge it into page.json and delete it.`)}for(const file of shipped){fileCount+=1;auditPage(file,slug,null,allowedParams,problems)}for(const file of stale){auditPage(file,slug,"stale",allowedParams,warnings)}}for(const rec of W5_SEEN.values()){if(rec.count>1)warnings[rec.index]+=` Repeated on ${rec.count} cells.`}if(a1SkipReason){console.warn(`[check-wired-bindings] A1 SKIPPED \u2014 ${a1SkipReason}. Component names unchecked.
|
|
3
|
+
`)}if(contractNote){console.warn(`[check-wired-bindings] note: ${contractNote}; registry bindings are warnings only.
|
|
4
4
|
`)}if(warnings.length>0){console.warn(`[check-wired-bindings] ${warnings.length} warning(s):
|
|
5
5
|
`);for(const w of warnings)console.warn(` \xB7 ${w}`);console.warn("")}const notGated=templates.filter(s=>!gateAll&&!GATED_TEMPLATES.has(s));if(notGated.length>0){console.warn(` Not gated: ${notGated.join(", ")} \u2014 reported but not enforced. Run with --all-gated to enforce.
|
|
6
6
|
`)}if(errors.length>0){console.error(`[check-wired-bindings] ${errors.length} error(s):
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import{existsSync,mkdirSync,readFileSync,writeFileSync}from"node:fs";import{resolve,dirname}from"node:path";import{fileURLToPath}from"node:url";import{initFromArgv,loadEnv,parseFlagValue,parseTargetEnv}from"../lib/env.mjs";import{collectReferencedKeys,hasComponentJSDoc,parseJSDocFile,readExportsFromIndex,resolveComponentFilePath,toDisplayName,toRepoRelative}from"../lib/jsdoc-parser.mjs";import{REPO_ROOT as REPO_ROOT_}from"../lib/root.mjs";import{lineageConfig,loadConfig,MAIN_LINEAGE,PUBLIC_LINEAGE}from"../lib/config.mjs";const repoRoot=REPO_ROOT_;const argv=process.argv.slice(2);function orgSlugFromConfig(){try{const raw=readFileSync(resolve(repoRoot,"template/config/org.json"),"utf8");return JSON.parse(raw).slug??null}catch{return null}}const EMIT_ROWS=parseFlagValue(argv,"emit-rows");const target=parseTargetEnv(argv);let ORG_SLUG;let supabase;if(EMIT_ROWS){loadEnv(target);ORG_SLUG=parseFlagValue(argv,"org")??process.env.ORG_SLUG??process.env.VITE_ORG_SLUG??(await loadConfig()).org??orgSlugFromConfig();if(!ORG_SLUG){console.error("[register-components] --emit-rows still needs an org: pass --org <slug>, set ORG_SLUG / VITE_ORG_SLUG, or set `org` in engine.config.ts.");process.exit(1)}}else{({orgSlug:ORG_SLUG,supabase}=initFromArgv(argv))}let VERSION=parseFlagValue(argv,"version")??process.env.VERSION??null;const BUNDLE_NAME=parseFlagValue(argv,"bundle")??"main";const LINEAGE=await lineageConfig(BUNDLE_NAME);const entryRelative=parseFlagValue(argv,"entry")??LINEAGE.entry;if(!existsSync(resolve(repoRoot,entryRelative))){console.error(`[register-components] no entry barrel at ${entryRelative} for lineage "${BUNDLE_NAME}".`);process.exit(1)}function entryRuleFor(){return{sourceRoot:LINEAGE.sourceRoot}}const ENTRY_RULE=entryRuleFor();const allExports=readExportsFromIndex(repoRoot,entryRelative);const exports=[];const skipped=[];for(const entry of allExports){const filePath=resolveComponentFilePath(repoRoot,entry.source);if(filePath&&hasComponentJSDoc(filePath,entry.name)){exports.push({...entry,filePath})}else{skipped.push(entry.name)}}if(exports.length===0){if(BUNDLE_NAME===MAIN_LINEAGE){console.error(`[register-components] No exports with an @component JSDoc block found in ${entryRelative}. The org bundle cannot be empty.`);process.exit(1)}console.warn(`[register-components] ${entryRelative} declares no components yet \u2014 registering the "${BUNDLE_NAME}" lineage with an empty component set so it can be cut over.`)}if(skipped.length>0){console.log(`[register-components] skipping ${skipped.length} export(s) with no matching @component block: ${skipped.join(", ")}`)}function buildSpecs(){return exports.map(({name,filePath})=>{const repoRelativePath=toRepoRelative(repoRoot,filePath);if(ENTRY_RULE.sourceRoot&&!repoRelativePath.startsWith(ENTRY_RULE.sourceRoot)){console.error(`[register-components] "${name}" is exported from ${entryRelative} but its source (${repoRelativePath}) is not under ${ENTRY_RULE.sourceRoot}. Refusing to register \u2014 the "${BUNDLE_NAME}" bundle may only publish components from that root.`);if(BUNDLE_NAME!==PUBLIC_LINEAGE){console.error(` If this is a shared component, do not re-export it here: it stays in the org's
|
|
3
|
-
main bundle and resolves through the host's cascade one layer down.`)}process.exit(1)}const jsdoc=parseJSDocFile(filePath,name);return{component_name:name,exposed_export:name,description:jsdoc.description??`${toDisplayName(name)} federated component`,props_schema:jsdoc.props_schema??null,file_path:filePath?`src/${toRepoRelative(repoRoot,filePath)}`:null,metadata:{source:"federated-remote",remote_org_slug:ORG_SLUG,...jsdoc.use_cases?{use_cases:jsdoc.use_cases}:{}}}})}if(EMIT_ROWS){const rows=buildSpecs();const outputPath=resolve(repoRoot,EMIT_ROWS);mkdirSync(dirname(outputPath),{recursive:true});writeFileSync(outputPath,JSON.stringify({_comment:"Generated by `register-components.mjs --emit-rows`. A dry run: these are the component_registry rows a real registration would write. Consumed by the L3 bundle harness to check the built bundle exports what the host will look for. Not checked in \u2014 regenerate rather than edit.",org_slug:ORG_SLUG,bundle_name:BUNDLE_NAME,entry:entryRelative,generated_at:new Date().toISOString(),rows},null,2)+"\n","utf8");console.log(`[register-components] --emit-rows \u2192 ${EMIT_ROWS} (${rows.length} row(s) for lineage "${BUNDLE_NAME}" from ${entryRelative}; no database touched).`);process.exit(0)}const{data:orgRow,error:orgError}=await supabase.from("organization").select("id").eq("metadata->>org_slug",ORG_SLUG).maybeSingle();if(orgError||!orgRow?.id){console.error(`[register-components] Could not resolve org_id for org_slug="${ORG_SLUG}":`,orgError??"no matching organization row");process.exit(1)}const ORG_ID=orgRow.id;let bundleRow;if(VERSION){const{data,error}=await supabase.from("component_bundle").select("id, status, version").eq("org_id",ORG_ID).eq("bundle_name",BUNDLE_NAME).eq("version",VERSION).maybeSingle();if(error||!data?.id){console.error(`[register-components] No component_bundle for (org=${ORG_SLUG}, bundle=${BUNDLE_NAME}, version=${VERSION}). Run upload-bundle.mjs for this version first.`);process.exit(1)}bundleRow=data}else{const{data,error}=await supabase.from("component_bundle").select("id, status, version").eq("org_id",ORG_ID).eq("bundle_name",BUNDLE_NAME).eq("status","uploaded").order("uploaded_at",{ascending:false}).limit(1).maybeSingle();if(error||!data?.id){console.error(`[register-components] No 'uploaded' bundle found for (org=${ORG_SLUG}, bundle=${BUNDLE_NAME}). Either run upload-bundle.mjs first or pass --version <v>.`);process.exit(1)}bundleRow=data;VERSION=data.version;console.log(`[register-components] auto-discovered most recent 'uploaded' bundle: version=${VERSION}`)}const BUNDLE_ID=bundleRow.id;console.log(`[register-components] target=${target} org=${ORG_SLUG} bundle=${BUNDLE_NAME} version=${VERSION} bundle_id=${BUNDLE_ID} status='${bundleRow.status}' \u2014 ${exports.length} components: ${exports.map(e=>e.name).join(", ")}`);const specs=buildSpecs();const referencedKeys=collectReferencedKeys(specs);if(referencedKeys.queries.size>0||referencedKeys.mutations.size>0){const allKeys=[...referencedKeys.queries,...referencedKeys.mutations];const{data:contractRows,error:contractError}=await supabase.from("component_contract").select("kind, registry_key, is_active, contract_version_removed").in("registry_key",allKeys);if(contractError){console.error("[register-components] Failed to read component_contract:",contractError);process.exit(2)}const indexByKey=new Map;for(const row of contractRows??[]){indexByKey.set(`${row.kind}:${row.registry_key}`,row)}const waived=new Set((parseFlagValue(argv,"allow-missing-contract")??"").split(",").map(s=>s.trim()).filter(Boolean));const waiversUsed=new Set;const problems=[];const warnings=[];for(const spec of specs){const data=spec.props_schema?.data??{};const actions2=spec.props_schema?.actions??{};for(const[propName,decl]of Object.entries(data)){const row=indexByKey.get(`query:${decl.registry}`);if(!row&&waived.has(decl.registry)&&decl.required===false){waiversUsed.add(decl.registry);warnings.push(`${spec.component_name}.${propName} \u2192 query "${decl.registry}" absent here, WAIVED (optional; the component must render without it)`)}else if(!row){problems.push(`${spec.component_name}.${propName} \u2192 unknown query "${decl.registry}"`+(waived.has(decl.registry)?" \u2014 waiver REFUSED: the declaration is required":""))}else if(!row.is_active){problems.push(`${spec.component_name}.${propName} \u2192 deprecated query "${decl.registry}" (removed at v${row.contract_version_removed??"?"})`)}}for(const[propName,decl]of Object.entries(actions2)){const row=indexByKey.get(`mutation:${decl.registry}`);if(!row&&waived.has(decl.registry)&&decl.required===false){waiversUsed.add(decl.registry);warnings.push(`${spec.component_name}.${propName} \u2192 mutation "${decl.registry}" absent here, WAIVED (optional; the component must degrade without it)`)}else if(!row){problems.push(`${spec.component_name}.${propName} \u2192 unknown mutation "${decl.registry}"`+(waived.has(decl.registry)?" \u2014 waiver REFUSED: the declaration is required":""))}else if(!row.is_active){problems.push(`${spec.component_name}.${propName} \u2192 deprecated mutation "${decl.registry}" (removed at v${row.contract_version_removed??"?"})`)}}}if(problems.length>0){console.error("[register-components] Contract validation FAILED:");for(const p of problems)console.error(` \xD7 ${p}`);console.error("");console.error(" Either add the missing keys to lib/portal/contract-manifest/ on");console.error(" the host (then run `pnpm sync-contract` there), or update the");console.error(" component JSDoc to reference an existing registry_key.");console.error("");console.error(" If a key is absent from THIS environment on purpose and the");console.error(" declaration is optional, name it explicitly:");console.error(" --allow-missing-contract <key>[,<key>...]");process.exit(1)}if(warnings.length>0){console.warn("[register-components] Contract validation passed WITH WAIVERS:");for(const w of warnings)console.warn(` \u26A0 ${w}`);console.warn(` These props will be undefined at runtime in this environment. Drop the flag once the host ships them.`)}for(const key of waived){if(!waiversUsed.has(key)){console.warn(`[register-components] \u26A0 waiver "${key}" was not needed \u2014 the key resolves here, or nothing declares it. Remove it from --allow-missing-contract.`)}}}const{data:existing,error:fetchError}=await supabase.from("component_registry").select("id, component_name, delivery_mode, exposed_export, is_active, org_id, bundle_id, description, props_schema, file_path, metadata").eq("org_slug",ORG_SLUG).eq("bundle_id",BUNDLE_ID);if(fetchError){console.error("[register-components] Failed to fetch existing rows:",fetchError);process.exit(2)}const existingByName=new Map(existing?.map(row=>[row.component_name,row])??[]);const actions=[];for(const spec of specs){const row=existingByName.get(spec.component_name);const payload={...spec,org_id:ORG_ID,org_slug:ORG_SLUG,bundle_id:BUNDLE_ID,delivery_mode:"federated",is_active:true};if(row){const changed=row.is_active!==true||row.delivery_mode!=="federated"||row.exposed_export!==payload.exposed_export||row.org_id!==ORG_ID||row.bundle_id!==BUNDLE_ID||row.description!==payload.description||row.file_path!==payload.file_path||canonical(row.props_schema)!==canonical(payload.props_schema)||canonical(row.metadata)!==canonical(payload.metadata);if(changed){actions.push({kind:"update",id:row.id,patch:payload})}}else{actions.push({kind:"insert",payload})}}if(actions.length===0){console.log("[register-components] No changes \u2014 bundle registry already in sync.")}else{for(const action of actions){if(action.kind==="insert"){const{error}=await supabase.from("component_registry").insert(action.payload);if(error){console.error(`[register-components] INSERT failed for "${action.payload.component_name}":`,error);process.exit(1)}console.log(`[register-components] + inserted "${action.payload.component_name}"`)}else{const{error}=await supabase.from("component_registry").update(action.patch).eq("id",action.id);if(error){console.error(`[register-components] UPDATE failed for id=${action.id}:`,error);process.exit(1)}console.log(`[register-components] ~ updated id=${action.id}`)}}console.log(`[register-components] ${actions.length} registry change(s) applied.`)}const KNOWLEDGE_PATH=resolve(repoRoot,`${LINEAGE.dist}/knowledge.json`);if(!existsSync(KNOWLEDGE_PATH)){console.error(`[register-components] no ${KNOWLEDGE_PATH.replace(repoRoot+"/","")}: build the lineage first (the build runs scripts/emit-knowledge.mts). Agents read the catalog from it.`);process.exit(1)}const knowledge=JSON.parse(readFileSync(KNOWLEDGE_PATH,"utf8"));if(knowledge.lineage!==`${ORG_SLUG}/${BUNDLE_NAME}`){console.error(`[register-components] ${KNOWLEDGE_PATH} is for ${knowledge.lineage}, not ${ORG_SLUG}/${BUNDLE_NAME}. Rebuild.`);process.exit(1)}{const{error:clearError}=await supabase.from("component_knowledge").delete().eq("bundle_id",BUNDLE_ID);if(clearError){console.error("[register-components] Failed to clear this bundle's knowledge:",clearError);process.exit(1)}const{data:inserted,error:insertError}=await supabase.from("component_knowledge").insert(knowledge.rows.map(r=>({bundle_id:BUNDLE_ID,kind:r.kind,key:r.key,title:r.title,summary:r.summary,body:r.body??null,tags:r.tags??[],snippets:r.snippets??[],attributes:r.attributes??{},page_cell:r.page_cell??true,sort:r.sort??0}))).select("id, kind, key");if(insertError){console.error("[register-components] Failed to write knowledge rows:",insertError);process.exit(1)}const idOf=new Map(inserted.map(r=>[`${r.kind}:${r.key}`,r.id]));const linkRows=knowledge.links.map(l=>({from_id:idOf.get(`${l.from.kind}:${l.from.key}`),relation:l.relation,to_lineage:l.to.lineage??null,to_kind:l.to.kind,to_key:l.to.key,note:l.note??null}));if(linkRows.some(l=>!l.from_id)){console.error("[register-components] a knowledge link starts at a row that was not written. Rebuild.");process.exit(1)}if(linkRows.length>0){const{error:linkError}=await supabase.from("component_knowledge_link").insert(linkRows);if(linkError){console.error("[register-components] Failed to write knowledge links:",linkError);process.exit(1)}}console.log(`[register-components] knowledge: ${inserted.length} rows, ${linkRows.length} links for ${knowledge.lineage}`)}const{error:bundleUpdateError}=await supabase.from("component_bundle").update({status:"registered",registered_at:new Date().toISOString(),exports:specs.map(s=>s.component_name),manifest:{exports:specs.map(s=>({component_name:s.component_name,exposed_export:s.exposed_export,file_path:s.file_path}))}}).eq("id",BUNDLE_ID);if(bundleUpdateError){console.error(`[register-components] Failed to mark bundle 'registered':`,bundleUpdateError);process.exit(1)}console.log(`[register-components] Done. bundle ${BUNDLE_ID} is now status='registered'.
|
|
3
|
+
main bundle and resolves through the host's cascade one layer down.`)}process.exit(1)}const jsdoc=parseJSDocFile(filePath,name);return{component_name:name,exposed_export:name,description:jsdoc.description??`${toDisplayName(name)} federated component`,props_schema:jsdoc.props_schema??null,file_path:filePath?`src/${toRepoRelative(repoRoot,filePath)}`:null,metadata:{source:"federated-remote",remote_org_slug:ORG_SLUG,...jsdoc.use_cases?{use_cases:jsdoc.use_cases}:{}}}})}if(EMIT_ROWS){const rows=buildSpecs();const outputPath=resolve(repoRoot,EMIT_ROWS);mkdirSync(dirname(outputPath),{recursive:true});writeFileSync(outputPath,JSON.stringify({_comment:"Generated by `register-components.mjs --emit-rows`. A dry run: these are the component_registry rows a real registration would write. Consumed by the L3 bundle harness to check the built bundle exports what the host will look for. Not checked in \u2014 regenerate rather than edit.",org_slug:ORG_SLUG,bundle_name:BUNDLE_NAME,entry:entryRelative,generated_at:new Date().toISOString(),rows},null,2)+"\n","utf8");console.log(`[register-components] --emit-rows \u2192 ${EMIT_ROWS} (${rows.length} row(s) for lineage "${BUNDLE_NAME}" from ${entryRelative}; no database touched).`);process.exit(0)}const{data:orgRow,error:orgError}=await supabase.from("organization").select("id").eq("metadata->>org_slug",ORG_SLUG).maybeSingle();if(orgError||!orgRow?.id){console.error(`[register-components] Could not resolve org_id for org_slug="${ORG_SLUG}":`,orgError??"no matching organization row");process.exit(1)}const ORG_ID=orgRow.id;let bundleRow;if(VERSION){const{data,error}=await supabase.from("component_bundle").select("id, status, version").eq("org_id",ORG_ID).eq("bundle_name",BUNDLE_NAME).eq("version",VERSION).maybeSingle();if(error||!data?.id){console.error(`[register-components] No component_bundle for (org=${ORG_SLUG}, bundle=${BUNDLE_NAME}, version=${VERSION}). Run upload-bundle.mjs for this version first.`);process.exit(1)}bundleRow=data}else{const{data,error}=await supabase.from("component_bundle").select("id, status, version").eq("org_id",ORG_ID).eq("bundle_name",BUNDLE_NAME).eq("status","uploaded").order("uploaded_at",{ascending:false}).limit(1).maybeSingle();if(error||!data?.id){console.error(`[register-components] No 'uploaded' bundle found for (org=${ORG_SLUG}, bundle=${BUNDLE_NAME}). Either run upload-bundle.mjs first or pass --version <v>.`);process.exit(1)}bundleRow=data;VERSION=data.version;console.log(`[register-components] auto-discovered most recent 'uploaded' bundle: version=${VERSION}`)}const BUNDLE_ID=bundleRow.id;console.log(`[register-components] target=${target} org=${ORG_SLUG} bundle=${BUNDLE_NAME} version=${VERSION} bundle_id=${BUNDLE_ID} status='${bundleRow.status}' \u2014 ${exports.length} components: ${exports.map(e=>e.name).join(", ")}`);const specs=buildSpecs();const referencedKeys=collectReferencedKeys(specs);if(referencedKeys.queries.size>0||referencedKeys.mutations.size>0){const declaring=specs.filter(s=>Object.values(s.props_schema?.data??{}).some(d=>d?.registry)||Object.values(s.props_schema?.actions??{}).some(d=>d?.registry)).length;console.warn(`[register-components] \u26A0 ${declaring} component(s) still declare host-contract registry keys (${referencedKeys.queries.size} query, ${referencedKeys.mutations.size} action). The host contract is retired: bind data through graph-defined queries (recipes), and actions through M2's action declarations. The keys are registered as written and not validated.`)}const{data:existing,error:fetchError}=await supabase.from("component_registry").select("id, component_name, delivery_mode, exposed_export, is_active, org_id, bundle_id, description, props_schema, file_path, metadata").eq("org_slug",ORG_SLUG).eq("bundle_id",BUNDLE_ID);if(fetchError){console.error("[register-components] Failed to fetch existing rows:",fetchError);process.exit(2)}const existingByName=new Map(existing?.map(row=>[row.component_name,row])??[]);const actions=[];for(const spec of specs){const row=existingByName.get(spec.component_name);const payload={...spec,org_id:ORG_ID,org_slug:ORG_SLUG,bundle_id:BUNDLE_ID,delivery_mode:"federated",is_active:true};if(row){const changed=row.is_active!==true||row.delivery_mode!=="federated"||row.exposed_export!==payload.exposed_export||row.org_id!==ORG_ID||row.bundle_id!==BUNDLE_ID||row.description!==payload.description||row.file_path!==payload.file_path||canonical(row.props_schema)!==canonical(payload.props_schema)||canonical(row.metadata)!==canonical(payload.metadata);if(changed){actions.push({kind:"update",id:row.id,patch:payload})}}else{actions.push({kind:"insert",payload})}}if(actions.length===0){console.log("[register-components] No changes \u2014 bundle registry already in sync.")}else{for(const action of actions){if(action.kind==="insert"){const{error}=await supabase.from("component_registry").insert(action.payload);if(error){console.error(`[register-components] INSERT failed for "${action.payload.component_name}":`,error);process.exit(1)}console.log(`[register-components] + inserted "${action.payload.component_name}"`)}else{const{error}=await supabase.from("component_registry").update(action.patch).eq("id",action.id);if(error){console.error(`[register-components] UPDATE failed for id=${action.id}:`,error);process.exit(1)}console.log(`[register-components] ~ updated id=${action.id}`)}}console.log(`[register-components] ${actions.length} registry change(s) applied.`)}const KNOWLEDGE_PATH=resolve(repoRoot,`${LINEAGE.dist}/knowledge.json`);if(!existsSync(KNOWLEDGE_PATH)){console.error(`[register-components] no ${KNOWLEDGE_PATH.replace(repoRoot+"/","")}: build the lineage first (the build runs scripts/emit-knowledge.mts). Agents read the catalog from it.`);process.exit(1)}const knowledge=JSON.parse(readFileSync(KNOWLEDGE_PATH,"utf8"));if(knowledge.lineage!==`${ORG_SLUG}/${BUNDLE_NAME}`){console.error(`[register-components] ${KNOWLEDGE_PATH} is for ${knowledge.lineage}, not ${ORG_SLUG}/${BUNDLE_NAME}. Rebuild.`);process.exit(1)}{const{error:clearError}=await supabase.from("component_knowledge").delete().eq("bundle_id",BUNDLE_ID);if(clearError){console.error("[register-components] Failed to clear this bundle's knowledge:",clearError);process.exit(1)}const{data:inserted,error:insertError}=await supabase.from("component_knowledge").insert(knowledge.rows.map(r=>({bundle_id:BUNDLE_ID,kind:r.kind,key:r.key,title:r.title,summary:r.summary,body:r.body??null,tags:r.tags??[],snippets:r.snippets??[],attributes:r.attributes??{},page_cell:r.page_cell??true,sort:r.sort??0}))).select("id, kind, key");if(insertError){console.error("[register-components] Failed to write knowledge rows:",insertError);process.exit(1)}const idOf=new Map(inserted.map(r=>[`${r.kind}:${r.key}`,r.id]));const linkRows=knowledge.links.map(l=>({from_id:idOf.get(`${l.from.kind}:${l.from.key}`),relation:l.relation,to_lineage:l.to.lineage??null,to_kind:l.to.kind,to_key:l.to.key,note:l.note??null}));if(linkRows.some(l=>!l.from_id)){console.error("[register-components] a knowledge link starts at a row that was not written. Rebuild.");process.exit(1)}if(linkRows.length>0){const{error:linkError}=await supabase.from("component_knowledge_link").insert(linkRows);if(linkError){console.error("[register-components] Failed to write knowledge links:",linkError);process.exit(1)}}console.log(`[register-components] knowledge: ${inserted.length} rows, ${linkRows.length} links for ${knowledge.lineage}`)}const{error:bundleUpdateError}=await supabase.from("component_bundle").update({status:"registered",registered_at:new Date().toISOString(),exports:specs.map(s=>s.component_name),manifest:{exports:specs.map(s=>({component_name:s.component_name,exposed_export:s.exposed_export,file_path:s.file_path}))}}).eq("id",BUNDLE_ID);if(bundleUpdateError){console.error(`[register-components] Failed to mark bundle 'registered':`,bundleUpdateError);process.exit(1)}console.log(`[register-components] Done. bundle ${BUNDLE_ID} is now status='registered'.
|
|
4
4
|
Promote it with the engine (the host has no cutover of its own):
|
|
5
5
|
engine cutover --version ${VERSION}${BUNDLE_NAME===MAIN_LINEAGE?"":` --bundle ${BUNDLE_NAME}`}${target==="local"?"":` --${target}`}`);function canonical(value){return JSON.stringify(sortKeys(value))}function sortKeys(value){if(Array.isArray(value))return value.map(sortKeys);if(value&&typeof value==="object"){const sorted={};for(const key of Object.keys(value).sort())sorted[key]=sortKeys(value[key]);return sorted}return value}
|