@peripheries/engine 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peripheries/engine",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "description": "Build, check, knowledge and deploy for peripheries component repos",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import{existsSync,readdirSync,readFileSync,statSync}from"node:fs";import{join,relative,resolve}from"node:path";import{fileURLToPath}from"node:url";import{budgetVerdict,loadConfig}from"../lib/config.mjs";import{REPO_ROOT}from"../lib/root.mjs";const SKIP=/(^|\/)(node_modules|__output__|dist[^/]*)(\/|$)|\.(fixtures|test|stories)\.[jt]sx?$|\.d\.ts$/;function onlyCustomProperties(ts,expr){while(ts.isParenthesizedExpression(expr)||ts.isAsExpression(expr)||ts.isSatisfiesExpression?.(expr))expr=expr.expression;if(ts.isCallExpression(expr)&&ts.isIdentifier(expr.expression)&&expr.expression.text==="vars")return true;if(ts.isIdentifier(expr)&&expr.text==="style"||ts.isPropertyAccessExpression(expr)&&expr.name.text==="style")return true;if(!ts.isObjectLiteralExpression(expr)||!expr.properties.length)return false;return expr.properties.every(p=>{if(ts.isSpreadAssignment(p))return onlyCustomProperties(ts,p.expression);const name=p.name&&(ts.isStringLiteral(p.name)||ts.isNoSubstitutionTemplateLiteral(p.name)?p.name.text:null);return typeof name==="string"&&name.startsWith("--")})}function inlineStyles(ts,code,fileName="x.tsx"){const sf=ts.createSourceFile(fileName,code,ts.ScriptTarget.Latest,true,ts.ScriptKind.TSX);const found=[];const visit=node=>{if(ts.isJsxAttribute(node)&&node.name.getText(sf)==="style"&&node.initializer&&ts.isJsxExpression(node.initializer)&&node.initializer.expression){if(!onlyCustomProperties(ts,node.initializer.expression)){const{line}=sf.getLineAndCharacterOfPosition(node.getStart(sf));found.push(`${line+1}: ${node.getText(sf).replace(/\s+/g," ").slice(0,80)}`)}}ts.forEachChild(node,visit)};visit(sf);return found}async function main(){const strict=process.argv.includes("--strict");const ts=(await import("typescript")).default;const walk=p=>{if(!existsSync(p)||SKIP.test(p))return[];return statSync(p).isDirectory()?readdirSync(p).flatMap(n=>walk(join(p,n))):[p]};const files=walk(resolve(REPO_ROOT,"src")).filter(f=>f.endsWith(".tsx"));const findings=files.flatMap(f=>inlineStyles(ts,readFileSync(f,"utf8"),f).map(x=>`${relative(REPO_ROOT,f)}:${x}`));const shown=strict||process.argv.includes("--all")?findings:findings.slice(0,20);for(const f of shown)console[strict?"error":"warn"](`[inline-styles] ${f}`);if(shown.length<findings.length)console.warn(`[inline-styles] \u2026 ${findings.length-shown.length} more (--all lists them)`);console.log(`[inline-styles] ${files.length} file(s), ${findings.length} inline style(s) beyond custom properties.`);const{checks}=await loadConfig();const budget=budgetVerdict("check-inline-styles",findings.length,checks.budgets["check-inline-styles"]);if(budget.message)console[budget.ok?"log":"error"](`[inline-styles] ${budget.message}`);return strict&&findings.length||!budget.ok?1:0}if(process.argv[1]&&resolve(process.argv[1])===fileURLToPath(import.meta.url)){process.exit(await main())}export{inlineStyles};
@@ -1,3 +1,3 @@
1
1
  #!/usr/bin/env tsx
