@peripheries/engine 0.1.0 → 0.2.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 +2 -2
- package/src/commands/check-cutover-ownership.mjs +3 -0
- package/src/commands/check-design-refs.mjs +5 -0
- package/src/commands/check-fixtures.mjs +9 -0
- package/src/commands/check-props-schema.mjs +3 -0
- package/src/commands/check-skills.mjs +4 -0
- package/src/commands/check-template-routes.mjs +7 -0
- package/src/commands/check-wired-bindings.mjs +7 -0
- package/src/commands/check-work-orders.mjs +11 -0
- package/src/commands/check.mjs +5 -0
- package/src/index.d.mts +7 -1
- package/src/index.mjs +1 -1
- package/src/lib/citation-markers.mjs +1 -0
- package/src/lib/config.d.mts +1 -1
- package/src/lib/config.mjs +1 -1
- package/src/lib/floor.mjs +4 -0
- package/src/lib/run.mjs +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peripheries/engine",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Build, check, knowledge and deploy for peripheries component repos",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"node": ">=22"
|
|
13
13
|
},
|
|
14
14
|
"bin": {
|
|
15
|
-
"engine": "
|
|
15
|
+
"engine": "bin/engine.mjs"
|
|
16
16
|
},
|
|
17
17
|
"exports": {
|
|
18
18
|
".": {
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import{readFileSync,readdirSync,statSync}from"node:fs";import{dirname,extname,join,relative,resolve}from"node:path";import{fileURLToPath}from"node:url";import{assertNonVacuous}from"../lib/floor.mjs";import{REPO_ROOT as REPO_ROOT_}from"../lib/root.mjs";const REPO_ROOT=REPO_ROOT_;const ROOTS=["template","scripts","src",".claude/skills",".claude/commands"];const EXTENSIONS=new Set([".ts",".tsx",".mjs",".js",".md",".json"]);const SKIP_DIRS=new Set(["node_modules","dist","dist-bundles","__output__","__generated__"]);const SELF="scripts/check-cutover-ownership.mjs";const BANNED=[/run\s+(the\s+)?cutover\s+(from|in|out\s+of)\s+the\s+host/i,/cutover\s+(is\s+)?(run\s+)?(from|by)\s+the\s+host\s+repo/i,/use\s+the\s+host'?s?\s+(scripts\/)?cutover\.ts/i,/run\s+the\s+host'?s?\s+(own\s+)?(scripts\/)?cutover(\.ts)?/i,/promote\s+.{0,30}\bfrom\s+the\s+host\s+repo/i,/fix\s+(it\s+)?with\s+the\s+host'?s?\s+(scripts\/)?cutover\.ts/i,/cutover\s+(runs|lives|belongs)\s+(in|from)\s+the\s+host/i,/cutover\s+is\s+never\s+run\s+from\s+this\s+repo/i,/(any\s+)?leftover\s+cutover\.mjs\s+.{0,40}\bdefect/i];const EXCUSED=[/\b(do|does|must|should|will|can)\s*n[o']?t\b/i,/\bnever\s+(run\s+)?(use\s+)?the\s+host/i,/\brefus(e|es|ed|ing)\b/i,/\b(used\s+to|no\s+longer|until|before|was\s+moved|moved\s+back|historically|formerly|pre-move)\b/i,/\bidentical\s+(to|semantics)\b/i,/\bported\s+(from|byte-for-byte)\b/i,/\bstale\b/i,/\bdisregard\b/i,/\bREPO_OWNED_CUTOVER\b/];function walk(dir,out=[]){let entries;try{entries=readdirSync(dir,{withFileTypes:true})}catch{return out}for(const e of entries){if(e.name.startsWith(".")&&e.name!==".claude"){if(!dir.includes(".claude"))continue}const full=join(dir,e.name);if(e.isDirectory()){if(SKIP_DIRS.has(e.name))continue;walk(full,out)}else if(EXTENSIONS.has(extname(e.name))){out.push(full)}}return out}const files=ROOTS.flatMap(r=>{const abs=resolve(REPO_ROOT,r);try{return statSync(abs).isDirectory()?walk(abs):[abs]}catch{return[]}});const violations=[];let excusedCount=0;let scanned=0;for(const file of files){const where=relative(REPO_ROOT,file);if(where===SELF)continue;scanned++;const lines=readFileSync(file,"utf8").split("\n");lines.forEach((line,i)=>{if(!BANNED.some(re=>re.test(line)))return;const clauses=line.split(/\s+—\s+|\s+--\s+|[;.]\s+|\)\s+/);const guilty=clauses.filter(c=>BANNED.some(re=>re.test(c)));if(guilty.length>0&&guilty.every(c=>EXCUSED.some(re=>re.test(c)))){excusedCount++;return}if(guilty.length===0&&EXCUSED.some(re=>re.test(line))){excusedCount++;return}violations.push({where,line:i+1,text:line.trim()})})}if(violations.length>0){console.error(`[cutover-ownership] ${violations.length} place(s) tell a reader to run cutover from the host:
|
|
3
|
+
`);for(const v of violations){console.error(` ${v.where}:${v.line}`);console.error(` ${v.text.slice(0,160)}`)}console.error("");console.error(" Cutover runs from this repo, through the engine. The host has no");console.error(" cutover of its own to run, so an instruction to use one is not merely");console.error(" stale: following it fails, or writes the same rows a second way.");console.error("");console.error(" engine cutover --version <v> --develop");console.error("");console.error(" To NAME the host's script in order to forbid it, compare against it,");console.error(' or describe what it used to do, say so on the same line ("do not",');console.error(' "never", "refuses", "used to", "ported from"). Recording history is');console.error(" fine; instructing the reader to go and do it is not.");process.exit(1)}assertNonVacuous("cutover-ownership",{files:{count:scanned,unit:"operative file(s)"}});console.log(`[cutover-ownership] ok \u2014 ${scanned} operative file(s) scanned; ${excusedCount} line(s) name the host's script to forbid, compare or historicise it. docs/plans/** deliberately not scanned (records, not instructions).`);
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import{existsSync,readFileSync,readdirSync,statSync}from"node:fs";import{dirname,join,relative,resolve}from"node:path";import{fileURLToPath}from"node:url";import{assertNonVacuous}from"../lib/floor.mjs";import{REPO_ROOT as REPO_ROOT_}from"../lib/root.mjs";const REPO_ROOT=REPO_ROOT_;const DESIGN_EXT=String.raw`(?:dc\.html|html|md|png|jpg|jpeg|svg|pdf|fig)`;const CITATION=new RegExp(String.raw`(?<![A-Za-z0-9._-])(?:[A-Za-z0-9._-]+/)*design/[A-Za-z0-9 ()._/-]+?\.${DESIGN_EXT}`,"g");const DIR_CITATION=/(?<![A-Za-z0-9._-])design\/[A-Za-z0-9._-]+(?![A-Za-z0-9 ._/-])/g;const NON_DURABLE=[/^\.scratch\//,/^node_modules\//,/^dist/];const SOURCE_DIRS=["src"];const SOURCE_EXT=/\.(ts|tsx|mjs|js|md)$/;function walk(dir,out=[]){for(const entry of readdirSync(dir)){if(entry==="node_modules"||entry.startsWith("."))continue;const full=join(dir,entry);if(statSync(full).isDirectory())walk(full,out);else if(SOURCE_EXT.test(entry))out.push(full)}return out}function templateFiles(){const roots=["src/org_templates","src/project_templates"].map(d=>resolve(REPO_ROOT,d));const found=[];for(const root of roots){if(!existsSync(root))continue;for(const entry of readdirSync(root)){const file=join(root,entry,"template.json");if(existsSync(file))found.push(file);const pagesDir=join(root,entry,"pages");if(!existsSync(pagesDir))continue;for(const slug of readdirSync(pagesDir)){const pageFile=join(pagesDir,slug,"page.json");if(existsSync(pageFile))found.push(pageFile)}}}return found}const findings=[];function checkDirectory(refPath,where){const abs=resolve(REPO_ROOT,refPath);if(existsSync(abs)&&statSync(abs).isDirectory())return;findings.push({where,ref:refPath,why:existsSync(abs)?"is cited as an initiative directory but is a file":"does not exist in this repo"})}function check(refPath,where){const nonDurable=NON_DURABLE.find(re=>re.test(refPath));if(nonDurable){findings.push({where,ref:refPath,why:"points into a gitignored / generated location \u2014 it exists on at most one machine"});return}if(!existsSync(resolve(REPO_ROOT,refPath))){findings.push({where,ref:refPath,why:"does not exist in this repo"})}}for(const file of templateFiles()){const where=relative(REPO_ROOT,file);let json;try{json=JSON.parse(readFileSync(file,"utf8"))}catch(err){console.error(`[design-refs] could not parse ${where}: ${err.message}`);process.exit(1)}for(const ref of json?.metadata?.design_refs??[])check(ref,`${where} \u2192 metadata.design_refs`)}let cited=0;for(const dir of SOURCE_DIRS){const root=resolve(REPO_ROOT,dir);if(!existsSync(root))continue;for(const file of walk(root)){const where=relative(REPO_ROOT,file);if(where==="scripts/check-design-refs.mjs")continue;const raw=readFileSync(file,"utf8");const joined=raw.replace(/\/\n\s*(?:\/\/|\*)[ \t]?/g,"/").replace(/\n\s*(?:\/\/|\*)[ \t]?/g," ").replace(/[ \t]{2,}/g," ");const text=`${raw}
|
|
3
|
+
${joined}`;const seen=new Set;for(const match of text.matchAll(CITATION)){const ref=match[0].replace(/[ .]+$/,"");if(seen.has(ref))continue;seen.add(ref);cited++;check(ref,where)}for(const match of text.matchAll(DIR_CITATION)){const ref=match[0];if(seen.has(ref))continue;if([...seen].some(f=>f.startsWith(`${ref}/`)))continue;seen.add(ref);cited++;checkDirectory(ref,where)}}}if(findings.length>0){console.error(`[design-refs] ${findings.length} dangling design reference(s):
|
|
4
|
+
`);for(const f of findings){console.error(` ${f.where}`);console.error(` "${f.ref}"`);console.error(` ${f.why}
|
|
5
|
+
`)}console.error("Fix by pointing at the in-repo source under design/<initiative>/, or by removing the\nreference if no durable source exists. Do not point at .scratch/ \u2014 that is how the\nswiss design-system document was lost.");process.exit(1)}assertNonVacuous("design-refs",{citations:{count:cited,unit:"source citation(s)"}});console.log(`[design-refs] ok \u2014 ${cited} source citation(s) and every template design_ref resolve in-repo.`);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
import{existsSync,readFileSync,readdirSync,statSync,writeFileSync}from"node:fs";import{dirname,join,relative,resolve}from"node:path";import{hasComponentJSDoc,parseJSDocFile,readExportsFromIndex,resolveComponentFilePath,toRepoRelative}from"../lib/jsdoc-parser.mjs";import{REPO_ROOT as REPO_ROOT_}from"../lib/root.mjs";const REPO_ROOT=REPO_ROOT_;const argv=process.argv.slice(2);const STRICT=argv.includes("--strict");const UPDATE_BASELINE=argv.includes("--update-baseline");const BASELINE_PATH=resolve(REPO_ROOT,"scripts/__baselines__/fixtures.json");function readBaseline(){if(!existsSync(BASELINE_PATH))return{classes:{}};return JSON.parse(readFileSync(BASELINE_PATH,"utf8"))}function barrels(){const out=["src/index.ts"];for(const f of readdirSync(join(REPO_ROOT,"src"))){if(/^index\.[a-z0-9-]+\.ts$/.test(f)&&!f.endsWith(".test.ts"))out.push(`src/${f}`)}return out}function walk(dir,pred,out=[]){if(!existsSync(dir))return out;for(const e of readdirSync(dir)){const full=join(dir,e);if(statSync(full).isDirectory())walk(full,pred,out);else if(pred(full))out.push(full)}return out}function storeDeclaringComponents(){const found=new Set;const pages=["src/project_templates","src/org_templates"].flatMap(r=>walk(join(REPO_ROOT,r),f=>f.endsWith("page.json")));for(const file of pages){let json;try{json=JSON.parse(readFileSync(file,"utf8"))}catch{continue}const cells=json?.content?.cells??[];for(const cell of cells){if(!(cell?.selections||cell?.viewState||cell?.live))continue;if(typeof cell.component==="string")found.add(cell.component)}}return found}const NARROW_PX=480;const isLoading=s=>s.status?.isLoading===true;const isError=s=>s.status?.isError===true||Boolean(s.status?.errors);const isNarrow=s=>typeof s.viewport?.width==="number"&&s.viewport.width<=NARROW_PX;const declaresStore=s=>Boolean(s.selections||s.viewState||s.live||s.storeFixture);function arrayProp(s,name){const v=s.props?.[name]??s.data?.[name];return Array.isArray(v)?v:void 0}async function coveredElsewhere(){const out=new Map;for(const file of walk(join(REPO_ROOT,"src/components"),f=>/\.fixtures\.tsx?$/.test(f))){let mod;try{mod=await import(file)}catch{continue}for(const name of mod.covers??[])out.set(name,toRepoRelative(REPO_ROOT,file))}return out}const storeComponents=storeDeclaringComponents();const covered=await coveredElsewhere();const findings=[];const byClass=new Map;const seen=new Set;let components=0;function record(cls,ok,component,message){const bucket=byClass.get(cls)??{checked:0,failed:0};bucket.checked++;if(!ok){bucket.failed++;findings.push({component,cls,message})}byClass.set(cls,bucket)}for(const entry of barrels()){for(const{name,source}of readExportsFromIndex(REPO_ROOT,entry)){if(seen.has(name))continue;const filePath=resolveComponentFilePath(REPO_ROOT,source);if(!filePath||!hasComponentJSDoc(filePath,name))continue;seen.add(name);components++;const base=filePath.replace(/\.(tsx?|jsx?)$/,"");const byExport=join(dirname(filePath),name);const fixtureFile=[`${base}.fixtures.tsx`,`${base}.fixtures.ts`,`${byExport}.fixtures.tsx`,`${byExport}.fixtures.ts`].find(existsSync);if(!fixtureFile&&covered.has(name)){record("exists",true,name,"");continue}record("exists",Boolean(fixtureFile),name,`no fixtures file beside ${toRepoRelative(REPO_ROOT,filePath)}`);if(!fixtureFile)continue;let scenarios=[];try{const mod=await import(fixtureFile);scenarios=mod.scenarios??mod.default?.scenarios??[]}catch(err){const why=err instanceof Error?err.message:String(err);record("exists",false,name,`fixtures module failed to import: ${why.slice(0,120)}`);continue}record("exists",scenarios.length>0,name,"fixtures module declares no scenarios");const first=scenarios[0];if(!first)continue;record("intent",Boolean(first.designIntent),name,`default scenario "${first.name}" has no designIntent \u2014 the eval step skips it silently`);record("narrow",scenarios.some(isNarrow),name,`no scenario under ${NARROW_PX}px \u2014 reflow is unproven`);const jsdoc=parseJSDocFile(filePath,name)??{};const props=jsdoc.props_schema?.properties??{};const propNames=Object.keys(props);const dataBearing=Boolean(jsdoc.props_schema?.data)||propNames.includes("data")||propNames.includes("status");if(dataBearing){record("loading",scenarios.some(isLoading),name,"declares host data but no loading scenario");record("error",scenarios.some(isError),name,"declares host data but no error scenario")}const required=jsdoc.props_schema?.required??[];const listProps=propNames.filter(p=>props[p]?.type==="array"&&required.includes(p));for(const p of listProps){const values=scenarios.map(s=>arrayProp(s,p)).filter(Boolean);if(values.length===0)continue;record("empty",values.some(v=>v.length===0),name,`array prop "${p}" is never empty in any scenario`);record("dense",values.some(v=>v.length>=5),name,`array prop "${p}" never carries 5+ items \u2014 density is unproven`)}if(storeComponents.has(name)){record("store",scenarios.some(declaresStore),name,"a page.json cell declares store channels for this component, but no scenario does")}}}const order=["exists","intent","narrow","loading","error","empty","dense","store"];const baseline=readBaseline();const observed={};for(const cls of order)observed[cls]=byClass.get(cls)?.failed??0;if(UPDATE_BASELINE){const raised=order.filter(c=>observed[c]>(baseline.classes[c]??0));writeFileSync(BASELINE_PATH,JSON.stringify({_comment:"Per-class fixture-coverage failure counts. A FLOOR TO BE LOWERED, never raised \u2014 exceeding one fails `npm run check:fixtures`. Regenerate with `npm run check:fixtures -- --update-baseline` only when a number goes DOWN. See the header of scripts/check-fixtures.mts.",classes:observed},null,2)+"\n","utf8");console.log(`[fixtures] baseline written to ${relative(REPO_ROOT,BASELINE_PATH)}`);if(raised.length>0){console.warn(`
|
|
3
|
+
[fixtures] WARNING \u2014 ${raised.length} class(es) went UP and were recorded anyway:
|
|
4
|
+
`+raised.map(c=>` ${c}: ${baseline.classes[c]??0} -> ${observed[c]}`).join("\n")+"\n\n A floor is meant to be lowered. Raising one records a regression as\n acceptable, which is exactly what this file exists to prevent. If that is\n deliberate, say why in the commit message.")}process.exit(0)}console.log(`[fixtures] ${components} components
|
|
5
|
+
`);const regressions=[];for(const cls of order){const b=byClass.get(cls);if(!b)continue;const floor=baseline.classes[cls]??0;const now=observed[cls];if(STRICT?now>0:now>floor)regressions.push({cls,was:floor,now});const verdict=now===0?"ok":now>floor?`${now} (floor ${floor}) OVER`:`${now}/${floor}`;console.log(` ${cls.padEnd(8)} ${String(b.checked-now).padStart(4)}/${String(b.checked).padEnd(4)} ${verdict}`)}function print(list,label){if(list.length===0)return;console.log(`
|
|
6
|
+
${label}:`);for(const f of list)console.log(` ${f.component} [${f.cls}] \u2014 ${f.message}`)}if(regressions.length>0){const worse=new Set(regressions.map(r=>r.cls));print(findings.filter(f=>worse.has(f.cls)),"Gaps in the class(es) that regressed");console.error(`
|
|
7
|
+
[fixtures] ${regressions.length} class(es) above their baseline floor:
|
|
8
|
+
`+regressions.map(r=>` ${r.cls}: floor ${r.was}, now ${r.now}`).join("\n")+"\n\n Add the missing scenario, or \u2014 if the count is genuinely correct and\n higher \u2014 run `npm run check:fixtures -- --update-baseline` and say why.\n A floor is meant to be lowered.");process.exit(1)}print(findings,"Open gaps (at or under the floor \u2014 not failing, but this is the backlog)");const total=Object.values(observed).reduce((a,b)=>a+b,0);console.log(`
|
|
9
|
+
[fixtures] ok \u2014 no class above its baseline floor`+(total?`; ${total} gap(s) remain in the backlog above.`:"; every class is clean."));
|
|
@@ -0,0 +1,3 @@
|
|
|
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);
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import{existsSync,readFileSync,readdirSync,statSync}from"node:fs";import{dirname,join,relative,resolve}from"node:path";import{fileURLToPath}from"node:url";import{FOREIGN,HISTORICAL,PLACEHOLDER,markerWindow}from"../lib/citation-markers.mjs";import{declBody,topLevelFields,topLevelKeys}from"../lib/contract-shape.mjs";import{assertNonVacuous}from"../lib/floor.mjs";import{REPO_ROOT as REPO_ROOT_}from"../lib/root.mjs";const REPO_ROOT=REPO_ROOT_;const DOC_ROOTS=[".claude/skills",".claude/commands"];const DOC_FILES=[".claude/CLAUDE.md"];const ROOTS=String.raw`src|scripts|template|docs|test|design|outlines|\.claude|\.github`;const PATH_RE=new RegExp(String.raw`(?<![A-Za-z0-9._@/-])((?:${ROOTS})/[A-Za-z0-9._/*{},-]*[A-Za-z0-9_*}])`,"g");const PARENT_RELATIVE_RE=/(?<![A-Za-z0-9._-])((?:\.\.\/)+[A-Za-z0-9._/-]*[A-Za-z0-9_])/g;const IMPORTISH=/\b(import|require|from)\b/;const NPM_RE=/npm run (?!--)([A-Za-z0-9:_-]+)/g;const OTHER_PM_RE=/(?<![A-Za-z0-9-])(pnpm|yarn)\s+(?!--)([A-Za-z0-9:@/._-]+)/g;const EXT_BOUNDARY=/\.(json|ts|tsx|mjs|mts|js|jsx|md|html|css|ya?ml)\./;function filePart(ref){const m=EXT_BOUNDARY.exec(ref);return m?ref.slice(0,m.index+m[0].length-1):ref}const TRY_EXT=["",".ts",".tsx",".mjs",".mts",".js",".json",".md"];const TRY_INDEX=["index.ts","index.tsx","index.mjs","SKILL.md"];function resolves(p,base=REPO_ROOT){const abs=resolve(base,p);for(const ext of TRY_EXT)if(existsSync(abs+ext))return true;for(const idx of TRY_INDEX)if(existsSync(join(abs,idx)))return true;return false}function walk(dir,out=[]){if(!existsSync(dir))return out;for(const entry of readdirSync(dir)){const full=join(dir,entry);if(statSync(full).isDirectory())walk(full,out);else if(entry.endsWith(".md"))out.push(full)}return out}function allowedMissing(text){const fm=/^---\n([\s\S]*?)\n---/.exec(text);if(!fm)return new Set;const inline=/^allowed_missing:\s*\[(.*)\]\s*$/m.exec(fm[1]);if(inline)return new Set(inline[1].split(",").map(s=>s.trim().replace(/^['"]|['"]$/g,"")));const block=/^allowed_missing:\s*\n((?:\s+-\s+.*\n?)+)/m.exec(fm[1]);if(!block)return new Set;return new Set([...block[1].matchAll(/-\s+(.*)/g)].map(m=>m[1].trim()))}const scripts=new Set(Object.keys(JSON.parse(readFileSync(join(REPO_ROOT,"package.json"),"utf8")).scripts??{}));const files=[...DOC_ROOTS.flatMap(d=>walk(resolve(REPO_ROOT,d))),...DOC_FILES.map(f=>resolve(REPO_ROOT,f)).filter(existsSync)];const findings=[];let checkedPaths=0;let checkedScripts=0;let exempted=0;for(const file of files){const where=relative(REPO_ROOT,file);if(where==="scripts/check-skills.mjs")continue;const text=readFileSync(file,"utf8");const allowed=allowedMissing(text);const lines=text.split("\n");let inFence=false;lines.forEach((line,i)=>{const at=`${where}:${i+1}`;if(line.trim().startsWith("```")){inFence=!inFence;return}const window=markerWindow(lines,i);for(const m of line.matchAll(NPM_RE)){const name=m[1];checkedScripts++;if(scripts.has(name))continue;if(HISTORICAL.test(window)||FOREIGN.test(window)||allowed.has(name)){exempted++;continue}findings.push({at,ref:`npm run ${name}`,why:"no such script in package.json",line})}for(const m of line.matchAll(OTHER_PM_RE)){if(HISTORICAL.test(window)||FOREIGN.test(window)||allowed.has(m[0])){exempted++;continue}findings.push({at,ref:m[0],why:`this repo is npm-only \u2014 say \`npm run \u2026\`, or mark the line as the host's`,line})}for(const m of inFence||IMPORTISH.test(line)?[]:line.matchAll(PARENT_RELATIVE_RE)){const ref=filePart(m[1]);if(/[*{]/.test(ref))continue;if(/(^|\/)(__output__|__snapshots__|dist|dist-bundles|node_modules)(\/|$)/.test(ref))continue;if(/^(\.\.\/)+$/.test(ref)||!/[A-Za-z0-9]/.test(ref.replace(/[./]/g,"")))continue;checkedPaths++;if(resolves(ref,dirname(file)))continue;if(allowed.has(ref)){exempted++;continue}if(PLACEHOLDER.test(ref)){exempted++;continue}if(HISTORICAL.test(window)||FOREIGN.test(window)){exempted++;continue}findings.push({at,ref,why:"does not exist relative to this file",line})}for(const m of line.matchAll(PATH_RE)){const ref=filePart(m[1]);if(/[*{]/.test(ref))continue;if(/(^|\/)(__output__|__snapshots__|dist|dist-bundles|node_modules)(\/|$)/.test(ref))continue;checkedPaths++;if(resolves(ref))continue;if(allowed.has(ref)){exempted++;continue}if(PLACEHOLDER.test(ref)){exempted++;continue}if(HISTORICAL.test(window)||FOREIGN.test(window)){exempted++;continue}findings.push({at,ref,why:"does not exist in this repo",line})}})}const CONTRACT_SRC="src/types/host-contract.ts";const contractSrc=existsSync(join(REPO_ROOT,CONTRACT_SRC))?readFileSync(join(REPO_ROOT,CONTRACT_SRC),"utf8"):null;let contractChecked=0;if(contractSrc){const exported=new Set([...contractSrc.matchAll(/export\s+(?:interface|type)\s+([A-Z]\w+)/g)].map(m=>m[1]));for(const file of files){const where=relative(REPO_ROOT,file);const text=readFileSync(file,"utf8");for(const block of text.matchAll(/```(?:ts|tsx|typescript)\n([\s\S]*?)```/g)){for(const decl of block[1].matchAll(/(?:export\s+)?(?:interface|type)\s+([A-Z]\w+)/g)){const name=decl[1];if(!exported.has(name))continue;const docFields=topLevelFields(declBody(block[1],name));const srcFields=topLevelFields(declBody(contractSrc,name));if(docFields===null||srcFields===null)continue;contractChecked++;const dk=[...docFields.keys()].sort();const sk=[...srcFields.keys()].sort();if(dk.join("|")!==sk.join("|")){findings.push({at:where,ref:name,why:`quoted shape has drifted from ${CONTRACT_SRC} \u2014 doc has [${dk.join(", ")}], source has [${sk.join(", ")}]`,line:`(fenced ts block declaring ${name})`});continue}for(const[field,srcType]of srcFields){const alias=/^([A-Z]\w+)$/.exec(srcType.trim());if(!alias)continue;const docInline=docFields.get(field)??"";if(!docInline.includes("{"))continue;const nestedDoc=topLevelKeys(docInline.slice(docInline.indexOf("{")+1,docInline.lastIndexOf("}")));const nestedSrc=topLevelKeys(declBody(contractSrc,alias[1]));if(nestedDoc===null||nestedSrc===null)continue;contractChecked++;if(nestedDoc.join("|")===nestedSrc.join("|"))continue;findings.push({at:where,ref:`${name}.${field}`,why:`inlined ${alias[1]} has drifted from ${CONTRACT_SRC} \u2014 doc has [${nestedDoc.join(", ")}], source has [${nestedSrc.join(", ")}]`,line:`(fenced ts block declaring ${name})`})}}}}}if(findings.length>0){console.error(`[skills] ${findings.length} dangling reference(s) in agent documentation:
|
|
3
|
+
`);for(const f of findings){console.error(` ${f.at}`);console.error(` "${f.ref}" \u2014 ${f.why}`);console.error(` ${f.line.trim().slice(0,120)}
|
|
4
|
+
`)}console.error('Fix the prose. If the reference is deliberate history, say so ON THE SAME LINE\n("retired 2026-08-08", "deleted", "no longer exists"); if it is a path in the HOST\nrepo, say "the host\'s \u2026". Both are recognised and exempt. Adding a path to a skill\'s\nfront-matter `allowed_missing:` is the last resort, not the first.');process.exit(1)}assertNonVacuous("skills",{paths:{count:checkedPaths,unit:"path citation(s)"},scripts:{count:checkedScripts,unit:"npm script(s)"},contractTypes:{count:contractChecked,unit:"quoted contract type(s)"}});console.log(`[skills] ok \u2014 ${checkedPaths} path citation(s) and ${checkedScripts} npm script(s) across ${files.length} document(s) resolve; ${exempted} exempt (historical / foreign / placeholder).`);console.log(`[skills] ok \u2014 ${contractChecked} quoted contract type(s) match ${CONTRACT_SRC} (field names and optionality; not type text \u2014 see this script's header).`);
|
|
@@ -0,0 +1,7 @@
|
|
|
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}.
|
|
3
|
+
`)}if(contractSkipReason){console.warn(`[check-template-routes] registry-id checks SKIPPED \u2014 ${contractSkipReason}.
|
|
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
|
+
`);for(const w of warnings)console.warn(` \xB7 ${w}`);if(notGated.length>0){console.warn(`
|
|
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.")}
|
|
@@ -0,0 +1,7 @@
|
|
|
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.
|
|
3
|
+
`)}if(e10SkipReason){console.warn(`[check-wired-bindings] E10 SKIPPED \u2014 ${e10SkipReason}. Registry ids unchecked.
|
|
4
|
+
`)}if(warnings.length>0){console.warn(`[check-wired-bindings] ${warnings.length} warning(s):
|
|
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
|
+
`)}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.")}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import{existsSync,readFileSync,readdirSync,statSync}from"node:fs";import{dirname,join,relative,resolve}from"node:path";import{fileURLToPath}from"node:url";import{HISTORICAL,markerWindowAt}from"../lib/citation-markers.mjs";import{namesAnotherRepo,orgSlug,stripSelfPrefix}from"../lib/repo-identity.mjs";import{assertNonVacuous}from"../lib/floor.mjs";import{REPO_ROOT as REPO_ROOT_}from"../lib/root.mjs";const LOOKBACK=1;const REPO_ROOT=REPO_ROOT_;const SCAN_DIRS=["src","scripts","test","design","docs","outlines","template",".claude"];const SCAN_EXT=/\.(ts|tsx|mts|mjs|js|jsx|md|json)$/;const ABSOLUTE=/(?<![A-Za-z0-9._/-])((?:[A-Za-z0-9_-]+\/)?docs\/(?:plans\/[A-Za-z0-9._-]+\/)?work-orders\/[A-Za-z0-9._-]+\.md)/g;const PARENT_RELATIVE=/((?:\.\.\/)+(?:[A-Za-z0-9._-]+\/)*work-orders\/[A-Za-z0-9._-]+\.md)/g;const RELATIVE=/(?<![A-Za-z0-9._/-])(work-orders\/[A-Za-z0-9._-]+\.md)/g;const DIR_CITATION=/(?<![A-Za-z0-9._/-])((?:[A-Za-z0-9._-]+\/)*(?:plans\/[A-Za-z0-9._-]+\/)?work-orders)\/(?![A-Za-z0-9._-]*\.md|<)/g;function walk(dir,out=[]){if(!existsSync(dir))return out;for(const entry of readdirSync(dir)){if(entry==="node_modules"||entry==="__output__"||entry.startsWith(".git"))continue;const full=join(dir,entry);if(statSync(full).isDirectory())walk(full,out);else if(SCAN_EXT.test(entry))out.push(full)}return out}function markerWindowWithout(text,index,ref){return markerWindowAt(text,index,LOOKBACK).split(ref).join(" ")}const findings=[];let checked=0;let exempted=0;let vendoredSkipped=0;const foreignPrefixes=new Set;const VENDORED_BANNER=/VENDORED FROM THE HOST/;for(const dir of SCAN_DIRS){for(const file of walk(resolve(REPO_ROOT,dir))){const where=relative(REPO_ROOT,file);if(where==="scripts/check-work-orders.mjs")continue;const text=readFileSync(file,"utf8");if(VENDORED_BANNER.test(text.slice(0,400))){vendoredSkipped++;continue}const seen=new Set;for(const m of text.matchAll(ABSOLUTE)){const ref=m[1];if(seen.has(ref))continue;seen.add(ref);const prefix=ref.includes("/docs/")?ref.slice(0,ref.indexOf("/docs/")):"";if(prefix&&namesAnotherRepo(prefix)){foreignPrefixes.add(prefix);continue}const local=stripSelfPrefix(ref);checked++;if(existsSync(resolve(REPO_ROOT,local)))continue;if(HISTORICAL.test(markerWindowWithout(text,m.index,m[1]))){exempted++;continue}findings.push({where,ref,how:"no such file (repo-relative)"})}for(const m of text.matchAll(PARENT_RELATIVE)){const ref=m[1];if(seen.has(ref))continue;seen.add(ref);checked++;if(existsSync(resolve(dirname(file),ref)))continue;if(HISTORICAL.test(markerWindowWithout(text,m.index,m[1]))){exempted++;continue}findings.push({where,ref,how:"no such file (relative to this file)"})}for(const m of text.matchAll(RELATIVE)){const ref=m[1];if([...seen].some(x=>x.endsWith(ref)))continue;if([...seen].some(s=>s.endsWith(ref)))continue;if(seen.has(ref))continue;seen.add(ref);checked++;if(existsSync(resolve(dirname(file),ref)))continue;if(HISTORICAL.test(markerWindowWithout(text,m.index,m[1]))){exempted++;continue}findings.push({where,ref,how:"no such file (relative to this file)"})}}}for(const dir of SCAN_DIRS){for(const file of walk(resolve(REPO_ROOT,dir))){const where=relative(REPO_ROOT,file);if(where==="scripts/check-work-orders.mjs")continue;const text=readFileSync(file,"utf8");if(VENDORED_BANNER.test(text.slice(0,400)))continue;const seen=new Set;for(const m of text.matchAll(DIR_CITATION)){const ref=m[1];if(seen.has(ref))continue;seen.add(ref);const dirPrefix=ref.includes("/")?ref.slice(0,ref.indexOf("/")):"";if(dirPrefix&&namesAnotherRepo(dirPrefix)){foreignPrefixes.add(dirPrefix);continue}const abs=ref.includes("/")?resolve(REPO_ROOT,ref):resolve(dirname(file),ref);checked++;if(existsSync(abs)&&statSync(abs).isDirectory())continue;if(HISTORICAL.test(markerWindowWithout(text,m.index,m[1]))){exempted++;continue}findings.push({where,ref:`${ref}/`,how:existsSync(abs)?"names a directory but is a file":"no such directory"})}}}if(findings.length>0){console.error(`[work-orders] ${findings.length} dangling work-order reference(s):
|
|
3
|
+
`);for(const f of findings){console.error(` ${f.where}`);console.error(` "${f.ref}" \u2014 ${f.how}
|
|
4
|
+
`)}console.error(`A work order lives in the plan that owns it: docs/plans/<plan>/work-orders/<slug>.md.
|
|
5
|
+
Cite it ABSOLUTELY from src/, scripts/ and test/ \u2014 a code comment has no stable
|
|
6
|
+
relative position \u2014 and relatively from the plan docs beside it.
|
|
7
|
+
If the order lives in ANOTHER repo, prefix it with that repo's name
|
|
8
|
+
(<repo>/docs/plans/<plan>/work-orders/<slug>.md) \u2014 any prefix that is not
|
|
9
|
+
this repo ("${orgSlug()}") and not a directory here is taken as another repo.
|
|
10
|
+
Vendored files carry the host's own comments and are not scanned at all;
|
|
11
|
+
never rewrite one to satisfy this gate.`);process.exit(1)}assertNonVacuous("work-orders",{citations:{count:checked,unit:"in-repo citation(s)"}});console.log(`[work-orders] ok \u2014 ${checked} in-repo citation(s) resolve`+(foreignPrefixes.size?`; skipped citations belonging to ${[...foreignPrefixes].sort().join(", ")}`:"")+(vendoredSkipped?`; ${vendoredSkipped} vendored file(s) not scanned`:"")+(exempted?`; ${exempted} historical`:"")+".");
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import{loadConfig}from"../lib/config.mjs";import{runCommand}from"../lib/run.mjs";const{lineages,checks}=await loadConfig();const named=process.argv.slice(2).filter(a=>!a.startsWith("--"));const list=named.length?named:checks.run;const results=[];for(const name of list){if(name==="knowledge"){for(const l of lineages){console.log(`
|
|
3
|
+
[check] knowledge --bundle ${l.name}`);results.push([`knowledge (${l.name})`,runCommand("knowledge",["--bundle",l.name,"--check"])])}continue}console.log(`
|
|
4
|
+
[check] ${name}`);results.push([name,runCommand(name,[])])}const failed=results.filter(([,status])=>status!==0);console.log(`
|
|
5
|
+
[check] ${results.length-failed.length}/${results.length} passed${failed.length?`; failed: ${failed.map(([n])=>n).join(", ")}`:""}.`);process.exit(failed.length?1:0);
|
package/src/index.d.mts
CHANGED
|
@@ -14,6 +14,11 @@ export type LineageSpec =
|
|
|
14
14
|
export type EngineConfig = {
|
|
15
15
|
org: string;
|
|
16
16
|
lineages?: LineageSpec[];
|
|
17
|
+
checks?: {
|
|
18
|
+
run?: string[];
|
|
19
|
+
peerRepos?: string[];
|
|
20
|
+
gated?: { projectTemplates?: string[]; orgTemplates?: string[] };
|
|
21
|
+
};
|
|
17
22
|
};
|
|
18
23
|
|
|
19
24
|
export type Lineage = {
|
|
@@ -25,9 +30,10 @@ export type Lineage = {
|
|
|
25
30
|
build: { script: string; env?: Record<string, string> };
|
|
26
31
|
};
|
|
27
32
|
|
|
33
|
+
export const DEFAULT_CHECKS: string[];
|
|
28
34
|
export const MAIN_LINEAGE: 'main';
|
|
29
35
|
export const PUBLIC_LINEAGE: 'public';
|
|
30
36
|
export function defineConfig(config: EngineConfig): EngineConfig;
|
|
31
37
|
export function lineageDefaults(name: string, kind?: LineageKind): Lineage;
|
|
32
|
-
export function loadConfig(): Promise<{ org: string; lineages: Lineage[] }>;
|
|
38
|
+
export function loadConfig(): Promise<{ org: string; lineages: Lineage[]; checks: { run: string[]; peerRepos: string[]; gated: { projectTemplates: string[]; orgTemplates: string[] } } }>;
|
|
33
39
|
export function lineageConfig(name: string): Promise<Lineage>;
|
package/src/index.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{defineConfig,lineageConfig,lineageDefaults,loadConfig,MAIN_LINEAGE,PUBLIC_LINEAGE}from"./lib/config.mjs";export{MAIN_LINEAGE,PUBLIC_LINEAGE,defineConfig,lineageConfig,lineageDefaults,loadConfig};
|
|
1
|
+
import{DEFAULT_CHECKS,defineConfig,lineageConfig,lineageDefaults,loadConfig,MAIN_LINEAGE,PUBLIC_LINEAGE}from"./lib/config.mjs";export{DEFAULT_CHECKS,MAIN_LINEAGE,PUBLIC_LINEAGE,defineConfig,lineageConfig,lineageDefaults,loadConfig};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{loadConfig}from"./config.mjs";import{assertNotSelf,selfNames}from"./repo-identity.mjs";const HISTORICAL=/\b(retire[ds]?|delet(e|ed|ing)|remov(e|ed)|mov(e|ed)|used to|pre-move|former(ly)?|superseded|stale|out of date|no longer|(n?ever|neither) existed|gone|replaced)\b/i;const FAMILY_REPO_NAMES=(await loadConfig()).checks.peerRepos;const OTHER_REPO_NAMES=FAMILY_REPO_NAMES.filter(n=>!selfNames().has(n));const FOREIGN=new RegExp([String.raw`\bthe host\b`,String.raw`\bhost'?s\b`,"host-side","host repo","host CI","host PR","OA host",String.raw`\bupstream repo\b`,String.raw`\bsibling repo\b`,String.raw`\bpeer repo\b`,...OTHER_REPO_NAMES.sort((a,b)=>b.length-a.length).map(n=>String.raw`\b${n}\b`)].join("|"),"i");assertNotSelf(OTHER_REPO_NAMES,"FAMILY_REPO_NAMES in citation-markers.mjs");const PLACEHOLDER=/(^|\/)(My[A-Z]|Your[A-Z]|Example[A-Z]|Foo|Bar|ComponentName|<)/;const LIST_ITEM=/^\s*(?:\/\/|\*(?!\s*\/))?\s*(?:[-*+]|\d+[.)])\s+\S/;function markerWindow(lines,i,lookback=3){const parts=[lines[i]];const fromListItem=LIST_ITEM.test(lines[i]??"");let spent=0;for(let j=i-1;j>=0&&spent<lookback;j--){const prev=lines[j];if(prev===void 0)break;const trimmed=prev.trim();if(trimmed===""||trimmed.startsWith("```"))continue;if(fromListItem&&LIST_ITEM.test(prev))continue;parts.push(prev);spent++}return parts.join("\n")}function markerWindowAt(text,index,lookback=3){const upto=text.slice(0,index);const lineNo=upto.split("\n").length-1;return markerWindow(text.split("\n"),lineNo,lookback)}export{FOREIGN,HISTORICAL,OTHER_REPO_NAMES,PLACEHOLDER,markerWindow,markerWindowAt};
|
package/src/lib/config.d.mts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { defineConfig, lineageConfig, lineageDefaults, loadConfig, MAIN_LINEAGE, PUBLIC_LINEAGE } from '../index.mjs';
|
|
1
|
+
export { DEFAULT_CHECKS, defineConfig, lineageConfig, lineageDefaults, loadConfig, MAIN_LINEAGE, PUBLIC_LINEAGE } from '../index.mjs';
|
package/src/lib/config.mjs
CHANGED
|
@@ -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 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",build:{script:"build"}}}if(name===PUBLIC_LINEAGE){return{name,kind:"public",entry:"src/index.public.ts",sourceRoot:"components/public/",dist:"dist-public",build:{script:"build:public"}}}const k=kind??"page";return{name,kind:k,entry:`src/index.${name}.ts`,sourceRoot:k==="form"?`components/form/${name}/`:`components/project_page/${name}/`,dist:`dist-bundles/${name}`,build:{script:"build:bundle",env:{OA_PROJECT_BUNDLE: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}});if(lineages[0]?.name!==MAIN_LINEAGE)throw new Error(`[engine] ${origin}: lineages must start with "main"`);return{org:raw.org,lineages}}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{MAIN_LINEAGE,PUBLIC_LINEAGE,defineConfig,lineageConfig,lineageDefaults,loadConfig};
|
|
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","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",build:{script:"build"}}}if(name===PUBLIC_LINEAGE){return{name,kind:"public",entry:"src/index.public.ts",sourceRoot:"components/public/",dist:"dist-public",build:{script:"build:public"}}}const k=kind??"page";return{name,kind:k,entry:`src/index.${name}.ts`,sourceRoot:k==="form"?`components/form/${name}/`:`components/project_page/${name}/`,dist:`dist-bundles/${name}`,build:{script:"build:bundle",env:{OA_PROJECT_BUNDLE: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}});if(lineages[0]?.name!==MAIN_LINEAGE)throw new Error(`[engine] ${origin}: lineages must start with "main"`);const gated=raw.checks?.gated??{};const checks={run:raw.checks?.run??DEFAULT_CHECKS,peerRepos:raw.checks?.peerRepos??[],gated:{projectTemplates:gated.projectTemplates??[],orgTemplates:gated.orgTemplates??[]}};return{org:raw.org,lineages,checks}}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};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
function assertNonVacuous(gate,sets){const empty=Object.entries(sets).filter(([,s])=>s.count<(s.floor??1));if(empty.length===0)return;const lines=empty.map(([name,s])=>` ${name}: examined ${s.count} ${s.unit}, floor is ${s.floor??1}`);throw new Error(`[${gate}] VACUOUS \u2014 this gate ran its checks over an empty (or near-empty) set and would have reported success:
|
|
2
|
+
${lines.join("\n")}
|
|
3
|
+
|
|
4
|
+
Nothing was verified. Either the inputs moved, a glob resolved against the wrong directory, or a filter matched nothing. A gate's green has to mean "I looked and found no problems", never "I did not look".`)}export{assertNonVacuous};
|
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={deploy:"deploy.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"};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={deploy:"deploy.mjs",check:"check.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"};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};
|