2
- import ts from"typescript";import{resolve}from"node:path";import{readFileSync}from"node:fs";import{readExportsFromIndex,resolveComponentFilePath,hasComponentJSDoc,parseJSDocFile}from"../lib/jsdoc-parser.mjs";import{REPO_ROOT as REPO_ROOT_}from"../lib/root.mjs";const repoRoot=REPO_ROOT_;const BARRELS=["src/index.ts","src/index.block-plans.ts","src/index.workbooks.ts"];const BLOCKING_FAMILIES=new Set(["core/document"]);const BLOCKING_NAMES=new Set(["Stat","StatGroup","KpiCardGrid","StatStrip","HighlightBox","DataTable","SectionHeader","DisclaimerBlock","MilestoneTimeline","ReportHeadlineStats","LineChart","AreaChart","BarChart","BarLineChart","Sparkline","PieChart","DonutChart","ScatterChart","MetricChart","WaterfallChart"]);const BLOCKING_FLOOR=45;const INTERNAL_PROPS=new Map([["profile","DocumentProfile"]]);const PROPLESS=new Set(["PageBreak"]);const HOST_INJECTED=new Set(["baseProps","data","status","actions","isLoading","error","className","style","key","ref"]);const showAll=process.argv.includes("--all");const configPath=resolve(repoRoot,"tsconfig.json");const config=ts.readConfigFile(configPath,ts.sys.readFile);const parsed=ts.parseJsonConfigFileContent(config.config,ts.sys,repoRoot);const program=ts.createProgram({rootNames:BARRELS.map(b=>resolve(repoRoot,b)),options:parsed.options});const checker=program.getTypeChecker();function readBlock(filePath,name){const content=readFileSync(filePath,"utf8");const block=(content.match(/\/\*\*[\s\S]*?\*\//g)??[]).find(b=>new RegExp(`@component\\s+${name}\\b`).test(b));const declared=new Set;if(!block)return{declared,exampleTags:0};for(const m of block.matchAll(/@props\s+\{[^}]+\}\s+(?:\[([^=\]]+)(?:=[^\]]+)?\]|(\w+(?:\.\w+)*))\s*-/g)){declared.add((m[1]??m[2]??"").trim().split(".")[0]??"")}return{declared,exampleTags:(block.match(/^\s*\*?\s*@example\b/gm)??[]).length}}function propsTypeOf(symbol,at){const target=symbol.flags&ts.SymbolFlags.Alias?checker.getAliasedSymbol(symbol):symbol;const type=checker.getTypeOfSymbolAtLocation(target,at);const sig=type.getCallSignatures()[0];const param=sig?.getParameters()[0];if(!param)return null;return checker.getTypeOfSymbolAtLocation(param,at)}function ownProps(type){const out=new Set;const members=type.isUnion()?type.types:[type];for(const member of members){for(const prop of checker.getPropertiesOfType(member)){const decls=prop.getDeclarations()??[];if(decls.length===0)continue;if(decls.every(d=>d.getSourceFile().fileName.includes("/node_modules/")))continue;if(HOST_INJECTED.has(prop.name))continue;const internalType=INTERNAL_PROPS.get(prop.name);if(internalType&&checker.typeToString(checker.getNonNullableType(checker.getTypeOfSymbol(prop)))===internalType)continue;out.add(prop.name)}}return out}function elementOf(type){const t=checker.getNonNullableType(type);if(checker.isArrayType(t))return checker.getTypeArguments(t)[0]??null;return null}function shapeFindings(schema,type,path,out){const items=schema?.type==="array"?schema.items:void 0;const element=elementOf(type);if(!items?.properties||!element)return;const fields=new Map(checker.getPropertiesOfType(checker.getNonNullableType(element)).map(p=>[p.name,p]));for(const name of fields.keys())if(!(name in items.properties)&&!HOST_INJECTED.has(name))out.push({code:"SHAPE_UNDECLARED",prop:`${path}[].${name}`});for(const[name,sub]of Object.entries(items.properties)){const field=fields.get(name);if(!field){if(items.additionalProperties!==true)out.push({code:"SHAPE_PHANTOM",prop:`${path}[].${name}`});continue}shapeFindings(sub,checker.getTypeOfSymbol(field),`${path}[].${name}`,out)}}const results=[];const seen=new Set;for(const barrel of BARRELS){const barrelFile=program.getSourceFile(resolve(repoRoot,barrel));if(!barrelFile)continue;const moduleSymbol=checker.getSymbolAtLocation(barrelFile);if(!moduleSymbol)continue;const exportsByName=new Map(checker.getExportsOfModule(moduleSymbol).map(s=>[s.name,s]));for(const{name,source}of readExportsFromIndex(repoRoot,barrel)){if(seen.has(name))continue;const filePath=resolveComponentFilePath(repoRoot,source.replace(/^\.\//,""));if(!filePath||!hasComponentJSDoc(filePath,name))continue;seen.add(name);const jsdoc=parseJSDocFile(filePath,name);const family=jsdoc.family??jsdoc.component_type??"?";const blocking=BLOCKING_FAMILIES.has(family)||BLOCKING_NAMES.has(name);const file=filePath.slice(repoRoot.length+1);const symbol=exportsByName.get(name);const type=symbol?propsTypeOf(symbol,barrelFile):null;if(!type){results.push({name,file,family,blocking,own:0,findings:[{code:"UNRESOLVED"}]});continue}const own=ownProps(type);const{declared,exampleTags}=readBlock(filePath,name);const examples=jsdoc.usage_examples??[];const findings=[];if(examples.length<exampleTags)findings.push({code:"EXAMPLE_UNPARSEABLE",prop:`${exampleTags-examples.length} of ${exampleTags}`});for(const key of new Set(examples.flatMap(e=>Object.keys(e)))){if(!own.has(key)&&!declared.has(key))findings.push({code:"EXAMPLE_UNKNOWN_PROP",prop:key})}if(blocking&&!jsdoc.agent_instructions)findings.push({code:"NO_INSTRUCTIONS"});if(blocking&&exampleTags===0)findings.push({code:"NO_EXAMPLE"});for(const p of own)if(!declared.has(p))findings.push({code:"UNDECLARED",prop:p});for(const p of declared)if(!own.has(p)&&!HOST_INJECTED.has(p))findings.push({code:"PHANTOM",prop:p});for(const[prop,schema]of Object.entries(jsdoc.props_schema?.properties??{})){const member=checker.getPropertyOfType(type,prop);if(member)shapeFindings(schema,checker.getTypeOfSymbol(member),prop,findings)}results.push({name,file,family,blocking,own:own.size,findings})}}const blockingSet=results.filter(r=>r.blocking);const failures=blockingSet.filter(r=>r.findings.length>0||r.own===0&&!PROPLESS.has(r.name));const reported=results.filter(r=>!r.blocking&&r.findings.length>0);function print(r){const detail=r.own===0&&r.findings.length===0?"no own props resolved (a component with none is a broken check, not a clean one)":r.findings.map(f=>f.prop?`${f.code} ${f.prop}`:f.code).join(", ");console.log(` ${r.name} (${r.family}) ${r.file}
3
- ${detail}`)}if(failures.length){console.log(`[props-schema] \u2717 ${failures.length} blocking component(s) whose @props disagree with their props type:`);failures.forEach(print)}if(showAll&&reported.length){console.log(`[props-schema] non-blocking (${reported.length}):`);reported.forEach(print)}const missingNames=[...BLOCKING_NAMES].filter(n=>!results.some(r=>r.name===n));if(missingNames.length){console.log(`[props-schema] \u2717 BLOCKING_NAMES not found in any barrel: ${missingNames.join(", ")}`)}if(blockingSet.length<BLOCKING_FLOOR){console.log(`[props-schema] \u2717 only ${blockingSet.length} blocking component(s) checked; floor is ${BLOCKING_FLOOR}. The resolver or the scope broke.`)}const failed=failures.length>0||missingNames.length>0||blockingSet.length<BLOCKING_FLOOR;console.log(`[props-schema] ${failed?"\u2717":"ok"} \u2014 ${blockingSet.length} blocking checked, ${failures.length} failing; ${reported.length} of ${results.length-blockingSet.length} others disagree (reported${showAll?"":", --all to list"}).`);process.exit(failed?1:0);
2
+ import ts from"typescript";import{resolve}from"node:path";import{readFileSync}from"node:fs";import{existsSync}from"node:fs";import{loadConfig}from"../lib/config.mjs";import{readExportsFromIndex,resolveComponentFilePath,hasComponentJSDoc,parseJSDocFile}from"../lib/jsdoc-parser.mjs";import{kitFamilies}from"../lib/knowledge.mjs";import{REPO_ROOT as REPO_ROOT_}from"../lib/root.mjs";const repoRoot=REPO_ROOT_;const engineConfig=await loadConfig();const BARRELS=engineConfig.lineages.map(l=>l.entry).filter(b=>existsSync(resolve(repoRoot,b)));const{propsSchema}=engineConfig.checks;const BLOCKING_FAMILIES=new Set(propsSchema.families??kitFamilies());const BLOCKING_NAMES=new Set(propsSchema.names);const BLOCKING_FLOOR=propsSchema.floor;const GATED=BLOCKING_FAMILIES.size>0||BLOCKING_NAMES.size>0;const INTERNAL_PROPS=new Map([["profile","DocumentProfile"]]);const PROPLESS=new Set(propsSchema.propless);const HOST_INJECTED=new Set(["baseProps","data","status","actions","isLoading","error","className","style","key","ref"]);const showAll=process.argv.includes("--all");const configPath=resolve(repoRoot,"tsconfig.json");const config=ts.readConfigFile(configPath,ts.sys.readFile);const parsed=ts.parseJsonConfigFileContent(config.config,ts.sys,repoRoot);const program=ts.createProgram({rootNames:BARRELS.map(b=>resolve(repoRoot,b)),options:parsed.options});const checker=program.getTypeChecker();function readBlock(filePath,name){const content=readFileSync(filePath,"utf8");const block=(content.match(/\/\*\*[\s\S]*?\*\//g)??[]).find(b=>new RegExp(`@component\\s+${name}\\b`).test(b));const declared=new Set;if(!block)return{declared,exampleTags:0};for(const m of block.matchAll(/@props\s+\{[^}]+\}\s+(?:\[([^=\]]+)(?:=[^\]]+)?\]|(\w+(?:\.\w+)*))\s*-/g)){declared.add((m[1]??m[2]??"").trim().split(".")[0]??"")}return{declared,exampleTags:(block.match(/^\s*\*?\s*@example\b/gm)??[]).length}}function propsTypeOf(symbol,at){const target=symbol.flags&ts.SymbolFlags.Alias?checker.getAliasedSymbol(symbol):symbol;const type=checker.getTypeOfSymbolAtLocation(target,at);const sig=type.getCallSignatures()[0];const param=sig?.getParameters()[0];if(!param)return null;return checker.getTypeOfSymbolAtLocation(param,at)}function ownProps(type){const out=new Set;const members=type.isUnion()?type.types:[type];for(const member of members){for(const prop of checker.getPropertiesOfType(member)){const decls=prop.getDeclarations()??[];if(decls.length===0)continue;if(decls.every(d=>d.getSourceFile().fileName.includes("/node_modules/")))continue;if(HOST_INJECTED.has(prop.name))continue;const internalType=INTERNAL_PROPS.get(prop.name);if(internalType&&checker.typeToString(checker.getNonNullableType(checker.getTypeOfSymbol(prop)))===internalType)continue;out.add(prop.name)}}return out}function elementOf(type){const t=checker.getNonNullableType(type);if(checker.isArrayType(t))return checker.getTypeArguments(t)[0]??null;return null}function shapeFindings(schema,type,path,out){const items=schema?.type==="array"?schema.items:void 0;const element=elementOf(type);if(!items?.properties||!element)return;const fields=new Map(checker.getPropertiesOfType(checker.getNonNullableType(element)).map(p=>[p.name,p]));for(const name of fields.keys())if(!(name in items.properties)&&!HOST_INJECTED.has(name))out.push({code:"SHAPE_UNDECLARED",prop:`${path}[].${name}`});for(const[name,sub]of Object.entries(items.properties)){const field=fields.get(name);if(!field){if(items.additionalProperties!==true)out.push({code:"SHAPE_PHANTOM",prop:`${path}[].${name}`});continue}shapeFindings(sub,checker.getTypeOfSymbol(field),`${path}[].${name}`,out)}}const results=[];const seen=new Set;for(const barrel of BARRELS){const barrelFile=program.getSourceFile(resolve(repoRoot,barrel));if(!barrelFile)continue;const moduleSymbol=checker.getSymbolAtLocation(barrelFile);if(!moduleSymbol)continue;const exportsByName=new Map(checker.getExportsOfModule(moduleSymbol).map(s=>[s.name,s]));for(const{name,source}of readExportsFromIndex(repoRoot,barrel)){if(seen.has(name))continue;const filePath=resolveComponentFilePath(repoRoot,source.replace(/^\.\//,""));if(!filePath||!hasComponentJSDoc(filePath,name))continue;seen.add(name);const jsdoc=parseJSDocFile(filePath,name);const family=jsdoc.family??jsdoc.component_type??"?";const blocking=BLOCKING_FAMILIES.has(family)||BLOCKING_NAMES.has(name);const file=filePath.slice(repoRoot.length+1);const symbol=exportsByName.get(name);const type=symbol?propsTypeOf(symbol,barrelFile):null;if(!type){results.push({name,file,family,blocking,own:0,findings:[{code:"UNRESOLVED"}]});continue}const own=ownProps(type);const{declared,exampleTags}=readBlock(filePath,name);const examples=jsdoc.usage_examples??[];const findings=[];if(examples.length<exampleTags)findings.push({code:"EXAMPLE_UNPARSEABLE",prop:`${exampleTags-examples.length} of ${exampleTags}`});for(const key of new Set(examples.flatMap(e=>Object.keys(e)))){if(!own.has(key)&&!declared.has(key))findings.push({code:"EXAMPLE_UNKNOWN_PROP",prop:key})}if(blocking&&!jsdoc.agent_instructions)findings.push({code:"NO_INSTRUCTIONS"});if(blocking&&exampleTags===0)findings.push({code:"NO_EXAMPLE"});for(const p of own)if(!declared.has(p))findings.push({code:"UNDECLARED",prop:p});for(const p of declared)if(!own.has(p)&&!HOST_INJECTED.has(p))findings.push({code:"PHANTOM",prop:p});for(const[prop,schema]of Object.entries(jsdoc.props_schema?.properties??{})){const member=checker.getPropertyOfType(type,prop);if(member)shapeFindings(schema,checker.getTypeOfSymbol(member),prop,findings)}results.push({name,file,family,blocking,own:own.size,findings})}}const blockingSet=results.filter(r=>r.blocking);const failures=blockingSet.filter(r=>r.findings.length>0||r.own===0&&!PROPLESS.has(r.name));const reported=results.filter(r=>!r.blocking&&r.findings.length>0);function print(r){const detail=r.own===0&&r.findings.length===0?"no own props resolved (a component with none is a broken check, not a clean one)":r.findings.map(f=>f.prop?`${f.code} ${f.prop}`:f.code).join(", ");console.log(` ${r.name} (${r.family}) ${r.file}
3
+ ${detail}`)}if(failures.length){console.log(`[props-schema] \u2717 ${failures.length} blocking component(s) whose @props disagree with their props type:`);failures.forEach(print)}if(showAll&&reported.length){console.log(`[props-schema] non-blocking (${reported.length}):`);reported.forEach(print)}const missingNames=[...BLOCKING_NAMES].filter(n=>!results.some(r=>r.name===n));if(missingNames.length){console.log(`[props-schema] \u2717 checks.propsSchema.names not found in any barrel: ${missingNames.join(", ")}`)}const underFloor=GATED&&blockingSet.length<BLOCKING_FLOOR;if(underFloor){console.log(`[props-schema] \u2717 only ${blockingSet.length} blocking component(s) checked; floor is ${BLOCKING_FLOOR}. The resolver or the scope broke.`)}const scope=propsSchema.families?"checks.propsSchema":"the kits";const blockingBy=GATED?`blocking: ${[...BLOCKING_FAMILIES].join(", ")||"(no families)"} from ${scope}${BLOCKING_NAMES.size?` + ${BLOCKING_NAMES.size} name(s)`:""}`:"nothing blocks (no kit families, no checks.propsSchema)";const failed=failures.length>0||missingNames.length>0||underFloor;console.log(`[props-schema] ${failed?"\u2717":"ok"} \u2014 ${blockingSet.length} blocking checked, ${failures.length} failing (${blockingBy}); ${reported.length} of ${results.length-blockingSet.length} others disagree (reported${showAll?"":", --all to list"}).`);process.exit(failed?1:0);
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import{existsSync}from"node:fs";import{resolve}from"node:path";import{fileURLToPath}from"node:url";import{loadConfig}from"../lib/config.mjs";import{REPO_ROOT}from"../lib/root.mjs";import{loadSharedLineages,unresolvedMessage}from"../lib/shared-lineages.mjs";function shadowFindings(lineageExports,sharedExports,shadows={}){const findings=[];const colliding=new Set;for(const[lineage,names]of lineageExports){for(const name of names){const owner=[...sharedExports].find(([,shared])=>shared.has(name))?.[0];if(!owner)continue;colliding.add(name);if(!(name in shadows))findings.push(`lineage "${lineage}" exports ${name}, which ${owner} also exports: declare it in shadows with a reason, or rename it`);else if(typeof shadows[name]!=="string"||!shadows[name].trim())findings.push(`shadows.${name} needs a reason`)}}for(const name of Object.keys(shadows)){if(!colliding.has(name))findings.push(`shadows.${name} is declared, but no lineage exports ${name} over a shared lineage: remove it`)}return findings}async function main(){const config=await loadConfig();const{lineages,shadows}=config;const own=lineages.filter(l=>l.kind!=="shared");const entries=own.map(l=>resolve(REPO_ROOT,l.entry)).filter(f=>existsSync(f));const shared=own.length?await loadSharedLineages(config,{roots:entries}):null;if(!shared?.specifiers.length){console.log("[shadowing] no org lineage imports a shared lineage; nothing to check.");return 0}if(shared.unresolved.length){for(const spec of shared.unresolved)console.error(`[shadowing] ${shared.error??unresolvedMessage(spec)}`);return 1}const sharedExports=new Map(shared.lineages.map(l=>[l.specifier,l.names]));const lineageExports=new Map(own.map(l=>[l.name,shared.valuesOf(resolve(REPO_ROOT,l.entry))]));const findings=shadowFindings(lineageExports,sharedExports,shadows);for(const f of findings)console.error(`[shadowing] ${f}`);const declared=Object.keys(shadows).length;console.log(`[shadowing] ${own.length} lineage(s) against ${shared.specifiers.join(", ")} (${[...sharedExports.values()].reduce((n,s)=>n+s.size,0)} shared names); ${declared} declared shadow(s), ${findings.length} finding(s).`);return findings.length?1:0}if(process.argv[1]&&resolve(process.argv[1])===fileURLToPath(import.meta.url)){process.exit(await main())}export{shadowFindings};
@@ -1,7 +1,7 @@
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{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 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=[];let routeCount=0;let templateCount=0;let orgCount=0;let componentRefCount=0;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)){problems.push(`${where} component "${name}" is not exported from any lineage barrel, 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??{};const routes=layout.whitelisted_routes;if(!Array.isArray(routes)||routes.length===0){problems.push(`${rel}: layout.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=layout.whitelisted_routes;if(!Array.isArray(routes)||routes.length===0){problems.push(`${rel}: portal_layout.whitelisted_routes is missing or empty \u2014 no portal page is reachable.`);continue}const seenPaths=new Map;const routeByPath=new Map;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)}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;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])}for(const[where,path]of navPaths){if(!resolvesToRoute(path)){problems.push(`${rel}: ${where} is "${path}" but no whitelisted_route matches it \u2014 the nav entry renders and then resolves to nothing.`)}}for(const[i,item]of(layout.sidebar?.navigation??[]).entries()){if(!item?.path||!item?.component)continue;const route=routeByPath.get(item.path);if(route&&route.component&&route.component!==item.component){problems.push(`${rel}: sidebar.navigation[${i}] claims component "${item.component}" for "${item.path}" but the whitelisted route declares "${route.component}" \u2014 nav and route disagree.`)}}const chrome=[["base_route",layout.base_route?.component],["app_bar",layout.app_bar?.component],["tab_bar",layout.tab_bar?.component],["more_menu",layout.more_menu?.component],["chat_rail",layout.chat_rail?.component],["right_sidebar",layout.right_sidebar?.component],["sidebar",layout.sidebar?.component],["assistant_overlay.component",layout.assistant_overlay?.component],["assistant_overlay.pill",layout.assistant_overlay?.pill]];for(const[where,name]of chrome){if(name===void 0||name===null)continue;checkComponentName(`${rel}: portal_layout.${where}`,name,problems)}if(layout.right_sidebar){checkDeclarations(`${rel}: portal_layout.right_sidebar`,layout.right_sidebar,problems)}}if(exportSkipReason){console.warn(`[check-template-routes] component-name checks SKIPPED \u2014 ${exportSkipReason}.
2
+ import{existsSync,readFileSync,readdirSync}from"node:fs";import{dirname,join,relative}from"node:path";import{fileURLToPath}from"node:url";import{loadConfig}from"../lib/config.mjs";import{loadSharedLineages,unresolvedMessage}from"../lib/shared-lineages.mjs";import{REPO_ROOT as REPO_ROOT_}from"../lib/root.mjs";const ENGINE_CONFIG=await loadConfig();const GATED_TEMPLATES=new Set(ENGINE_CONFIG.checks.gated.projectTemplates);const GATED_ORGS=new Set(ENGINE_CONFIG.checks.gated.orgTemplates);const REQUIRED_PREFIX="/portal/project/{projectId}";const ALLOWED_PATH_PARAMS=new Set(["projectId"]);const HOST_PROVIDED_PARAMS=new Set(["projectId","clientId","userId","orgId","organizationId","advisorOrgId","pageId","pageType","processId"]);const repoRoot=REPO_ROOT_;const templatesRoot=join(repoRoot,"src","project_templates");const orgsRoot=join(repoRoot,"src","org_templates");const args=process.argv.slice(2);const gateAll=args.includes("--all-gated");const warnOnly=args.includes("--warn-only");const only=args.find(a=>!a.startsWith("--"));let exportedNames=null;let hasComponentJSDoc=null;let resolveComponentFilePath=null;let exportSkipReason=null;try{const parser=await import("../lib/jsdoc-parser.mjs");if(typeof parser.readExportsFromIndex!=="function"){exportSkipReason="jsdoc-parser.mjs exports no readExportsFromIndex"}else{exportedNames=new Map(ENGINE_CONFIG.lineages.flatMap(l=>existsSync(join(repoRoot,l.entry))?parser.readExportsFromIndex(repoRoot,l.entry).map(e=>[e.name,e.source]):[]));hasComponentJSDoc=typeof parser.hasComponentJSDoc==="function"?parser.hasComponentJSDoc:null;resolveComponentFilePath=typeof parser.resolveComponentFilePath==="function"?parser.resolveComponentFilePath:null}}catch(err){exportSkipReason=`could not load the engine's jsdoc parser \u2014 ${err.message}`}const SHARED=await loadSharedLineages(ENGINE_CONFIG);const sharedNames=SHARED.names;const sharedLabel=SHARED.specifiers.length?` or shared lineage (${SHARED.specifiers.join(", ")})`:"";let sharedRefCount=0;const GENERATED_CONTRACT=join(repoRoot,"src","types","host-contract.generated.ts");function generatedContractKeys(source,interfaceName){const open=source.indexOf(`export interface ${interfaceName} {`);if(open===-1)return null;const keys=new Set;let depth=0;for(let i=source.indexOf("{",open);i<source.length;i++){const ch=source[i];if(ch==="{"){depth+=1;continue}if(ch==="}"){depth-=1;if(depth===0)break;continue}if(depth===1&&ch==='"'){const end=source.indexOf('"',i+1);if(end===-1)break;const key=source.slice(i+1,end);if(source.slice(end+1,end+2)===":")keys.add(key);i=end}}return keys}let contractQueries=null;let contractMutations=null;let 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 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??{};const routes=layout.whitelisted_routes;if(!Array.isArray(routes)||routes.length===0){problems.push(`${rel}: layout.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=layout.whitelisted_routes;if(!Array.isArray(routes)||routes.length===0){problems.push(`${rel}: portal_layout.whitelisted_routes is missing or empty \u2014 no portal page is reachable.`);continue}const seenPaths=new Map;const routeByPath=new Map;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)}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;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])}for(const[where,path]of navPaths){if(!resolvesToRoute(path)){problems.push(`${rel}: ${where} is "${path}" but no whitelisted_route matches it \u2014 the nav entry renders and then resolves to nothing.`)}}for(const[i,item]of(layout.sidebar?.navigation??[]).entries()){if(!item?.path||!item?.component)continue;const route=routeByPath.get(item.path);if(route&&route.component&&route.component!==item.component){problems.push(`${rel}: sidebar.navigation[${i}] claims component "${item.component}" for "${item.path}" but the whitelisted route declares "${route.component}" \u2014 nav and route disagree.`)}}const chrome=[["base_route",layout.base_route?.component],["app_bar",layout.app_bar?.component],["tab_bar",layout.tab_bar?.component],["more_menu",layout.more_menu?.component],["chat_rail",layout.chat_rail?.component],["right_sidebar",layout.right_sidebar?.component],["sidebar",layout.sidebar?.component],["assistant_overlay.component",layout.assistant_overlay?.component],["assistant_overlay.pill",layout.assistant_overlay?.pill]];for(const[where,name]of chrome){if(name===void 0||name===null)continue;checkComponentName(`${rel}: portal_layout.${where}`,name,problems)}if(layout.right_sidebar){checkDeclarations(`${rel}: portal_layout.right_sidebar`,layout.right_sidebar,problems)}}if(exportSkipReason){console.warn(`[check-template-routes] component-name checks SKIPPED \u2014 ${exportSkipReason}.
3
3
  `)}if(contractSkipReason){console.warn(`[check-template-routes] registry-id checks SKIPPED \u2014 ${contractSkipReason}.
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):
7
- `);for(const e of errors)console.error(` \u2022 ${e}`);console.error("")}console.log(`[check-template-routes] ${errors.length===0?"OK":`FAIL (${errors.length} error(s))`} \u2014 walked ${routeCount} route(s) and ${componentRefCount} component ref(s) across ${templateCount} project template(s) + ${orgCount} org template(s); ${warnings.length} warning(s); gated: ${gateAll?"all":[...GATED_TEMPLATES,...GATED_ORGS].join(", ")||"(none)"}.`);if(routeCount===0){console.error(`[check-template-routes] no routes found${only?` for "${only}"`:""} \u2014 nothing was validated.`);process.exit(1)}if(errors.length>0){if(!warnOnly)process.exit(1);console.warn("[check-template-routes] --warn-only: exiting 0 despite the errors above.")}
7
+ `);for(const e of errors)console.error(` \u2022 ${e}`);console.error("")}console.log(`[check-template-routes] ${errors.length===0?"OK":`FAIL (${errors.length} error(s))`} \u2014 walked ${routeCount} route(s) and ${componentRefCount} component ref(s) (${sharedRefCount} from shared lineages) across ${templateCount} project template(s) + ${orgCount} org template(s); ${warnings.length} warning(s); gated: ${gateAll?"all":[...GATED_TEMPLATES,...GATED_ORGS].join(", ")||"(none)"}.`);if(routeCount===0){console.error(`[check-template-routes] no routes found${only?` for "${only}"`:""} \u2014 nothing was validated.`);process.exit(1)}if(errors.length>0){if(!warnOnly)process.exit(1);console.warn("[check-template-routes] --warn-only: exiting 0 despite the errors above.")}
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import{existsSync,readdirSync,readFileSync,statSync}from"node:fs";import{createRequire}from"node:module";import{join,relative,resolve}from"node:path";import{fileURLToPath,pathToFileURL}from"node:url";import{REPO_ROOT}from"../lib/root.mjs";const ROOTS=["src/org_templates","src/project_templates"];function themesIn(value,path=""){if(!value||typeof value!=="object")return[];if(Array.isArray(value))return value.flatMap((v,i)=>themesIn(v,`${path}[${i}]`));return Object.entries(value).flatMap(([k,v])=>{const here=path?`${path}.${k}`:k;if(k==="theme"&&v&&typeof v==="object"&&!Array.isArray(v))return[{path:here,theme:v}];return themesIn(v,here)})}function failing(raw,diagnostics,strict){if(strict)return diagnostics;if(raw.version!==2)return[];return diagnostics.filter(d=>d.severity==="error"||d.code==="THEME_CONTRAST")}async function main(){const strict=process.argv.includes("--strict");let api;try{const entry=createRequire(resolve(REPO_ROOT,"package.json")).resolve("@peripheries/renderer/theme");api=await import(pathToFileURL(entry).href)}catch{console.log("[theme] @peripheries/renderer/theme not found (renderer 0.7 or later has it); nothing checked.");return 0}const walk=p=>statSync(p).isDirectory()?readdirSync(p).flatMap(n=>walk(join(p,n))):[p];const files=ROOTS.map(r=>resolve(REPO_ROOT,r)).filter(existsSync).flatMap(walk).filter(f=>f.endsWith("template.json"));let themes=0;let failed=0;for(const file of files){for(const{path,theme:raw}of themesIn(JSON.parse(readFileSync(file,"utf8")))){themes++;const where=`${relative(REPO_ROOT,file)} ${path}`;const{theme,diagnostics}=api.parseTheme(raw);const all=[...diagnostics,...api.checkTheme(theme)];const fails=new Set(failing(raw,all,strict));if(raw.version!==2)console.warn(`[theme] ${where}: version 1; move it to version 2 (design \xA76.1)`);for(const d of all)console[fails.has(d)?"error":"warn"](`[theme] ${where}: ${d.code} ${d.path}: ${d.message}`);failed+=fails.size}}console.log(`[theme] ${files.length} template(s), ${themes} theme(s), ${failed} failure(s).`);return failed?1:0}if(process.argv[1]&&resolve(process.argv[1])===fileURLToPath(import.meta.url)){process.exit(await main())}export{failing,themesIn};
@@ -1,2 +1,2 @@
1
1
  #!/usr/bin/env node
2
- import{existsSync,readdirSync,readFileSync,statSync}from"node:fs";import{join,relative,resolve}from"node:path";import{REPO_ROOT}from"../lib/root.mjs";const strict=process.argv.includes("--strict");const SKIP=/(^|\/)(node_modules|__output__|dist[^/]*)(\/|$)|\.(fixtures|test|stories)\.[jt]sx?$|\.d\.ts$/;const RULES=[[/var\(\s*--palette-[\w-]+/,"a primitive token (read a semantic one)"],[/var\(\s*--oa-[\w-]+/,"a legacy --oa-* name"],[/(?<![\w-])#[0-9a-fA-F]{3,8}\b(?![\w-])/,"a hex colour (use a token)"],[/\b(rgba?|hsla?|oklch|oklab)\(\s*\d/,"a literal colour (use a token)"]];const walk=p=>{if(!existsSync(p)||SKIP.test(p))return[];return statSync(p).isDirectory()?readdirSync(p).flatMap(n=>walk(join(p,n))):[p]};const files=walk(resolve(REPO_ROOT,"src")).filter(f=>/\.(css|tsx?|mts)$/.test(f));const findings=[];for(const file of files){const lines=readFileSync(file,"utf8").split("\n");if(lines.slice(0,15).some(l=>l.includes("check-tokens: defines tokens")))continue;const css=file.endsWith(".css");lines.forEach((line,i)=>{const trimmed=line.trim();if(trimmed.startsWith("//")||trimmed.startsWith("*")||trimmed.startsWith("/*"))return;for(const[re,what]of RULES){if(!css&&re.source.startsWith("(?<!")&&!/['"`][^'"`]*#[0-9a-fA-F]{3,8}\b/.test(line))continue;const m=line.match(re);if(m)findings.push(`${relative(REPO_ROOT,file)}:${i+1}: ${what}: ${m[0]}`)}})}const shown=strict||process.argv.includes("--all")?findings:findings.slice(0,20);for(const f of shown)console[strict?"error":"warn"](`[tokens] ${f}`);if(shown.length<findings.length)console.warn(`[tokens] \u2026 ${findings.length-shown.length} more (--all lists them)`);console.log(`[tokens] ${files.length} file(s), ${findings.length} finding(s)${findings.length&&!strict?" (warnings: --strict fails on them)":""}.`);process.exit(strict&&findings.length?1:0);
2
+ import{existsSync,readdirSync,readFileSync,statSync}from"node:fs";import{join,relative,resolve}from"node:path";import{budgetVerdict,loadConfig}from"../lib/config.mjs";import{REPO_ROOT}from"../lib/root.mjs";const strict=process.argv.includes("--strict");const SKIP=/(^|\/)(node_modules|__output__|dist[^/]*)(\/|$)|\.(fixtures|test|stories)\.[jt]sx?$|\.d\.ts$/;const RULES=[[/var\(\s*--palette-[\w-]+/,"a primitive token (read a semantic one)"],[/var\(\s*--oa-[\w-]+/,"a legacy --oa-* name"],[/(?<![\w-])#[0-9a-fA-F]{3,8}\b(?![\w-])/,"a hex colour (use a token)"],[/\b(rgba?|hsla?|oklch|oklab)\(\s*\d/,"a literal colour (use a token)"]];const walk=p=>{if(!existsSync(p)||SKIP.test(p))return[];return statSync(p).isDirectory()?readdirSync(p).flatMap(n=>walk(join(p,n))):[p]};const files=walk(resolve(REPO_ROOT,"src")).filter(f=>/\.(css|tsx?|mts)$/.test(f));const findings=[];for(const file of files){const lines=readFileSync(file,"utf8").split("\n");if(lines.slice(0,15).some(l=>l.includes("check-tokens: defines tokens")))continue;const css=file.endsWith(".css");lines.forEach((line,i)=>{const trimmed=line.trim();if(trimmed.startsWith("//")||trimmed.startsWith("*")||trimmed.startsWith("/*"))return;for(const[re,what]of RULES){if(!css&&re.source.startsWith("(?<!")&&!/['"`][^'"`]*#[0-9a-fA-F]{3,8}\b/.test(line))continue;const m=line.match(re);if(m)findings.push(`${relative(REPO_ROOT,file)}:${i+1}: ${what}: ${m[0]}`)}})}const shown=strict||process.argv.includes("--all")?findings:findings.slice(0,20);for(const f of shown)console[strict?"error":"warn"](`[tokens] ${f}`);if(shown.length<findings.length)console.warn(`[tokens] \u2026 ${findings.length-shown.length} more (--all lists them)`);console.log(`[tokens] ${files.length} file(s), ${findings.length} finding(s)${findings.length&&!strict?" (warnings: --strict fails on them)":""}.`);const{checks}=await loadConfig();const budget=budgetVerdict("check-tokens",findings.length,checks.budgets["check-tokens"]);if(budget.message)console[budget.ok?"log":"error"](`[tokens] ${budget.message}`);process.exit(strict&&findings.length||!budget.ok?1:0);
@@ -1,7 +1,7 @@
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{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 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=[];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)){problems.push(`${where} component "${name}" is not exported from any lineage barrel, 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.
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
3
  `)}if(e10SkipReason){console.warn(`[check-wired-bindings] E10 SKIPPED \u2014 ${e10SkipReason}. Registry ids unchecked.
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):
7
- `);for(const e of errors)console.error(` \u2022 ${e}`);console.error("")}console.log(`[check-wired-bindings] ${errors.length===0?"OK":`FAIL (${errors.length} error(s))`} \u2014 walked ${cellCount} cell(s) and ${tokenCount} @token(s) across ${fileCount} page.json (${documentPageCount} document-layout) (+${staleCount} stale sibling(s)); ${patternBCells} Pattern B cell(s); ${warnings.length} warning(s); gated: ${gateAll?"all":[...GATED_TEMPLATES].join(", ")||"(none)"}.`);if(fileCount===0){console.error(`[check-wired-bindings] no page.json found under ${relative(repoRoot,templatesRoot)}${only?` for template "${only}"`:""} \u2014 nothing was validated.`);process.exit(1)}if(errors.length>0){if(!warnOnly)process.exit(1);console.warn("[check-wired-bindings] --warn-only: exiting 0 despite the errors above.")}
7
+ `);for(const e of errors)console.error(` \u2022 ${e}`);console.error("")}console.log(`[check-wired-bindings] ${errors.length===0?"OK":`FAIL (${errors.length} error(s))`} \u2014 walked ${cellCount} cell(s) and ${tokenCount} @token(s) across ${fileCount} page.json (${documentPageCount} document-layout) (${sharedRefCount} component ref(s) from shared lineages) (+${staleCount} stale sibling(s)); ${patternBCells} Pattern B cell(s); ${warnings.length} warning(s); gated: ${gateAll?"all":[...GATED_TEMPLATES].join(", ")||"(none)"}.`);if(fileCount===0){console.error(`[check-wired-bindings] no page.json found under ${relative(repoRoot,templatesRoot)}${only?` for template "${only}"`:""} \u2014 nothing was validated.`);process.exit(1)}if(errors.length>0){if(!warnOnly)process.exit(1);console.warn("[check-wired-bindings] --warn-only: exiting 0 despite the errors above.")}
@@ -1,2 +1,2 @@
1
1
  #!/usr/bin/env tsx
2
- import{mkdirSync,writeFileSync}from"node:fs";import{dirname,resolve}from"node:path";import{checkCatalog}from"@peripheries/renderer/catalog";import{lineageConfig,loadConfig}from"../lib/config.mjs";import{buildKnowledge}from"../lib/knowledge.mjs";import{REPO_ROOT as REPO_ROOT_}from"../lib/root.mjs";const repoRoot=REPO_ROOT_;const argv=process.argv.slice(2);const flag=name=>{const i=argv.indexOf(`--${name}`);return i===-1?void 0:argv[i+1]};const bundle=flag("bundle")??"main";const checkOnly=argv.includes("--check");const outPath=resolve(repoRoot,flag("out")??`${(await lineageConfig(bundle)).dist}/knowledge.json`);const org=flag("org")??(await loadConfig()).org;const{lineage,rows,links,registry,scopeLineage,scopeRegistry}=await buildKnowledge(bundle,org);const kits=rows.filter(r=>r.kind==="kit");const patterns=rows.filter(r=>r.kind==="pattern");const diagnostics=checkCatalog({lineage,rows,links,registry,scopeRegistry,inScope:(target,kind,key)=>target===scopeLineage&&kind==="component"?Boolean(scopeRegistry?.[key]):void 0});for(const d of diagnostics)console[d.severity==="error"?"error":"warn"](`[knowledge] ${d.severity} ${d.code} ${d.path??""}: ${d.message}`);const errors=diagnostics.filter(d=>d.severity==="error").length;console.log(`[knowledge] ${lineage}: ${rows.length} rows (${rows.filter(r=>r.kind==="component").length} components, ${kits.length} kits, ${patterns.length} patterns), ${links.length} links; ${errors} error(s)`);if(errors>0&&!argv.includes("--allow-errors"))process.exit(1);if(!checkOnly){mkdirSync(dirname(outPath),{recursive:true});writeFileSync(outPath,JSON.stringify({schema:"peripheries.knowledge/1",lineage,rows,links},null,2)+"\n");console.log(`[knowledge] wrote ${outPath}`)}
2
+ import{mkdirSync,writeFileSync}from"node:fs";import{dirname,resolve}from"node:path";import{checkCatalog}from"@peripheries/renderer/catalog";import{lineageConfig,loadConfig}from"../lib/config.mjs";import{buildKnowledge}from"../lib/knowledge.mjs";import{REPO_ROOT as REPO_ROOT_}from"../lib/root.mjs";const repoRoot=REPO_ROOT_;const argv=process.argv.slice(2);const flag=name=>{const i=argv.indexOf(`--${name}`);return i===-1?void 0:argv[i+1]};const bundle=flag("bundle")??"main";const checkOnly=argv.includes("--check");const outPath=resolve(repoRoot,flag("out")??`${(await lineageConfig(bundle)).dist}/knowledge.json`);const org=flag("org")??(await loadConfig()).org;const{lineage,rows,links,registry,scopeLineage,scopeRegistry,shared}=await buildKnowledge(bundle,org);const kits=rows.filter(r=>r.kind==="kit");const patterns=rows.filter(r=>r.kind==="pattern");const cascade=Object.assign({},...[...shared].reverse().map(s=>s.registry),scopeRegistry??{});const diagnostics=checkCatalog({lineage,rows,links,registry,...Object.keys(cascade).length?{scopeRegistry:cascade}:{},inScope:(target,kind,key)=>{if(kind!=="component")return void 0;if(target===scopeLineage)return Boolean(scopeRegistry?.[key]);const scope=shared.find(s=>s.lineage===target);return scope?Boolean(scope.registry[key]):void 0}});for(const d of diagnostics)console[d.severity==="error"?"error":"warn"](`[knowledge] ${d.severity} ${d.code} ${d.path??""}: ${d.message}`);if(shared.length&&diagnostics.some(d=>d.code==="KNOWLEDGE_LINK_DANGLING"&&/ links to kit:/.test(d.message))){console.error(`[knowledge] a kit in a shared lineage is named with its lineage: "${shared[0].lineage}:<kit>" (in scope: ${shared.map(s=>s.lineage).join(", ")})`)}const errors=diagnostics.filter(d=>d.severity==="error").length;const across=links.filter(l=>l.to.lineage&&l.to.lineage!==lineage);console.log(`[knowledge] ${lineage}: ${rows.length} rows (${rows.filter(r=>r.kind==="component").length} components, ${kits.length} kits, ${patterns.length} patterns), ${links.length} links (${across.length} to other lineages${across.length?`: ${[...new Set(across.map(l=>l.to.lineage))].join(", ")}`:""}); ${errors} error(s)`);if(errors>0&&!argv.includes("--allow-errors"))process.exit(1);if(!checkOnly){mkdirSync(dirname(outPath),{recursive:true});writeFileSync(outPath,JSON.stringify({schema:"peripheries.knowledge/1",lineage,rows,links},null,2)+"\n");console.log(`[knowledge] wrote ${outPath}`)}
@@ -1,2 +1,2 @@
1
1
  #!/usr/bin/env node
2
- import{mkdir,rm,writeFile}from"node:fs/promises";import{createRequire}from"node:module";import{cpus}from"node:os";import{join,relative,resolve}from"node:path";import{pathToFileURL}from"node:url";import{loadConfig}from"../lib/config.mjs";import{parseFlagValue}from"../lib/env.mjs";import{REPO_ROOT}from"../lib/root.mjs";import{newDeterministicContext,prepare,settle}from"../harness/determinism.mjs";import{buildAndServeHarness,createHarnessServer}from"../harness/server.mjs";const argv=process.argv.slice(2);const{harness}=await loadConfig();const OUTPUT=resolve(REPO_ROOT,harness.output);const only=parseFlagValue(argv,"only");const[shardIndex,shardTotal]=(parseFlagValue(argv,"shard")??"1/1").split("/").map(Number);if(!(shardTotal>=1&&shardIndex>=1&&shardIndex<=shardTotal))throw new Error("[test-visual] --shard expects <i>/<n>");const fromRepo=createRequire(resolve(REPO_ROOT,"package.json"));const playwright=await import(pathToFileURL(fromRepo.resolve("playwright")).href);const chromium=playwright.chromium??playwright.default.chromium;const skipped=[];const{server}=await createHarnessServer();const served=await buildAndServeHarness();const origin=served.origin;const HARNESS_PATH=served.path;let exitCode=0;try{const list=await listTargets(server);const targets=list.filter(t=>(!only||t.label.startsWith(`${only} `))&&t.index%shardTotal===shardIndex-1);await rm(OUTPUT,{recursive:true,force:true});await mkdir(OUTPUT,{recursive:true});console.log(`[test-visual] ${targets.length} target(s) (of ${list.length}) \xD7 ${harness.widths.length} width(s) + a ${harness.container}px container${skipped.length?`; ${skipped.length} public-page scenario(s) skipped (R6)`:""}`);const browser=await chromium.launch();const concurrency=Math.max(1,Number(process.env.ENGINE_VISUAL_CONCURRENCY)||Math.min(6,Math.max(2,cpus().length-1)));const reports=new Array(targets.length);let next=0;await Promise.all(Array.from({length:Math.min(concurrency,targets.length)},async()=>{while(next<targets.length){const i=next++;const t=targets[i];const renders=[...harness.widths.map(w=>({width:w,container:null})),{width:Math.max(...harness.widths),container:harness.container}];const report={target:t.label,kind:t.kind,...t.designIntent?{designIntent:t.designIntent}:{},widths:[]};for(const r of renders)report.widths.push(await renderOnce(browser,t,r));reports[i]=report;const bad=report.widths.filter(w=>w.errors.length);const over=report.widths.filter(w=>w.overflow);console.log(` ${bad.length?"\u2717":over.length?"\u25B3":"\u2713"} ${t.label}${bad.length?` \u2014 ${bad[0].errors[0].split("\n")[0].slice(0,160)}`:""}${over.length?` (overflow at ${over.map(w=>w.container?`${w.container}px container`:`${w.width}px`).join(", ")})`:""}`)}}));await browser.close();const failed=reports.filter(r=>r.widths.some(w=>w.errors.length));const overflowing=reports.filter(r=>r.widths.some(w=>w.overflow));await writeFile(join(OUTPUT,"report.json"),JSON.stringify({widths:harness.widths,container:harness.container,reports,skipped},null,2)+"\n");console.log(`[test-visual] ${reports.length-failed.length}/${reports.length} rendered clean; ${overflowing.length} overflow; report \u2192 ${relative(REPO_ROOT,join(OUTPUT,"report.json"))}`);if(failed.length||harness.overflow==="error"&&overflowing.length)exitCode=1}finally{await server.close();await served.close()}process.exit(exitCode);async function listTargets(vite){const{readdir}=await import("node:fs/promises");const walk=async dir=>{const out=[];for(const e of await readdir(dir,{withFileTypes:true}).catch(()=>[])){const p=join(dir,e.name);if(e.isDirectory())out.push(...await walk(p));else out.push(p)}return out};const targets=[];const fixtureFiles=(await walk(resolve(REPO_ROOT,"src"))).filter(f=>/\.fixtures\.tsx?$/.test(f)&&!f.includes("/node_modules/")).sort();for(const file of fixtureFiles){const name=file.split("/").pop().replace(/\.fixtures\.tsx?$/,"");const mod=await vite.ssrLoadModule(file);for(const s of mod.scenarios??[]){if(s.surface==="public-page"){skipped.push(`${name} \xB7 ${s.name}`);continue}targets.push({kind:"fixture",label:`${name} \xB7 ${s.name}`,query:`fixture=${encodeURIComponent(name)}&scenario=${encodeURIComponent(s.name)}`,designIntent:s.designIntent})}}const pageFiles=[...(await walk(resolve(REPO_ROOT,"src/pages"))).filter(f=>f.endsWith("/page.json")&&f.split("/").length-resolve(REPO_ROOT,"src/pages").split("/").length===2),...(await walk(resolve(REPO_ROOT,"src/project_templates"))).filter(f=>/\/pages\/[^/]+\/page\.json$/.test(f))].sort();for(const file of pageFiles){const m=file.match(/\/project_templates\/([^/]+)\/pages\/([^/]+)\/page\.json$/)??file.match(/\/pages\/([^/]+)\/page\.json$/);const key=m.slice(1).join("/");targets.push({kind:"page",label:`page ${key}`,query:`page=${encodeURIComponent(key)}`})}return targets.map((t,index)=>({...t,index}))}async function renderOnce(browser,target,{width,container}){const context=await newDeterministicContext(browser,{width,height:900});const page=await context.newPage();await prepare(page);const consoleErrors=[];const pageErrors=[];page.on("console",m=>m.type()==="error"&&consoleErrors.push(m.text()));page.on("pageerror",e=>pageErrors.push(e.stack||e.message));const url=`${origin}${HARNESS_PATH}?${target.query}${container?`&container=${container}`:""}`;const result={width,...container?{container}:{},errors:[],diagnostics:[],overflow:false};try{await page.goto(url,{waitUntil:"load",timeout:3e4});await settle(page,{timeout:15e3});const harnessError=await page.evaluate(()=>window.__ENGINE_HARNESS_ERROR??null);if(harnessError)result.errors.push(harnessError);result.diagnostics=await page.evaluate(()=>window.__ENGINE_HARNESS_DIAGNOSTICS??[]);result.overflow=await page.evaluate(inContainer=>{const el=inContainer?document.querySelector("[data-engine-container]"):document.scrollingElement;return!!el&&el.scrollWidth>el.clientWidth+1},Boolean(container));result.aria=await page.locator("#root").ariaSnapshot().catch(()=>null)}catch(err){result.errors.push(err instanceof Error?err.message:String(err))}const dir=join(OUTPUT,target.kind,target.label.replace(/[^A-Za-z0-9._-]+/g,"_"));await mkdir(dir,{recursive:true});const shot=join(dir,`${container?`c${container}`:width}.png`);await page.screenshot({path:shot,fullPage:true}).then(()=>result.screenshot=relative(REPO_ROOT,shot),()=>{});const attributeWarning=/Unknown event handler property|React does not recognize the|Invalid DOM property|Invalid value for prop|for a non-boolean attribute/;result.warnings=consoleErrors.filter(m=>attributeWarning.test(m));result.errors.push(...pageErrors,...consoleErrors.filter(m=>!attributeWarning.test(m)));for(const d of result.diagnostics)if(d.severity==="error")result.errors.push(`${d.code}: ${d.message}`);await context.close();return result}
2
+ import{mkdir,rm,writeFile}from"node:fs/promises";import{createRequire}from"node:module";import{cpus}from"node:os";import{join,relative,resolve}from"node:path";import{pathToFileURL}from"node:url";import{loadConfig}from"../lib/config.mjs";import{parseFlagValue}from"../lib/env.mjs";import{REPO_ROOT}from"../lib/root.mjs";import{newDeterministicContext,prepare,settle}from"../harness/determinism.mjs";import{buildAndServeHarness,createHarnessServer}from"../harness/server.mjs";const argv=process.argv.slice(2);const{harness}=await loadConfig();const OUTPUT=resolve(REPO_ROOT,harness.output);const only=parseFlagValue(argv,"only");const[shardIndex,shardTotal]=(parseFlagValue(argv,"shard")??"1/1").split("/").map(Number);if(!(shardTotal>=1&&shardIndex>=1&&shardIndex<=shardTotal))throw new Error("[test-visual] --shard expects <i>/<n>");const fromRepo=createRequire(resolve(REPO_ROOT,"package.json"));const playwright=await import(pathToFileURL(fromRepo.resolve("playwright")).href);const chromium=playwright.chromium??playwright.default.chromium;const skipped=[];const{server}=await createHarnessServer();const served=await buildAndServeHarness();const origin=served.origin;const HARNESS_PATH=served.path;let exitCode=0;try{const list=await listTargets(server);const targets=list.filter(t=>(!only||t.label.startsWith(`${only} `))&&t.index%shardTotal===shardIndex-1);await rm(OUTPUT,{recursive:true,force:true});await mkdir(OUTPUT,{recursive:true});console.log(`[test-visual] ${targets.length} target(s) (of ${list.length}) \xD7 ${harness.widths.length} width(s) + a ${harness.container}px container${skipped.length?`; ${skipped.length} public-page scenario(s) skipped (R6)`:""}`);const browser=await chromium.launch();const concurrency=Math.max(1,Number(process.env.ENGINE_VISUAL_CONCURRENCY)||Math.min(6,Math.max(2,cpus().length-1)));const reports=new Array(targets.length);let next=0;await Promise.all(Array.from({length:Math.min(concurrency,targets.length)},async()=>{while(next<targets.length){const i=next++;const t=targets[i];const renders=[...harness.widths.map(w=>({width:w,container:null})),{width:Math.max(...harness.widths),container:harness.container}];const report={target:t.label,kind:t.kind,...t.designIntent?{designIntent:t.designIntent}:{},widths:[]};for(const r of renders)report.widths.push(await renderOnce(browser,t,r));reports[i]=report;const bad=report.widths.filter(w=>w.errors.length);const over=report.widths.filter(w=>w.overflow);console.log(` ${bad.length?"\u2717":over.length?"\u25B3":"\u2713"} ${t.label}${bad.length?` \u2014 ${bad[0].errors[0].split("\n")[0].slice(0,160)}`:""}${over.length?` (overflow at ${over.map(w=>w.container?`${w.container}px container`:`${w.width}px`).join(", ")})`:""}`)}}));await browser.close();const failed=reports.filter(r=>r.widths.some(w=>w.errors.length));const overflowing=reports.filter(r=>r.widths.some(w=>w.overflow));await writeFile(join(OUTPUT,"report.json"),JSON.stringify({widths:harness.widths,container:harness.container,reports,skipped},null,2)+"\n");console.log(`[test-visual] ${reports.length-failed.length}/${reports.length} rendered clean; ${overflowing.length} overflow; report \u2192 ${relative(REPO_ROOT,join(OUTPUT,"report.json"))}`);if(failed.length||harness.overflow==="error"&&overflowing.length)exitCode=1}finally{await server.close();await served.close()}process.exit(exitCode);async function listTargets(vite){const{readdir}=await import("node:fs/promises");const walk=async dir=>{const out=[];for(const e of await readdir(dir,{withFileTypes:true}).catch(()=>[])){const p=join(dir,e.name);if(e.isDirectory())out.push(...await walk(p));else out.push(p)}return out};const targets=[];const fixtureFiles=(await walk(resolve(REPO_ROOT,"src"))).filter(f=>/\.fixtures\.tsx?$/.test(f)&&!f.includes("/node_modules/")).sort();for(const file of fixtureFiles){const name=file.split("/").pop().replace(/\.fixtures\.tsx?$/,"");const mod=await vite.ssrLoadModule(file);for(const s of mod.scenarios??[]){if(s.surface==="public-page"){skipped.push(`${name} \xB7 ${s.name}`);continue}targets.push({kind:"fixture",label:`${name} \xB7 ${s.name}`,query:`fixture=${encodeURIComponent(name)}&scenario=${encodeURIComponent(s.name)}`,designIntent:s.designIntent})}}const pageFiles=[...(await walk(resolve(REPO_ROOT,"src/pages"))).filter(f=>f.endsWith("/page.json")&&f.split("/").length-resolve(REPO_ROOT,"src/pages").split("/").length===2),...(await walk(resolve(REPO_ROOT,"src/project_templates"))).filter(f=>/\/pages\/[^/]+\/page\.json$/.test(f))].sort();for(const file of pageFiles){const m=file.match(/\/project_templates\/([^/]+)\/pages\/([^/]+)\/page\.json$/)??file.match(/\/pages\/([^/]+)\/page\.json$/);const key=m.slice(1).join("/");targets.push({kind:"page",label:`page ${key}`,query:`page=${encodeURIComponent(key)}`})}return targets.map((t,index)=>({...t,index}))}async function renderOnce(browser,target,{width,container}){const context=await newDeterministicContext(browser,{width,height:900});const page=await context.newPage();await prepare(page);const consoleErrors=[];const pageErrors=[];page.on("console",m=>m.type()==="error"&&consoleErrors.push(m.text()));page.on("pageerror",e=>pageErrors.push(e.stack||e.message));const url=`${origin}${HARNESS_PATH}?${target.query}${container?`&container=${container}`:""}`;const result={width,...container?{container}:{},errors:[],diagnostics:[],overflow:false};try{await page.goto(url,{waitUntil:"load",timeout:3e4});await settle(page,{timeout:15e3});const harnessError=await page.evaluate(()=>window.__ENGINE_HARNESS_ERROR??null);if(harnessError)result.errors.push(harnessError);result.diagnostics=await page.evaluate(()=>window.__ENGINE_HARNESS_DIAGNOSTICS??[]);const missing=await page.$$eval("[data-missing-component]",els=>els.map(e=>e.getAttribute("data-missing-component")));for(const name of new Set(missing))result.errors.push(`${name} isn't exported by any barrel or shared lineage`);result.overflow=await page.evaluate(inContainer=>{const el=inContainer?document.querySelector("[data-engine-container]"):document.scrollingElement;return!!el&&el.scrollWidth>el.clientWidth+1},Boolean(container));result.aria=await page.locator("#root").ariaSnapshot().catch(()=>null)}catch(err){result.errors.push(err instanceof Error?err.message:String(err))}const dir=join(OUTPUT,target.kind,target.label.replace(/[^A-Za-z0-9._-]+/g,"_"));await mkdir(dir,{recursive:true});const shot=join(dir,`${container?`c${container}`:width}.png`);await page.screenshot({path:shot,fullPage:true}).then(()=>result.screenshot=relative(REPO_ROOT,shot),()=>{});const attributeWarning=/Unknown event handler property|React does not recognize the|Invalid DOM property|Invalid value for prop|for a non-boolean attribute/;result.warnings=consoleErrors.filter(m=>attributeWarning.test(m));result.errors.push(...pageErrors,...consoleErrors.filter(m=>!attributeWarning.test(m)));for(const d of result.diagnostics)if(d.severity==="error")result.errors.push(`${d.code}: ${d.message}`);await context.close();return result}
@@ -1,2 +1,2 @@
1
- import{Fragment,jsx,jsxs}from"react/jsx-runtime";import"@peripheries/renderer/styles.css";import{pageRuntimeFromValues,projectCellActions}from"@peripheries/renderer/core";import{PageRenderer}from"@peripheries/renderer/react";import{getStackStore}from"@peripheries/renderer/runtime";import{FIXTURE_BASE_PROPS}from"@peripheries/renderer/testing";import{Component,isValidElement,useEffect,useMemo}from"react";import{createRoot}from"react-dom/client";const fixtureName=path=>path.split("/").pop().replace(/\.fixtures\.tsx?$/,"");const pageKey=path=>{const m=path.match(/\/project_templates\/([^/]+)\/pages\/([^/]+)\/page\.json$/)??path.match(/\/pages\/([^/]+)\/page\.json$/);return m?m.slice(1).join("/"):path};function isJson(v){if(v===null||["string","number","boolean"].includes(typeof v))return true;if(Array.isArray(v))return v.every(isJson);if(typeof v!=="object"||isValidElement(v))return false;const proto=Object.getPrototypeOf(v);return(proto===Object.prototype||proto===null)&&Object.values(v).every(isJson)}class Boundary extends Component{state={error:null};static getDerivedStateFromError(error){return{error}}componentDidCatch(error,info){window.__ENGINE_HARNESS_ERROR=`${error.message}
2
- ${info.componentStack??""}`}render(){return this.state.error?jsx("pre",{role:"alert",children:this.state.error.message}):this.props.children}}function Ready({children}){useEffect(()=>{const id=requestAnimationFrame(()=>requestAnimationFrame(()=>window.__ENGINE_HARNESS_READY=true));return()=>cancelAnimationFrame(id)},[]);return jsx(Fragment,{children})}function RenderedPage({row,values,actions,baseProps,components,Wrapper,statusOverride}){const runtime=useMemo(()=>{const rt=pageRuntimeFromValues(row,{values});if(statusOverride&&(statusOverride.isLoading||statusOverride.error)){const error=statusOverride.error?new Error(statusOverride.error):null;for(const cell of rt.cells)rt.cellData[cell.id]??={static:{},declared:{}};for(const own of Object.values(rt.cellData)){const keys=Object.keys(own.static).length?Object.keys(own.static):["fixture"];for(const k of keys)own.static[k]={data:void 0,isLoading:Boolean(statusOverride.isLoading),error}}}return rt},[row,values,statusOverride]);const bundles=useMemo(()=>{const ThemeProvider=Wrapper?({children})=>jsx(Wrapper,{baseProps,children}):void 0;const bundle={id:"harness",label:"harness",components,contracts:{},...ThemeProvider?{ThemeProvider}:{}};return{scope:[bundle],forCell:()=>null}},[components,Wrapper,baseProps]);const environment=useMemo(()=>{const pageActions=Object.fromEntries(Object.keys(actions??{}).map(k=>[k,async input=>(console.info(`[harness] action ${k}`,input),{ok:true})]));window.__ENGINE_HARNESS_DIAGNOSTICS=[];return{core:{},navigate:()=>{},store:getStackStore(),actionsFor:cell=>cell.actions?projectCellActions(cell.actions,pageActions):Object.keys(pageActions).length?pageActions:void 0,onCellError:(cellId,error)=>console.error(`[harness] cell "${cellId}" failed:`,error),onDiagnostic:d=>{window.__ENGINE_HARNESS_DIAGNOSTICS.push({code:d.code,severity:d.severity,message:d.message,...d.cellId?{cellId:d.cellId}:{}});if(d.severity==="error")console.error(`[harness] ${d.code}: ${d.message}`)}}},[actions]);return jsx(PageRenderer,{runtime,bundles,params:{},baseProps,context:{environment:"harness"},environment})}function pageRow(id,json){return{id,name:json.name,org_id:null,project_id:null,process_id:null,graph_node_id:null,page_type:json.page_type??null,status:"published",is_template:json.is_template??false,content:json.content??{cells:[]},layout:json.layout??{type:"stack",gap:16},metadata:json.metadata??null}}function Index({fixtures,pages}){useEffect(()=>void(window.__ENGINE_HARNESS_READY=true),[]);const link=q=>`?${q}`;return jsxs("main",{style:{fontFamily:"system-ui, sans-serif",padding:24,maxWidth:960},children:[jsx("h1",{children:"Harness"}),jsx("h2",{children:"Fixtures"}),fixtures.map(([name,scenarios])=>jsxs("section",{children:[jsx("h3",{children:name}),jsx("ul",{children:scenarios.map(s=>jsxs("li",{children:[jsx("a",{href:link(`fixture=${encodeURIComponent(name)}&scenario=${encodeURIComponent(s.name)}`),children:s.name})," \xB7 ",jsx("a",{href:link(`fixture=${encodeURIComponent(name)}&scenario=${encodeURIComponent(s.name)}&container=320`),children:"in 320px"})]},s.name))})]},name)),jsx("h2",{children:"Pages"}),jsx("ul",{children:pages.map(p=>jsx("li",{children:jsx("a",{href:link(`page=${encodeURIComponent(p)}`),children:p})},p))})]})}function mountHarness(sources){const q=new URLSearchParams(window.location.search);const components=Object.assign({},...Object.values(sources.barrels));const fixtures=Object.entries(sources.fixtures).map(([path,mod])=>[fixtureName(path),[...mod.scenarios??[]]]).sort(([a],[b])=>a.localeCompare(b));const pages=Object.fromEntries(Object.entries(sources.pages).map(([path,json])=>[pageKey(path),{json,mock:sources.mocks[path.replace(/page\.json$/,"mock.json")]}]));let body;const fixture=q.get("fixture");const page=q.get("page");if(fixture){const scenario=fixtures.find(([n])=>n===fixture)?.[1].find(s=>s.name===q.get("scenario"));if(!scenario){window.__ENGINE_HARNESS_ERROR=`no scenario "${q.get("scenario")}" for ${fixture}`;body=null}else if(!isJson(scenario.props??{})){const Direct=components[fixture];if(!Direct)window.__ENGINE_HARNESS_ERROR=`${fixture} is not exported from any barrel`;const baseProps={...FIXTURE_BASE_PROPS,...scenario.baseProps};const element=Direct?jsx("div",{className:"cell","data-engine-direct":"",children:jsx(Direct,{...scenario.props,baseProps})}):null;body=sources.Wrapper?jsx(sources.Wrapper,{baseProps,children:element}):element}else{const cell={id:"fixture",component:fixture,props:scenario.props??{},...scenario.data?{data:{static:scenario.data}}:{},...scenario.selections?{selections:scenario.selections}:{},...scenario.viewState?{viewState:scenario.viewState}:{},...scenario.live?{live:scenario.live}:{}};body=jsx(RenderedPage,{row:pageRow(`${fixture}/${scenario.name}`,{name:`${fixture} \xB7 ${scenario.name}`,content:{cells:[cell]}}),values:{},actions:scenario.actions,baseProps:{...FIXTURE_BASE_PROPS,...scenario.baseProps},components,Wrapper:sources.Wrapper,statusOverride:scenario.status})}}else if(page){const entry=pages[page];if(!entry){window.__ENGINE_HARNESS_ERROR=`no page "${page}"`;body=null}else{const values={...entry.mock?.data};body=jsx(RenderedPage,{row:pageRow(page,entry.json),values,actions:entry.mock?.actions,baseProps:{...FIXTURE_BASE_PROPS,...entry.mock?.baseProps},components,Wrapper:sources.Wrapper})}}else{body=jsx(Index,{fixtures,pages:Object.keys(pages).sort()})}const container=Number(q.get("container"))||null;const framed=container?jsx("div",{"data-engine-container":"",style:{width:container,containerType:"inline-size",outline:"1px dashed #bbb"},children:body}):body;createRoot(document.getElementById("root")).render(jsx(Boundary,{children:jsx(Ready,{children:framed})}))}export{mountHarness};
1
+ import{Fragment,jsx,jsxs}from"react/jsx-runtime";import"@peripheries/renderer/styles.css";import{pageRuntimeFromValues,projectCellActions}from"@peripheries/renderer/core";import{PageRenderer}from"@peripheries/renderer/react";import{getStackStore}from"@peripheries/renderer/runtime";import{FIXTURE_BASE_PROPS}from"@peripheries/renderer/testing";import{Component,isValidElement,useEffect,useMemo}from"react";import{createRoot}from"react-dom/client";const isComponent=v=>typeof v==="function"||typeof v==="object"&&v!==null&&"$$typeof"in v;function sharedBundle({specifier,module}){const components=Object.fromEntries(Object.entries(module).filter(([name,v])=>/^[A-Z]/.test(name)&&isComponent(v)));return{id:specifier,label:specifier,specifier,components,contracts:{}}}const fixtureName=path=>path.split("/").pop().replace(/\.fixtures\.tsx?$/,"");const pageKey=path=>{const m=path.match(/\/project_templates\/([^/]+)\/pages\/([^/]+)\/page\.json$/)??path.match(/\/pages\/([^/]+)\/page\.json$/);return m?m.slice(1).join("/"):path};function isJson(v){if(v===null||["string","number","boolean"].includes(typeof v))return true;if(Array.isArray(v))return v.every(isJson);if(typeof v!=="object"||isValidElement(v))return false;const proto=Object.getPrototypeOf(v);return(proto===Object.prototype||proto===null)&&Object.values(v).every(isJson)}class Boundary extends Component{state={error:null};static getDerivedStateFromError(error){return{error}}componentDidCatch(error,info){window.__ENGINE_HARNESS_ERROR=`${error.message}
2
+ ${info.componentStack??""}`}render(){return this.state.error?jsx("pre",{role:"alert",children:this.state.error.message}):this.props.children}}function Ready({children}){useEffect(()=>{const id=requestAnimationFrame(()=>requestAnimationFrame(()=>window.__ENGINE_HARNESS_READY=true));return()=>cancelAnimationFrame(id)},[]);return jsx(Fragment,{children})}function RenderedPage({row,values,actions,baseProps,components,shared,Wrapper,statusOverride}){const runtime=useMemo(()=>{const rt=pageRuntimeFromValues(row,{values});if(statusOverride&&(statusOverride.isLoading||statusOverride.error)){const error=statusOverride.error?new Error(statusOverride.error):null;for(const cell of rt.cells)rt.cellData[cell.id]??={static:{},declared:{}};for(const own of Object.values(rt.cellData)){const keys=Object.keys(own.static).length?Object.keys(own.static):["fixture"];for(const k of keys)own.static[k]={data:void 0,isLoading:Boolean(statusOverride.isLoading),error}}}return rt},[row,values,statusOverride]);const bundles=useMemo(()=>{const ThemeProvider=Wrapper?({children})=>jsx(Wrapper,{baseProps,children}):void 0;const bundle={id:"harness",label:"harness",components,contracts:{},...ThemeProvider?{ThemeProvider}:{}};return{scope:[bundle,...shared],forCell:()=>null}},[components,shared,Wrapper,baseProps]);const environment=useMemo(()=>{const pageActions=Object.fromEntries(Object.keys(actions??{}).map(k=>[k,async input=>(console.info(`[harness] action ${k}`,input),{ok:true})]));window.__ENGINE_HARNESS_DIAGNOSTICS=[];return{core:{},navigate:()=>{},store:getStackStore(),actionsFor:cell=>cell.actions?projectCellActions(cell.actions,pageActions):Object.keys(pageActions).length?pageActions:void 0,onCellError:(cellId,error)=>console.error(`[harness] cell "${cellId}" failed:`,error),onDiagnostic:d=>{window.__ENGINE_HARNESS_DIAGNOSTICS.push({code:d.code,severity:d.severity,message:d.message,...d.cellId?{cellId:d.cellId}:{}});if(d.severity==="error")console.error(`[harness] ${d.code}: ${d.message}`)}}},[actions]);return jsx(PageRenderer,{runtime,bundles,params:{},baseProps,context:{environment:"harness"},environment})}function pageRow(id,json){return{id,name:json.name,org_id:null,project_id:null,process_id:null,graph_node_id:null,page_type:json.page_type??null,status:"published",is_template:json.is_template??false,content:json.content??{cells:[]},layout:json.layout??{type:"stack",gap:16},metadata:json.metadata??null}}function Index({fixtures,pages}){useEffect(()=>void(window.__ENGINE_HARNESS_READY=true),[]);const link=q=>`?${q}`;return jsxs("main",{style:{fontFamily:"system-ui, sans-serif",padding:24,maxWidth:960},children:[jsx("h1",{children:"Harness"}),jsx("h2",{children:"Fixtures"}),fixtures.map(([name,scenarios])=>jsxs("section",{children:[jsx("h3",{children:name}),jsx("ul",{children:scenarios.map(s=>jsxs("li",{children:[jsx("a",{href:link(`fixture=${encodeURIComponent(name)}&scenario=${encodeURIComponent(s.name)}`),children:s.name})," \xB7 ",jsx("a",{href:link(`fixture=${encodeURIComponent(name)}&scenario=${encodeURIComponent(s.name)}&container=320`),children:"in 320px"})]},s.name))})]},name)),jsx("h2",{children:"Pages"}),jsx("ul",{children:pages.map(p=>jsx("li",{children:jsx("a",{href:link(`page=${encodeURIComponent(p)}`),children:p})},p))})]})}function mountHarness(sources){const q=new URLSearchParams(window.location.search);const components=Object.assign({},...Object.values(sources.barrels));const shared=(sources.shared??[]).map(sharedBundle);const fixtures=Object.entries(sources.fixtures).map(([path,mod])=>[fixtureName(path),[...mod.scenarios??[]]]).sort(([a],[b])=>a.localeCompare(b));const pages=Object.fromEntries(Object.entries(sources.pages).map(([path,json])=>[pageKey(path),{json,mock:sources.mocks[path.replace(/page\.json$/,"mock.json")]}]));let body;const fixture=q.get("fixture");const page=q.get("page");if(fixture){const scenario=fixtures.find(([n])=>n===fixture)?.[1].find(s=>s.name===q.get("scenario"));if(!scenario){window.__ENGINE_HARNESS_ERROR=`no scenario "${q.get("scenario")}" for ${fixture}`;body=null}else if(!isJson(scenario.props??{})){const Direct=components[fixture]??shared.find(b=>b.components[fixture])?.components[fixture];if(!Direct)window.__ENGINE_HARNESS_ERROR=`${fixture} is not exported from any barrel or shared lineage`;const baseProps={...FIXTURE_BASE_PROPS,...scenario.baseProps};const element=Direct?jsx("div",{className:"cell","data-engine-direct":"",children:jsx(Direct,{...scenario.props,baseProps})}):null;body=sources.Wrapper?jsx(sources.Wrapper,{baseProps,children:element}):element}else{const cell={id:"fixture",component:fixture,props:scenario.props??{},...scenario.data?{data:{static:scenario.data}}:{},...scenario.selections?{selections:scenario.selections}:{},...scenario.viewState?{viewState:scenario.viewState}:{},...scenario.live?{live:scenario.live}:{}};body=jsx(RenderedPage,{row:pageRow(`${fixture}/${scenario.name}`,{name:`${fixture} \xB7 ${scenario.name}`,content:{cells:[cell]}}),values:{},actions:scenario.actions,baseProps:{...FIXTURE_BASE_PROPS,...scenario.baseProps},components,shared,Wrapper:sources.Wrapper,statusOverride:scenario.status})}}else if(page){const entry=pages[page];if(!entry){window.__ENGINE_HARNESS_ERROR=`no page "${page}"`;body=null}else{const values={...entry.mock?.data};body=jsx(RenderedPage,{row:pageRow(page,entry.json),values,actions:entry.mock?.actions,baseProps:{...FIXTURE_BASE_PROPS,...entry.mock?.baseProps},components,shared,Wrapper:sources.Wrapper})}}else{body=jsx(Index,{fixtures,pages:Object.keys(pages).sort()})}const container=Number(q.get("container"))||null;const framed=container?jsx("div",{"data-engine-container":"",style:{width:container,containerType:"inline-size",outline:"1px dashed #bbb"},children:body}):body;createRoot(document.getElementById("root")).render(jsx(Boundary,{children:jsx(Ready,{children:framed})}))}export{mountHarness};
@@ -1,13 +1,14 @@
1
- import{existsSync}from"node:fs";import{dirname,resolve}from"node:path";import{fileURLToPath}from"node:url";const HERE=dirname(fileURLToPath(import.meta.url));const APP=["app.tsx","app.mjs"].map(f=>resolve(HERE,f)).find(f=>existsSync(f));const ENTRY="virtual:engine-harness/entry";const HARNESS_PATH="/__engine/harness.html";const HTML=`<!doctype html>
1
+ import{existsSync}from"node:fs";import{dirname,resolve}from"node:path";import{fileURLToPath}from"node:url";import{LAYER_ORDER}from"../lib/layers.mjs";const HERE=dirname(fileURLToPath(import.meta.url));const APP=["app.tsx","app.mjs"].map(f=>resolve(HERE,f)).find(f=>existsSync(f));const ENTRY="virtual:engine-harness/entry";const HARNESS_PATH="/__engine/harness.html";const HTML=`<!doctype html>
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>Harness</title>
7
+ <style>@layer ${LAYER_ORDER.join(", ")};</style>
7
8
  <style>html, body { margin: 0; padding: 0; background: #fff; }</style>
8
9
  </head>
9
10
  <body>
10
11
  <div id="root"></div>
11
12
  <script type="module" src="/@id/${ENTRY}"><\/script>
12
13
  </body>
13
- </html>`;function entryCode(wrapperPath,wrapperExport="default",appPath="/@fs"+APP,wrapperPrefix="/@fs"){return[`import { mountHarness } from ${JSON.stringify(appPath)};`,wrapperPath?`import { ${wrapperExport} as Wrapper } from ${JSON.stringify(wrapperPrefix+wrapperPath)};`:"const Wrapper = undefined;",`const fixtures = import.meta.glob('/src/**/*.fixtures.{ts,tsx}', { eager: true });`,`const barrels = import.meta.glob(['/src/index.ts', '/src/index.*.ts', '!/src/index.*.test.ts'], { eager: true });`,`const pages = import.meta.glob(['/src/pages/*/page.json', '/src/project_templates/*/pages/*/page.json'], { eager: true, import: 'default' });`,`const mocks = import.meta.glob(['/src/pages/*/mock.json', '/src/project_templates/*/pages/*/mock.json'], { eager: true, import: 'default' });`,`mountHarness({ fixtures, barrels, pages, mocks, Wrapper });`].join("\n")}function engineHarness({root,wrapper=null}){const[wrapperFile,wrapperExport="default"]=wrapper?wrapper.split("#"):[];const wrapperPath=wrapperFile?resolve(root,wrapperFile):null;return{name:"engine-harness",resolveId(id){return id===ENTRY?`\0${ENTRY}`:void 0},load(id){return id===`\0${ENTRY}`?entryCode(wrapperPath,wrapperExport):void 0},configureServer(server){server.middlewares.use(async(req,res,next)=>{if(!req.url||!req.url.startsWith(HARNESS_PATH))return next();const html=await server.transformIndexHtml(req.url,HTML);res.setHeader("content-type","text/html; charset=utf-8");res.end(html)})}}}export{APP,HARNESS_PATH,HTML,engineHarness,entryCode};
14
+ </html>`;function entryCode({wrapperPath=null,wrapperExport="default",appPath="/@fs"+APP,fsPrefix="/@fs",shared=[]}={}){return[`import { mountHarness } from ${JSON.stringify(appPath)};`,wrapperPath?`import { ${wrapperExport} as Wrapper } from ${JSON.stringify(fsPrefix+wrapperPath)};`:"const Wrapper = undefined;",...shared.flatMap((s,i)=>[`import * as __shared${i} from ${JSON.stringify(s.specifier)};`,...s.css?[`import ${JSON.stringify(fsPrefix+s.css)};`]:[]]),`const shared = [${shared.map((s,i)=>`{ specifier: ${JSON.stringify(s.specifier)}, module: __shared${i} }`).join(", ")}];`,`const fixtures = import.meta.glob('/src/**/*.fixtures.{ts,tsx}', { eager: true });`,`const barrels = import.meta.glob(['/src/index.ts', '/src/index.*.ts', '!/src/index.*.test.ts'], { eager: true });`,`const pages = import.meta.glob(['/src/pages/*/page.json', '/src/project_templates/*/pages/*/page.json'], { eager: true, import: 'default' });`,`const mocks = import.meta.glob(['/src/pages/*/mock.json', '/src/project_templates/*/pages/*/mock.json'], { eager: true, import: 'default' });`,`mountHarness({ fixtures, barrels, pages, mocks, Wrapper, shared });`].join("\n")}function engineHarness({root,wrapper=null,shared=[]}){const[wrapperFile,wrapperExport="default"]=wrapper?wrapper.split("#"):[];const wrapperPath=wrapperFile?resolve(root,wrapperFile):null;return{name:"engine-harness",resolveId(id){return id===ENTRY?`\0${ENTRY}`:void 0},load(id){return id===`\0${ENTRY}`?entryCode({wrapperPath,wrapperExport,shared}):void 0},configureServer(server){server.middlewares.use(async(req,res,next)=>{if(!req.url||!req.url.startsWith(HARNESS_PATH))return next();const html=await server.transformIndexHtml(req.url,HTML);res.setHeader("content-type","text/html; charset=utf-8");res.end(html)})}}}export{APP,HARNESS_PATH,HTML,engineHarness,entryCode};
@@ -1 +1 @@
1
- import{createReadStream,existsSync,mkdirSync,statSync,writeFileSync}from"node:fs";import{createServer as createHttpServer}from"node:http";import{createRequire}from"node:module";import{dirname,extname,join,normalize,resolve}from"node:path";import{fileURLToPath,pathToFileURL}from"node:url";import{loadConfig}from"../lib/config.mjs";import{REPO_ROOT}from"../lib/root.mjs";import{engineHarness}from"./plugin.mjs";const fromRepo=createRequire(resolve(REPO_ROOT,"package.json"));const importFromRepo=name=>import(pathToFileURL(fromRepo.resolve(name)).href);async function createHarnessServer({port=0,open=false}={}){const config=await loadConfig();const{createServer}=await importFromRepo("vite");const react=(await importFromRepo("@vitejs/plugin-react")).default;const server=await createServer({configFile:false,root:REPO_ROOT,logLevel:"warn",plugins:[react(),engineHarness({root:REPO_ROOT,wrapper:config.harness.wrapper})],define:{__OA_BUNDLE_NAME__:JSON.stringify(config.org)},optimizeDeps:{include:["react","react-dom/client","@peripheries/renderer/core","@peripheries/renderer/react","@peripheries/renderer/runtime","@peripheries/renderer/testing"]},resolve:{dedupe:["react","react-dom","@peripheries/renderer","@peripheries/runtime"],alias:{"@":resolve(REPO_ROOT,"src"),...Object.fromEntries(Object.entries(config.harness.alias).map(([k,v])=>[k,resolve(REPO_ROOT,v)]))}},server:{port,strictPort:Boolean(port),open,fs:{allow:[REPO_ROOT,dirname(fileURLToPath(import.meta.url))]}}});await server.listen();const address=server.httpServer?.address();if(!address||typeof address==="string")throw new Error("[harness] the Vite server did not bind a port");return{server,origin:`http://localhost:${address.port}`}}async function buildAndServeHarness(){const config=await loadConfig();const{build}=await importFromRepo("vite");const react=(await importFromRepo("@vitejs/plugin-react")).default;const{entryCode,HTML,APP}=await import("./plugin.mjs");const cache=resolve(REPO_ROOT,"node_modules/.cache/engine-harness");const outDir=resolve(cache,"dist");mkdirSync(cache,{recursive:true});const[wrapperFile,wrapperExport="default"]=config.harness.wrapper?config.harness.wrapper.split("#"):[];writeFileSync(resolve(cache,"entry.mjs"),entryCode(wrapperFile?resolve(REPO_ROOT,wrapperFile):null,wrapperExport,APP,""));writeFileSync(resolve(cache,"harness.html"),HTML.replace(/<script type="module" src="[^"]*"><\/script>/,'<script type="module" src="./entry.mjs"><\/script>'));await build({configFile:false,root:REPO_ROOT,mode:"development",logLevel:"warn",plugins:[react()],define:{__OA_BUNDLE_NAME__:JSON.stringify(config.org),"process.env.NODE_ENV":JSON.stringify("development")},resolve:{dedupe:["react","react-dom","@peripheries/renderer","@peripheries/runtime"],alias:{"@":resolve(REPO_ROOT,"src"),...Object.fromEntries(Object.entries(config.harness.alias).map(([k,v])=>[k,resolve(REPO_ROOT,v)]))}},build:{outDir,emptyOutDir:true,minify:false,sourcemap:false,rollupOptions:{input:resolve(cache,"harness.html")}}});const TYPES={".html":"text/html; charset=utf-8",".js":"text/javascript",".mjs":"text/javascript",".css":"text/css",".json":"application/json",".svg":"image/svg+xml",".png":"image/png",".woff2":"font/woff2"};const server=createHttpServer((req,res)=>{const path=normalize(decodeURIComponent((req.url??"/").split("?")[0])).replace(/^(\.\.[/\\])+/,"");const file=join(outDir,path);if(!file.startsWith(outDir)||!existsSync(file)||statSync(file).isDirectory()){res.statusCode=404;return res.end("not found")}res.setHeader("content-type",TYPES[extname(file)]??"application/octet-stream");createReadStream(file).pipe(res)});await new Promise(r=>server.listen(0,"127.0.0.1",r));const{port}=server.address();return{origin:`http://127.0.0.1:${port}`,path:"/node_modules/.cache/engine-harness/harness.html",close:()=>new Promise(r=>server.close(r))}}export{buildAndServeHarness,createHarnessServer};
1
+ import{createReadStream,existsSync,mkdirSync,realpathSync,statSync,writeFileSync}from"node:fs";import{createServer as createHttpServer}from"node:http";import{createRequire}from"node:module";import{dirname,extname,join,normalize,resolve}from"node:path";import{fileURLToPath,pathToFileURL}from"node:url";import{loadConfig}from"../lib/config.mjs";import{REPO_ROOT}from"../lib/root.mjs";import{loadSharedLineages,unresolvedMessage}from"../lib/shared-lineages.mjs";import{engineHarness}from"./plugin.mjs";const fromRepo=createRequire(resolve(REPO_ROOT,"package.json"));const importFromRepo=name=>import(pathToFileURL(fromRepo.resolve(name)).href);const escape=s=>s.replace(/[.*+?^${}()|[\]\\/]/g,"\\$&");async function harnessShared(config){const loaded=await loadSharedLineages(config);const shared=[];const alias=[{find:"@",replacement:resolve(REPO_ROOT,"src")},...Object.entries(config.harness.alias).map(([find,to])=>({find,replacement:resolve(REPO_ROOT,to)}))];const dirs=[];for(const specifier of loaded.specifiers){const aliased=specifier in config.harness.alias;const found=loaded.lineages.find(l=>l.specifier===specifier);if(!aliased&&!found)throw new Error(`[harness] ${loaded.error??unresolvedMessage(specifier)}`);if(!aliased&&!found.external&&!/\.d\.[cm]?ts$/.test(found.file)){alias.push({find:new RegExp(`^${escape(specifier)}$`),replacement:found.file})}if(!aliased&&found.packageDir)dirs.push(found.packageDir,realpathSync(found.packageDir));shared.push({specifier,css:!aliased&&found.external?found.css:null})}return{shared,alias,dirs}}async function createHarnessServer({port=0,open=false}={}){const config=await loadConfig();const{shared,alias,dirs}=await harnessShared(config);const{createServer}=await importFromRepo("vite");const react=(await importFromRepo("@vitejs/plugin-react")).default;const server=await createServer({configFile:false,root:REPO_ROOT,logLevel:"warn",plugins:[react(),engineHarness({root:REPO_ROOT,wrapper:config.harness.wrapper,shared})],define:{__OA_BUNDLE_NAME__:JSON.stringify(config.org)},optimizeDeps:{include:["react","react-dom/client","@peripheries/renderer/core","@peripheries/renderer/react","@peripheries/renderer/runtime","@peripheries/renderer/testing"]},resolve:{dedupe:["react","react-dom","@peripheries/renderer","@peripheries/runtime"],alias},server:{port,strictPort:Boolean(port),open,fs:{allow:[REPO_ROOT,dirname(fileURLToPath(import.meta.url)),...dirs]}}});await server.listen();const address=server.httpServer?.address();if(!address||typeof address==="string")throw new Error("[harness] the Vite server did not bind a port");return{server,origin:`http://localhost:${address.port}`}}async function buildAndServeHarness(){const config=await loadConfig();const{shared,alias}=await harnessShared(config);const{build}=await importFromRepo("vite");const react=(await importFromRepo("@vitejs/plugin-react")).default;const{entryCode,HTML,APP}=await import("./plugin.mjs");const cache=resolve(REPO_ROOT,"node_modules/.cache/engine-harness");const outDir=resolve(cache,"dist");mkdirSync(cache,{recursive:true});const[wrapperFile,wrapperExport="default"]=config.harness.wrapper?config.harness.wrapper.split("#"):[];writeFileSync(resolve(cache,"entry.mjs"),entryCode({wrapperPath:wrapperFile?resolve(REPO_ROOT,wrapperFile):null,wrapperExport,appPath:APP,fsPrefix:"",shared}));writeFileSync(resolve(cache,"harness.html"),HTML.replace(/<script type="module" src="[^"]*"><\/script>/,'<script type="module" src="./entry.mjs"><\/script>'));await build({configFile:false,root:REPO_ROOT,mode:"development",logLevel:"warn",plugins:[react()],define:{__OA_BUNDLE_NAME__:JSON.stringify(config.org),"process.env.NODE_ENV":JSON.stringify("development")},resolve:{dedupe:["react","react-dom","@peripheries/renderer","@peripheries/runtime"],alias},build:{outDir,emptyOutDir:true,minify:false,sourcemap:false,rollupOptions:{input:resolve(cache,"harness.html")}}});const TYPES={".html":"text/html; charset=utf-8",".js":"text/javascript",".mjs":"text/javascript",".css":"text/css",".json":"application/json",".svg":"image/svg+xml",".png":"image/png",".woff2":"font/woff2"};const server=createHttpServer((req,res)=>{const path=normalize(decodeURIComponent((req.url??"/").split("?")[0])).replace(/^(\.\.[/\\])+/,"");const file=join(outDir,path);if(!file.startsWith(outDir)||!existsSync(file)||statSync(file).isDirectory()){res.statusCode=404;return res.end("not found")}res.setHeader("content-type",TYPES[extname(file)]??"application/octet-stream");createReadStream(file).pipe(res)});await new Promise(r=>server.listen(0,"127.0.0.1",r));const{port}=server.address();return{origin:`http://127.0.0.1:${port}`,path:"/node_modules/.cache/engine-harness/harness.html",close:()=>new Promise(r=>server.close(r))}}export{buildAndServeHarness,createHarnessServer,harnessShared};
package/src/index.d.mts CHANGED
@@ -19,6 +19,7 @@ export type EngineConfig = {
19
19
  org: string;
20
20
  lineages?: LineageSpec[];
21
21
  shared?: string[];
22
+ shadows?: Record<string, string>;
22
23
  harness?: {
23
24
  wrapper?: string;
24
25
  widths?: number[];
@@ -31,6 +32,13 @@ export type EngineConfig = {
31
32
  run?: string[];
32
33
  peerRepos?: string[];
33
34
  gated?: { projectTemplates?: string[]; orgTemplates?: string[] };
35
+ budgets?: Partial<Record<'check-tokens' | 'check-inline-styles', number>>;
36
+ propsSchema?: {
37
+ families?: string[];
38
+ names?: string[];
39
+ floor?: number;
40
+ propless?: string[];
41
+ };
34
42
  };
35
43
  };
36
44
 
@@ -56,7 +64,14 @@ export type LoadedConfig = {
56
64
  org: string;
57
65
  lineages: Lineage[];
58
66
  shared: string[];
59
- checks: { run: string[]; peerRepos: string[]; gated: { projectTemplates: string[]; orgTemplates: string[] } };
67
+ checks: {
68
+ run: string[];
69
+ peerRepos: string[];
70
+ gated: { projectTemplates: string[]; orgTemplates: string[] };
71
+ budgets: Record<string, number>;
72
+ propsSchema: { families: string[] | null; names: string[]; floor: number; propless: string[] };
73
+ };
74
+ shadows: Record<string, string>;
60
75
  harness: { wrapper: string | null; widths: number[]; container: number; overflow: 'warn' | 'error'; output: string; alias: Record<string, string> };
61
76
  };
62
77
  export function loadConfig(): Promise<LoadedConfig>;
@@ -1 +1 @@
1
- import{existsSync,readFileSync}from"node:fs";import{resolve}from"node:path";import{pathToFileURL}from"node:url";import{REPO_ROOT}from"./root.mjs";const MAIN_LINEAGE="main";const DEFAULT_CHECKS=["validate-externals","check-wired","check-routes","check-design-refs","check-skills","check-work-orders","check-cutover-ownership","check-fixtures","check-tokens","check-css-scope","knowledge"];const PUBLIC_LINEAGE="public";const CONFIG_FILES=["engine.config.ts","engine.config.mts","engine.config.mjs","engine.config.js"];function defineConfig(config){return config}function lineageDefaults(name,kind){if(name===MAIN_LINEAGE){return{name,kind:"main",entry:"src/index.ts",sourceRoot:null,dist:"dist"}}if(name===PUBLIC_LINEAGE){return{name,kind:"public",entry:"src/index.public.ts",sourceRoot:"components/public/",dist:"dist-public"}}const k=kind??"page";return{name,kind:k,entry:`src/index.${name}.ts`,sourceRoot:k==="form"?`components/form/${name}/`:k==="shared"?`${name}/`:`components/project_page/${name}/`,dist:`dist-bundles/${name}`}}function normalize(raw,origin){if(!raw||typeof raw!=="object")throw new Error(`[engine] ${origin} must export a config object`);if(typeof raw.org!=="string"||!raw.org)throw new Error(`[engine] ${origin}: \`org\` (the org slug) is required`);const list=raw.lineages??[MAIN_LINEAGE];const lineages=list.map(l=>{const spec=typeof l==="string"?{name:l}:l;if(!spec?.name)throw new Error(`[engine] ${origin}: every lineage needs a name`);return{...lineageDefaults(spec.name,spec.kind),...spec}});const mainAt=lineages.findIndex(l=>l.name===MAIN_LINEAGE);if(mainAt>0)throw new Error(`[engine] ${origin}: main must be the first lineage`);if(mainAt===-1&&!lineages.every(l=>l.kind==="shared")){throw new Error(`[engine] ${origin}: lineages must start with "main", unless every lineage is shared`)}for(const l of lineages){if(l.kind==="shared"&&!/^@[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$/.test(l.specifier??"")){throw new Error(`[engine] ${origin}: shared lineage "${l.name}" needs a specifier like "@stack/${l.name}"`)}}const gated=raw.checks?.gated??{};const checks={run:raw.checks?.run??DEFAULT_CHECKS,peerRepos:raw.checks?.peerRepos??[],gated:{projectTemplates:gated.projectTemplates??[],orgTemplates:gated.orgTemplates??[]}};const shared=raw.shared??[];const harness={wrapper:raw.harness?.wrapper??null,widths:raw.harness?.widths??[390,768,1280],container:raw.harness?.container??320,overflow:raw.harness?.overflow??"warn",output:raw.harness?.output??"test/visual/__output__",alias:raw.harness?.alias??{}};return{org:raw.org,lineages,checks,shared,harness}}const normalizeForTest=raw=>normalize(raw,"test");let cached;async function loadConfig(){if(cached)return cached;const file=CONFIG_FILES.map(f=>resolve(REPO_ROOT,f)).find(f=>existsSync(f));if(file){const mod=await import(pathToFileURL(file).href);cached=normalize(mod.default??mod,file);return cached}const pkg=JSON.parse(readFileSync(resolve(REPO_ROOT,"package.json"),"utf8"));let org=pkg.oaBundle?.name;const orgJson=resolve(REPO_ROOT,"template/config/org.json");if(existsSync(orgJson))org=JSON.parse(readFileSync(orgJson,"utf8")).slug??org;const lineages=(pkg.oaBundle?.lineages??[MAIN_LINEAGE]).map(n=>n==="workbooks"?{name:n,kind:"form"}:n);cached=normalize({org,lineages},"package.json oaBundle");return cached}async function lineageConfig(name){const{lineages}=await loadConfig();return lineages.find(l=>l.name===name)??lineageDefaults(name)}export{DEFAULT_CHECKS,MAIN_LINEAGE,PUBLIC_LINEAGE,defineConfig,lineageConfig,lineageDefaults,loadConfig,normalizeForTest};
1
+ import{existsSync,readFileSync}from"node:fs";import{resolve}from"node:path";import{pathToFileURL}from"node:url";import{REPO_ROOT}from"./root.mjs";const MAIN_LINEAGE="main";const DEFAULT_CHECKS=["validate-externals","check-wired","check-routes","check-design-refs","check-skills","check-work-orders","check-cutover-ownership","check-fixtures","check-tokens","check-css-scope","check-inline-styles","check-shadowing","check-theme","knowledge"];function budgetVerdict(name,count,budget){if(budget===void 0||budget===null)return{ok:true,message:null};if(count>budget)return{ok:false,message:`${count} finding(s), over the budget of ${budget}: the count may only fall (checks.budgets['${name}'])`};if(count<budget)return{ok:true,message:`${count} finding(s), under the budget of ${budget}: lower checks.budgets['${name}'] to ${count}`};return{ok:true,message:`${count} finding(s), at the budget`}}const PUBLIC_LINEAGE="public";const CONFIG_FILES=["engine.config.ts","engine.config.mts","engine.config.mjs","engine.config.js"];function defineConfig(config){return config}function lineageDefaults(name,kind){if(name===MAIN_LINEAGE){return{name,kind:"main",entry:"src/index.ts",sourceRoot:null,dist:"dist"}}if(name===PUBLIC_LINEAGE){return{name,kind:"public",entry:"src/index.public.ts",sourceRoot:"components/public/",dist:"dist-public"}}const k=kind??"page";return{name,kind:k,entry:`src/index.${name}.ts`,sourceRoot:k==="form"?`components/form/${name}/`:k==="shared"?`${name}/`:`components/project_page/${name}/`,dist:`dist-bundles/${name}`}}function normalize(raw,origin){if(!raw||typeof raw!=="object")throw new Error(`[engine] ${origin} must export a config object`);if(typeof raw.org!=="string"||!raw.org)throw new Error(`[engine] ${origin}: \`org\` (the org slug) is required`);const list=raw.lineages??[MAIN_LINEAGE];const lineages=list.map(l=>{const spec=typeof l==="string"?{name:l}:l;if(!spec?.name)throw new Error(`[engine] ${origin}: every lineage needs a name`);return{...lineageDefaults(spec.name,spec.kind),...spec}});const mainAt=lineages.findIndex(l=>l.name===MAIN_LINEAGE);if(mainAt>0)throw new Error(`[engine] ${origin}: main must be the first lineage`);if(mainAt===-1&&!lineages.every(l=>l.kind==="shared")){throw new Error(`[engine] ${origin}: lineages must start with "main", unless every lineage is shared`)}for(const l of lineages){if(l.kind==="shared"&&!/^@[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$/.test(l.specifier??"")){throw new Error(`[engine] ${origin}: shared lineage "${l.name}" needs a specifier like "@stack/${l.name}"`)}}const gated=raw.checks?.gated??{};const checks={run:raw.checks?.run??DEFAULT_CHECKS,peerRepos:raw.checks?.peerRepos??[],gated:{projectTemplates:gated.projectTemplates??[],orgTemplates:gated.orgTemplates??[]},budgets:raw.checks?.budgets??{},propsSchema:{families:raw.checks?.propsSchema?.families??null,names:raw.checks?.propsSchema?.names??[],floor:raw.checks?.propsSchema?.floor??1,propless:raw.checks?.propsSchema?.propless??[]}};const shadows=raw.shadows??{};const shared=raw.shared??[];const harness={wrapper:raw.harness?.wrapper??null,widths:raw.harness?.widths??[390,768,1280],container:raw.harness?.container??320,overflow:raw.harness?.overflow??"warn",output:raw.harness?.output??"test/visual/__output__",alias:raw.harness?.alias??{}};return{org:raw.org,lineages,checks,shared,shadows,harness}}const normalizeForTest=raw=>normalize(raw,"test");let cached;async function loadConfig(){if(cached)return cached;const file=CONFIG_FILES.map(f=>resolve(REPO_ROOT,f)).find(f=>existsSync(f));if(file){const mod=await import(pathToFileURL(file).href);cached=normalize(mod.default??mod,file);return cached}const pkg=JSON.parse(readFileSync(resolve(REPO_ROOT,"package.json"),"utf8"));let org=pkg.oaBundle?.name;const orgJson=resolve(REPO_ROOT,"template/config/org.json");if(existsSync(orgJson))org=JSON.parse(readFileSync(orgJson,"utf8")).slug??org;const lineages=(pkg.oaBundle?.lineages??[MAIN_LINEAGE]).map(n=>n==="workbooks"?{name:n,kind:"form"}:n);cached=normalize({org,lineages},"package.json oaBundle");return cached}async function lineageConfig(name){const{lineages}=await loadConfig();return lineages.find(l=>l.name===name)??lineageDefaults(name)}export{DEFAULT_CHECKS,MAIN_LINEAGE,PUBLIC_LINEAGE,budgetVerdict,defineConfig,lineageConfig,lineageDefaults,loadConfig,normalizeForTest};
@@ -10,6 +10,18 @@ export type Knowledge = {
10
10
  scopeRegistry?: Record<string, {
11
11
  props_schema?: unknown;
12
12
  }>;
13
+ shared: SharedScope[];
13
14
  };
15
+ export type SharedScope = {
16
+ lineage: string;
17
+ registry: Record<string, {
18
+ props_schema?: unknown;
19
+ }>;
20
+ };
21
+ export declare function qualifiedRef(value: string): {
22
+ lineage?: string;
23
+ key: string;
24
+ };
25
+ export declare function kitFamilies(): string[];
14
26
  export declare const holdsReference: (v: unknown) => boolean;
15
27
  export declare function buildKnowledge(bundle: string, org: string): Promise<Knowledge>;
@@ -1,4 +1,4 @@
1
- import{existsSync,readdirSync,readFileSync}from"node:fs";import{resolve}from"node:path";import{hasComponentJSDoc,parseJSDocFile,readExportsFromIndex,resolveComponentFilePath,toDisplayName}from"./jsdoc-parser.mjs";import{pathToFileURL}from"node:url";import{REPO_ROOT as REPO_ROOT_}from"./root.mjs";function registryOf(entry){const out={};for(const{name,source}of readExportsFromIndex(repoRoot,entry)){const file=resolveComponentFilePath(repoRoot,source.replace(/^\.\//,""));if(file&&hasComponentJSDoc(file,name))out[name]={props_schema:parseJSDocFile(file,name).props_schema??void 0}}return out}const repoRoot=REPO_ROOT_;function readDoc(path){const text=readFileSync(path,"utf8");const m=text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);if(!m)throw new Error(`[knowledge] ${path} has no front matter`);const meta={};for(const line of m[1].split("\n")){const kv=line.match(/^([\w-]+):\s*(.*)$/);if(!kv)continue;const raw=kv[2].trim();try{meta[kv[1]]=JSON.parse(raw)}catch{meta[kv[1]]=raw}}return{meta,body:m[2].trim()}}const docsIn=dir=>existsSync(resolve(repoRoot,dir))?readdirSync(resolve(repoRoot,dir)).filter(f=>f.endsWith(".md")).sort().map(f=>({key:f.replace(/\.md$/,""),...readDoc(resolve(repoRoot,dir,f))})):[];const str=v=>typeof v==="string"?v:void 0;const strs=v=>Array.isArray(v)?v.map(String):[];function oneLine(text,fallback){const first=(text??"").split(/(?<=[.!?])\s+/)[0]?.trim()||fallback;if(first.length<=200)return first;return`${first.slice(0,199).replace(/\s+\S*$/,"")}\u2026`}function isPlainJson(v){if(v===null||["string","number","boolean"].includes(typeof v))return true;if(Array.isArray(v))return v.every(isPlainJson);if(typeof v!=="object"||Object.getPrototypeOf(v)!==Object.prototype||"$$typeof"in v)return false;return Object.values(v).every(x=>x===void 0||isPlainJson(x))}async function fixtureExamples(componentFile){const base=componentFile.replace(/\.tsx?$/,"");const file=[".fixtures.ts",".fixtures.tsx"].map(ext=>base+ext).find(f=>existsSync(f));if(!file)return[];const mod=await import(file);return(mod.scenarios??[]).filter(s=>["cell","whole-page"].includes(s.surface??"cell")&&s.props&&Object.keys(s.props).length>0&&isPlainJson(s.props)).map(s=>({name:s.name,props:JSON.parse(JSON.stringify(s.props))})).filter(s=>JSON.stringify(s.props).length<4e3).sort((a,b)=>JSON.stringify(a.props).length-JSON.stringify(b.props).length).slice(0,3)}const holdsReference=v=>typeof v==="string"&&(v.startsWith("@")||v.includes("${"))||(Array.isArray(v)?v.some(holdsReference):typeof v==="object"&&v!==null&&Object.values(v).some(holdsReference));async function loadVocabulary(){const file=["src/catalog/vocabulary.ts","src/catalog/vocabulary.tsx"].map(f=>resolve(repoRoot,f)).find(f=>existsSync(f));if(!file)return{};return(await import(pathToFileURL(file).href)).VOCABULARY??{}}async function buildKnowledge(bundle,org){const VOCABULARY=await loadVocabulary();const lineage=`${org}/${bundle}`;const entry=bundle==="main"?"src/index.ts":`src/index.${bundle}.ts`;const scopeLineage=`${org}/main`;const scopeRegistry=bundle==="main"||!existsSync(resolve(repoRoot,"src/index.ts"))?void 0:registryOf("src/index.ts");const bundleDocPath=resolve(repoRoot,"src/catalog",`${bundle}.md`);if(!existsSync(bundleDocPath))throw new Error(`[knowledge] no src/catalog/${bundle}.md: every lineage says how to compose a page from it.`);const bundleDoc=readDoc(bundleDocPath);const kits=docsIn("src/catalog/kits").filter(k=>(str(k.meta.bundle)??"main")===bundle);const patterns=docsIn("src/catalog/patterns").filter(p=>(str(p.meta.bundle)??"main")===bundle);const notPageCellFamilies=new Set(strs(bundleDoc.meta.not_page_cell_families));const pageCellExceptions=new Set(strs(bundleDoc.meta.page_cells));const notPageCells=new Set(strs(bundleDoc.meta.not_page_cells));const rows=[];const links=[];const registry={};rows.push({kind:"bundle",key:bundle,title:str(bundleDoc.meta.title)??lineage,summary:str(bundleDoc.meta.summary)??"",body:bundleDoc.body,attributes:{kits:kits.map(k=>k.key),patterns:patterns.map(p=>p.key)}});for(const kit of kits){const vocabulary=str(kit.meta.vocabulary);if(vocabulary&&!VOCABULARY[vocabulary])throw new Error(`[knowledge] kit ${kit.key} names vocabulary "${vocabulary}", which has no generator`);rows.push({kind:"kit",key:kit.key,title:str(kit.meta.title)??kit.key,summary:str(kit.meta.summary)??"",body:kit.body,tags:strs(kit.meta.tags),sort:Number(kit.meta.sort??0),attributes:{families:strs(kit.meta.families),...vocabulary?{vocabulary:VOCABULARY[vocabulary]()}:{}}})}const kitOfFamily=new Map(kits.flatMap(k=>strs(k.meta.families).map(f=>[f,k.key])));const cellId=name=>name.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase();for(const{name,source}of readExportsFromIndex(repoRoot,entry)){const file=resolveComponentFilePath(repoRoot,source.replace(/^\.\//,""));if(!file||!hasComponentJSDoc(file,name))continue;const j=parseJSDocFile(file,name);registry[name]={props_schema:j.props_schema??void 0};const family=j.family??"other";const pageCell=j.page_cell??(notPageCells.has(name)?false:pageCellExceptions.has(name)||!notPageCellFamilies.has(family));const weight=e=>(holdsReference(e)?1e6:0)+JSON.stringify(e).length;const examples=[...j.usage_examples??[]].sort((a,b)=>weight(a)-weight(b));const snippets=examples.length?examples.map((props,i)=>({title:`${name}, example ${i+1}`,cells:[{id:cellId(name),component:name,props}]})):(await fixtureExamples(file)).map(f=>({title:`${name}, ${f.name}`,cells:[{id:cellId(name),component:name,props:f.props}]}));const useCases=j.use_cases?.length?`
1
+ import{existsSync,readdirSync,readFileSync}from"node:fs";import{resolve}from"node:path";import{loadConfig}from"./config.mjs";import{hasComponentJSDoc,parseJSDocFile,readExportsFromIndex,resolveComponentFilePath,toDisplayName}from"./jsdoc-parser.mjs";import{pathToFileURL}from"node:url";import{REPO_ROOT as REPO_ROOT_}from"./root.mjs";import{lineageOfSpecifier,loadSharedLineages,unresolvedMessage}from"./shared-lineages.mjs";function qualifiedRef(value){const at=value.lastIndexOf(":");return at===-1?{key:value}:{lineage:value.slice(0,at),key:value.slice(at+1)}}function registryOf(entry){const out={};for(const{name,source}of readExportsFromIndex(repoRoot,entry)){const file=resolveComponentFilePath(repoRoot,source.replace(/^\.\//,""));if(file&&hasComponentJSDoc(file,name))out[name]={props_schema:parseJSDocFile(file,name).props_schema??void 0}}return out}const repoRoot=REPO_ROOT_;function readDoc(path){const text=readFileSync(path,"utf8");const m=text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);if(!m)throw new Error(`[knowledge] ${path} has no front matter`);const meta={};for(const line of m[1].split("\n")){const kv=line.match(/^([\w-]+):\s*(.*)$/);if(!kv)continue;const raw=kv[2].trim();try{meta[kv[1]]=JSON.parse(raw)}catch{meta[kv[1]]=raw}}return{meta,body:m[2].trim()}}const docsIn=dir=>existsSync(resolve(repoRoot,dir))?readdirSync(resolve(repoRoot,dir)).filter(f=>f.endsWith(".md")).sort().map(f=>({key:f.replace(/\.md$/,""),...readDoc(resolve(repoRoot,dir,f))})):[];function kitFamilies(){return[...new Set(docsIn("src/catalog/kits").flatMap(k=>strs(k.meta.families)))]}const str=v=>typeof v==="string"?v:void 0;const strs=v=>Array.isArray(v)?v.map(String):[];function oneLine(text,fallback){const first=(text??"").split(/(?<=[.!?])\s+/)[0]?.trim()||fallback;if(first.length<=200)return first;return`${first.slice(0,199).replace(/\s+\S*$/,"")}\u2026`}function isPlainJson(v){if(v===null||["string","number","boolean"].includes(typeof v))return true;if(Array.isArray(v))return v.every(isPlainJson);if(typeof v!=="object"||Object.getPrototypeOf(v)!==Object.prototype||"$$typeof"in v)return false;return Object.values(v).every(x=>x===void 0||isPlainJson(x))}async function fixtureExamples(componentFile){const base=componentFile.replace(/\.tsx?$/,"");const file=[".fixtures.ts",".fixtures.tsx"].map(ext=>base+ext).find(f=>existsSync(f));if(!file)return[];const mod=await import(file);return(mod.scenarios??[]).filter(s=>["cell","whole-page"].includes(s.surface??"cell")&&s.props&&Object.keys(s.props).length>0&&isPlainJson(s.props)).map(s=>({name:s.name,props:JSON.parse(JSON.stringify(s.props))})).filter(s=>JSON.stringify(s.props).length<4e3).sort((a,b)=>JSON.stringify(a.props).length-JSON.stringify(b.props).length).slice(0,3)}const holdsReference=v=>typeof v==="string"&&(v.startsWith("@")||v.includes("${"))||(Array.isArray(v)?v.some(holdsReference):typeof v==="object"&&v!==null&&Object.values(v).some(holdsReference));async function loadVocabulary(){const file=["src/catalog/vocabulary.ts","src/catalog/vocabulary.tsx"].map(f=>resolve(repoRoot,f)).find(f=>existsSync(f));if(!file)return{};return(await import(pathToFileURL(file).href)).VOCABULARY??{}}async function buildKnowledge(bundle,org){const VOCABULARY=await loadVocabulary();const lineage=`${org}/${bundle}`;const entry=bundle==="main"?"src/index.ts":`src/index.${bundle}.ts`;const scopeLineage=`${org}/main`;const scopeRegistry=bundle==="main"||!existsSync(resolve(repoRoot,"src/index.ts"))?void 0:registryOf("src/index.ts");const config=await loadConfig();const self=config.lineages.find(l=>l.name===bundle);const ownShared=config.lineages.filter(l=>l.kind==="shared"&&l.name!==bundle&&l.specifier&&[...config.shared,...self?.shared??[]].includes(l.specifier)&&existsSync(resolve(repoRoot,l.entry))).map(l=>({lineage:lineageOfSpecifier(l.specifier),registry:registryOf(l.entry)}));const imported=await loadSharedLineages(config);if(imported.unresolved.length)throw new Error(`[knowledge] ${imported.unresolved.map(s=>imported.error??unresolvedMessage(s)).join("; ")}`);const shared=[...ownShared,...imported.lineages.map(l=>({lineage:l.lineage,registry:Object.fromEntries([...l.names].map(n=>[n,{}]))}))];const sharedLineages=new Set(shared.map(s=>s.lineage));const bundleDocPath=resolve(repoRoot,"src/catalog",`${bundle}.md`);if(!existsSync(bundleDocPath))throw new Error(`[knowledge] no src/catalog/${bundle}.md: every lineage says how to compose a page from it.`);const bundleDoc=readDoc(bundleDocPath);const kits=docsIn("src/catalog/kits").filter(k=>(str(k.meta.bundle)??"main")===bundle);const patterns=docsIn("src/catalog/patterns").filter(p=>(str(p.meta.bundle)??"main")===bundle);const notPageCellFamilies=new Set(strs(bundleDoc.meta.not_page_cell_families));const pageCellExceptions=new Set(strs(bundleDoc.meta.page_cells));const notPageCells=new Set(strs(bundleDoc.meta.not_page_cells));const rows=[];const links=[];const registry={};rows.push({kind:"bundle",key:bundle,title:str(bundleDoc.meta.title)??lineage,summary:str(bundleDoc.meta.summary)??"",body:bundleDoc.body,attributes:{kits:kits.map(k=>k.key),patterns:patterns.map(p=>p.key),...shared.length?{shared:shared.map(s=>s.lineage)}:{}}});for(const kit of kits){const vocabulary=str(kit.meta.vocabulary);if(vocabulary&&!VOCABULARY[vocabulary])throw new Error(`[knowledge] kit ${kit.key} names vocabulary "${vocabulary}", which has no generator`);rows.push({kind:"kit",key:kit.key,title:str(kit.meta.title)??kit.key,summary:str(kit.meta.summary)??"",body:kit.body,tags:strs(kit.meta.tags),sort:Number(kit.meta.sort??0),attributes:{families:strs(kit.meta.families),...vocabulary?{vocabulary:VOCABULARY[vocabulary]()}:{}}})}const kitOfFamily=new Map(kits.flatMap(k=>strs(k.meta.families).map(f=>[f,k.key])));const cellId=name=>name.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase();for(const{name,source}of readExportsFromIndex(repoRoot,entry)){const file=resolveComponentFilePath(repoRoot,source.replace(/^\.\//,""));if(!file||!hasComponentJSDoc(file,name))continue;const j=parseJSDocFile(file,name);registry[name]={props_schema:j.props_schema??void 0};const family=j.family??"other";const pageCell=j.page_cell??(notPageCells.has(name)?false:pageCellExceptions.has(name)||!notPageCellFamilies.has(family));const weight=e=>(holdsReference(e)?1e6:0)+JSON.stringify(e).length;const examples=[...j.usage_examples??[]].sort((a,b)=>weight(a)-weight(b));const snippets=examples.length?examples.map((props,i)=>({title:`${name}, example ${i+1}`,cells:[{id:cellId(name),component:name,props}]})):(await fixtureExamples(file)).map(f=>({title:`${name}, ${f.name}`,cells:[{id:cellId(name),component:name,props:f.props}]}));const useCases=j.use_cases?.length?`
2
2
 
3
3
  Use it for:
4
- ${j.use_cases.map(u=>`- ${u}`).join("\n")}`:"";rows.push({kind:"component",key:name,title:toDisplayName(name),summary:oneLine(j.summary??j.description,`${toDisplayName(name)}.`),body:`${j.agent_instructions??j.description??""}${useCases}`.trim()||null,tags:j.tags??[],snippets,page_cell:pageCell,attributes:{family,...j.print_policy||j.size?{capabilities:{...j.print_policy?{print_policy:j.print_policy}:{},...j.size?{size:true}:{}}}:{}}});const kit=j.kit??kitOfFamily.get(family);if(kit)links.push({from:{kind:"component",key:name},relation:"part_of",to:{kind:"kit",key:kit}});for(const l of j.links??[])links.push({from:{kind:"component",key:name},relation:l.relation,to:{kind:"component",key:l.to},note:l.note})}for(const pattern of patterns){const slug=str(pattern.meta.page);if(!slug)throw new Error(`[knowledge] pattern ${pattern.key} names no page`);const dir=slug.includes("/")?resolve(repoRoot,"src",slug):resolve(repoRoot,"src/pages",slug);const page=JSON.parse(readFileSync(resolve(dir,"page.json"),"utf8"));const mock=existsSync(resolve(dir,"mock.json"))?JSON.parse(readFileSync(resolve(dir,"mock.json"),"utf8")):{};const data={...mock.data??{}};delete data.params;rows.push({kind:"pattern",key:pattern.key,title:str(pattern.meta.title)??pattern.key,summary:str(pattern.meta.summary)??"",body:pattern.body,tags:strs(pattern.meta.tags),sort:Number(pattern.meta.sort??0),snippets:[{title:str(pattern.meta.title)??pattern.key,intent:str(pattern.meta.summary),cells:page.content.cells,layout:page.layout,data}],attributes:{layout:page.layout?.type??"stack",...page.layout?.profile?{profile:page.layout.profile}:{},page:slug}});for(const component of new Set([...page.content.cells.map(c=>c.component).filter(c=>Boolean(c)),...strs(pattern.meta.uses)])){const inMain=scopeRegistry&&!registry[component]&&scopeRegistry[component];links.push({from:{kind:"pattern",key:pattern.key},relation:"uses",to:{...inMain?{lineage:scopeLineage}:{},kind:"component",key:component}})}for(const kit of strs(pattern.meta.kits))links.push({from:{kind:"pattern",key:pattern.key},relation:"uses",to:{kind:"kit",key:kit}})}return{lineage,rows,links,registry,...scopeRegistry?{scopeLineage,scopeRegistry}:{}}}export{buildKnowledge,holdsReference};
4
+ ${j.use_cases.map(u=>`- ${u}`).join("\n")}`:"";rows.push({kind:"component",key:name,title:toDisplayName(name),summary:oneLine(j.summary??j.description,`${toDisplayName(name)}.`),body:`${j.agent_instructions??j.description??""}${useCases}`.trim()||null,tags:j.tags??[],snippets,page_cell:pageCell,attributes:{family,...j.print_policy||j.size?{capabilities:{...j.print_policy?{print_policy:j.print_policy}:{},...j.size?{size:true}:{}}}:{}}});const kit=j.kit??kitOfFamily.get(family);if(kit)links.push({from:{kind:"component",key:name},relation:"part_of",to:{kind:"kit",key:kit}});for(const l of j.links??[])links.push({from:{kind:"component",key:name},relation:l.relation,to:{kind:"component",key:l.to},note:l.note})}for(const pattern of patterns){const slug=str(pattern.meta.page);if(!slug)throw new Error(`[knowledge] pattern ${pattern.key} names no page`);const dir=slug.includes("/")?resolve(repoRoot,"src",slug):resolve(repoRoot,"src/pages",slug);const page=JSON.parse(readFileSync(resolve(dir,"page.json"),"utf8"));const mock=existsSync(resolve(dir,"mock.json"))?JSON.parse(readFileSync(resolve(dir,"mock.json"),"utf8")):{};const data={...mock.data??{}};delete data.params;rows.push({kind:"pattern",key:pattern.key,title:str(pattern.meta.title)??pattern.key,summary:str(pattern.meta.summary)??"",body:pattern.body,tags:strs(pattern.meta.tags),sort:Number(pattern.meta.sort??0),snippets:[{title:str(pattern.meta.title)??pattern.key,intent:str(pattern.meta.summary),cells:page.content.cells,layout:page.layout,data}],attributes:{layout:page.layout?.type??"stack",...page.layout?.profile?{profile:page.layout.profile}:{},page:slug}});for(const component of new Set([...page.content.cells.map(c=>c.component).filter(c=>Boolean(c)),...strs(pattern.meta.uses)])){const{lineage:named,key}=qualifiedRef(component);links.push({from:{kind:"pattern",key:pattern.key},relation:"uses",to:{...named?{lineage:named}:{},kind:"component",key}})}for(const kit of strs(pattern.meta.kits)){const{lineage:named,key}=qualifiedRef(kit);links.push({from:{kind:"pattern",key:pattern.key},relation:"uses",to:{...named?{lineage:named}:{},kind:"kit",key}})}}const inScopeLineages=new Set([lineage,...scopeRegistry?[scopeLineage]:[],...sharedLineages]);for(const link of links){const to=link.to;if(to.lineage){if(!inScopeLineages.has(to.lineage)){throw new Error(`[knowledge] ${link.from.kind} ${link.from.key} links to ${to.lineage}:${to.key}, but ${to.lineage} isn't in scope (${[...inScopeLineages].join(", ")})`)}const scope=shared.find(x=>x.lineage===to.lineage);if(scope&&to.kind==="component"&&!scope.registry[to.key]){throw new Error(`[knowledge] ${link.from.kind} ${link.from.key} links to ${to.lineage}:${to.key}, which ${to.lineage} doesn't export`)}if(to.lineage===lineage)delete to.lineage;continue}if(to.kind!=="component"||registry[to.key])continue;if(scopeRegistry?.[to.key])to.lineage=scopeLineage;else{const owner=shared.find(s=>s.registry[to.key]);if(owner)to.lineage=owner.lineage}}return{lineage,rows,links,registry,...scopeRegistry?{scopeLineage,scopeRegistry}:{},shared}}export{buildKnowledge,holdsReference,kitFamilies,qualifiedRef};
package/src/lib/run.mjs CHANGED
@@ -1 +1 @@
1
- import{spawnSync}from"node:child_process";import{existsSync}from"node:fs";import{dirname,resolve}from"node:path";import{fileURLToPath}from"node:url";import{REPO_ROOT}from"./root.mjs";const COMMANDS_DIR=resolve(dirname(fileURLToPath(import.meta.url)),"..","commands");const TSX=import.meta.resolve("tsx/esm");const COMMANDS={build:"build.mjs",deploy:"deploy.mjs",check:"check.mjs",harness:"harness.mjs","test-visual":"test-visual.mjs",template:"template.mjs",upload:"upload-bundle.mjs",register:"register-components.mjs",cutover:"cutover.mjs",knowledge:"emit-knowledge.mjs",contracts:"emit-contracts.mjs","bundle-size":"bundle-size.mjs","check-bundle-leak":"check-bundle-leak.mjs","validate-externals":"validate-externals.mjs","check-wired":"check-wired-bindings.mjs","check-routes":"check-template-routes.mjs","check-design-refs":"check-design-refs.mjs","check-skills":"check-skills.mjs","check-work-orders":"check-work-orders.mjs","check-cutover-ownership":"check-cutover-ownership.mjs","check-fixtures":"check-fixtures.mjs","check-props-schema":"check-props-schema.mjs","check-tokens":"check-tokens.mjs","check-css-scope":"check-css-scope.mjs"};function commandFile(name){if(!COMMANDS[name])return null;const file=resolve(COMMANDS_DIR,COMMANDS[name]);const ts=file.replace(/\.mjs$/,".mts");return existsSync(file)?file:existsSync(ts)?ts:null}function runCommand(name,args=[],env={}){const file=commandFile(name);if(!file)throw new Error(`[engine] unknown command "${name}"`);const r=spawnSync(process.execPath,["--import",TSX,file,...args],{cwd:REPO_ROOT,stdio:"inherit",env:{...process.env,ENGINE_ROOT:REPO_ROOT,...env}});return r.status??1}export{COMMANDS,commandFile,runCommand};
1
+ import{spawnSync}from"node:child_process";import{existsSync}from"node:fs";import{dirname,resolve}from"node:path";import{fileURLToPath}from"node:url";import{REPO_ROOT}from"./root.mjs";const COMMANDS_DIR=resolve(dirname(fileURLToPath(import.meta.url)),"..","commands");const TSX=import.meta.resolve("tsx/esm");const COMMANDS={build:"build.mjs",deploy:"deploy.mjs",check:"check.mjs",harness:"harness.mjs","test-visual":"test-visual.mjs",template:"template.mjs",upload:"upload-bundle.mjs",register:"register-components.mjs",cutover:"cutover.mjs",knowledge:"emit-knowledge.mjs",contracts:"emit-contracts.mjs","bundle-size":"bundle-size.mjs","check-bundle-leak":"check-bundle-leak.mjs","validate-externals":"validate-externals.mjs","check-wired":"check-wired-bindings.mjs","check-routes":"check-template-routes.mjs","check-design-refs":"check-design-refs.mjs","check-skills":"check-skills.mjs","check-work-orders":"check-work-orders.mjs","check-cutover-ownership":"check-cutover-ownership.mjs","check-fixtures":"check-fixtures.mjs","check-props-schema":"check-props-schema.mjs","check-tokens":"check-tokens.mjs","check-css-scope":"check-css-scope.mjs","check-inline-styles":"check-inline-styles.mjs","check-shadowing":"check-shadowing.mjs","check-theme":"check-theme.mjs"};function commandFile(name){if(!COMMANDS[name])return null;const file=resolve(COMMANDS_DIR,COMMANDS[name]);const ts=file.replace(/\.mjs$/,".mts");return existsSync(file)?file:existsSync(ts)?ts:null}function runCommand(name,args=[],env={}){const file=commandFile(name);if(!file)throw new Error(`[engine] unknown command "${name}"`);const r=spawnSync(process.execPath,["--import",TSX,file,...args],{cwd:REPO_ROOT,stdio:"inherit",env:{...process.env,ENGINE_ROOT:REPO_ROOT,...env}});return r.status??1}export{COMMANDS,commandFile,runCommand};
@@ -0,0 +1,23 @@
1
+ import type { LoadedConfig } from '../index.mjs';
2
+
3
+ export type SharedLineage = {
4
+ specifier: string;
5
+ lineage: string;
6
+ file: string;
7
+ external: boolean;
8
+ packageDir: string | null;
9
+ css: string | null;
10
+ names: Set<string>;
11
+ };
12
+ export type SharedLineages = {
13
+ specifiers: string[];
14
+ lineages: SharedLineage[];
15
+ unresolved: string[];
16
+ error?: string;
17
+ names: Map<string, string>;
18
+ valuesOf: (file: string) => Set<string>;
19
+ };
20
+ export function importedSpecifiers(config: Pick<LoadedConfig, 'lineages' | 'shared'>): string[];
21
+ export function lineageOfSpecifier(specifier: string): string;
22
+ export function loadSharedLineages(config: Pick<LoadedConfig, 'lineages' | 'shared'>, options?: { roots?: string[]; root?: string }): Promise<SharedLineages>;
23
+ export function unresolvedMessage(specifier: string): string;
@@ -0,0 +1 @@
1
+ import{existsSync,readFileSync}from"node:fs";import{dirname,join,resolve}from"node:path";import{REPO_ROOT}from"./root.mjs";function importedSpecifiers({lineages,shared=[]}){const own=new Set(lineages.filter(l=>l.kind==="shared").map(l=>l.specifier));return[...new Set([...shared,...lineages.flatMap(l=>l.shared??[])])].filter(s=>!own.has(s))}function lineageOfSpecifier(specifier){const m=/^@([^/]+)\/([^/]+)$/.exec(specifier);return m?`${m[1]}/${m[2]}`:specifier}async function loadTypeScript(){try{return(await import("typescript")).default}catch{return null}}function compilerOptions(ts,root=REPO_ROOT){const configPath=resolve(root,"tsconfig.json");return existsSync(configPath)?ts.parseJsonConfigFileContent(ts.readConfigFile(configPath,ts.sys.readFile).config,ts.sys,root).options:{module:ts.ModuleKind.ESNext,moduleResolution:ts.ModuleResolutionKind.Bundler,jsx:ts.JsxEmit.ReactJSX}}function packageDirOf(file){for(let dir=dirname(file);dir!==dirname(dir);dir=dirname(dir)){if(existsSync(join(dir,"package.json")))return dir}return null}function stylesheetOf(packageDir){if(!packageDir)return null;try{const pkg=JSON.parse(readFileSync(join(packageDir,"package.json"),"utf8"));const entry=pkg.exports?.["./index.css"];const target=typeof entry==="string"?entry:entry?.default??entry?.style;if(typeof target==="string"&&existsSync(resolve(packageDir,target)))return resolve(packageDir,target)}catch{}return existsSync(join(packageDir,"index.css"))?join(packageDir,"index.css"):null}function resolveSpecifier(ts,specifier,{options,from}){const resolved=ts.resolveModuleName(specifier,from,options,ts.sys).resolvedModule;if(!resolved?.resolvedFileName)return null;const external=Boolean(resolved.isExternalLibraryImport)||resolved.resolvedFileName.includes("/node_modules/");const packageDir=external?packageDirOf(resolved.resolvedFileName):null;return{specifier,lineage:lineageOfSpecifier(specifier),file:resolved.resolvedFileName,external,packageDir,css:stylesheetOf(packageDir)}}async function loadSharedLineages(config,{roots=[],root=REPO_ROOT}={}){const specifiers=importedSpecifiers(config);const empty={specifiers,lineages:[],unresolved:[],names:new Map,valuesOf:()=>new Set};if(!specifiers.length&&!roots.length)return empty;const ts=await loadTypeScript();if(!ts){if(!specifiers.length)return empty;return{...empty,unresolved:specifiers,error:"typescript is not installed: the engine reads shared lineages from their declarations"}}const options=compilerOptions(ts,root);const from=config.lineages.map(l=>resolve(root,l.entry)).find(f=>existsSync(f))??resolve(root,"src/index.ts");const resolved=[];const unresolved=[];for(const specifier of specifiers){const r=resolveSpecifier(ts,specifier,{options,from});if(r)resolved.push(r);else unresolved.push(specifier)}const program=ts.createProgram({rootNames:[...roots.filter(f=>existsSync(f)),...resolved.map(r=>r.file)],options:{...options,noEmit:true}});const checker=program.getTypeChecker();const valuesOf=file=>{const sf=program.getSourceFile(file);const symbol=sf&&checker.getSymbolAtLocation(sf);if(!symbol)return new Set;return new Set(checker.getExportsOfModule(symbol).filter(s=>{const target=s.flags&ts.SymbolFlags.Alias?checker.getAliasedSymbol(s):s;return(target.flags&ts.SymbolFlags.Value)!==0}).map(s=>s.name).filter(n=>/^[A-Z]/.test(n)))};const lineages=resolved.map(r=>({...r,names:valuesOf(r.file)}));const names=new Map;for(const l of lineages)for(const n of l.names)if(!names.has(n))names.set(n,l.specifier);return{specifiers,lineages,unresolved,names,valuesOf}}const unresolvedMessage=specifier=>`can't resolve ${specifier}: install it (npm:@peripheries/\u2026) or map it in tsconfig paths`;export{compilerOptions,importedSpecifiers,lineageOfSpecifier,loadSharedLineages,loadTypeScript,resolveSpecifier,unresolvedMessage};