@reqvire-org/ads 0.3.18 → 0.3.21
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/README.md +2 -0
- package/dist/behaviors.mjs +19 -9
- package/dist/bootstrap-ds/ADS.md +2 -2
- package/dist/bootstrap-ds/README.md +2 -2
- package/dist/bootstrap-ds/design-system/index.ts +0 -1
- package/dist/bootstrap-ds/design-system/review/index.ts +1 -0
- package/dist/bootstrap-ds/design-system/tokens/{Foundation.scenario.tsx → Foundation.ads.scenario.tsx} +2 -2
- package/dist/bootstrap-ds/ds.config.json +1 -1
- package/dist/bootstrap-ds/tsconfig.build.json +9 -2
- package/dist/bootstrap-ds/tsconfig.json +18 -3
- package/dist/bootstrap-ds/vite.config.ts +1 -1
- package/dist/boundaries.mjs +6 -2
- package/dist/check.mjs +44 -17
- package/dist/config.mjs +33 -5
- package/dist/contract-migration.mjs +110 -0
- package/dist/contracts.d.mts +22 -0
- package/dist/evidence.mjs +1 -1
- package/dist/index.mjs +1 -0
- package/dist/init.mjs +18 -15
- package/dist/layers.mjs +288 -0
- package/dist/lint-config.mjs +1 -1
- package/dist/mcp/catalog.mjs +9 -2
- package/dist/mcp/context.mjs +2 -1
- package/dist/mcp/edit-preparation.mjs +9 -6
- package/dist/mcp/mutations.mjs +1 -1
- package/dist/mcp/preview-widget.bundle.js +41 -39
- package/dist/mcp/prompts.mjs +1 -1
- package/dist/mcp/server.mjs +9 -3
- package/dist/mcp/widget.bundle.js +1 -1
- package/dist/oxlint.mjs +3 -1
- package/dist/paint-contracts.mjs +124 -0
- package/dist/preview-resources.mjs +12 -2
- package/dist/public-api.mjs +13 -6
- package/dist/public-props.mjs +2 -2
- package/dist/runtime-contracts.mjs +225 -0
- package/dist/scenario-trace.mjs +37 -9
- package/dist/scenarios-vite.mjs +3 -2
- package/dist/scenarios.mjs +89 -30
- package/dist/scoped-tokens.mjs +9 -4
- package/dist/showcase/client.bundle.js +1 -1
- package/dist/showcase/sandbox.bundle.js +1 -1
- package/dist/skills/ads-break-ui/SKILL.md +25 -6
- package/dist/skills/ads-break-ui/references/contracts.md +250 -0
- package/dist/skills/ads-break-ui/references/guarded-editing.md +1 -1
- package/dist/skills/ads-break-ui/references/layers.md +123 -0
- package/dist/skills/ads-break-ui/references/review.md +96 -0
- package/dist/skills/ads-break-ui/references/scenarios.md +24 -3
- package/dist/skills/ads-designer/SKILL.md +26 -7
- package/dist/skills/ads-designer/assets/bootstrap-ds/ADS.md +2 -2
- package/dist/skills/ads-designer/assets/bootstrap-ds/README.md +2 -2
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/index.ts +0 -1
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/review/index.ts +1 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/tokens/{Foundation.scenario.tsx → Foundation.ads.scenario.tsx} +2 -2
- package/dist/skills/ads-designer/assets/bootstrap-ds/ds.config.json +1 -1
- package/dist/skills/ads-designer/assets/bootstrap-ds/tsconfig.build.json +9 -2
- package/dist/skills/ads-designer/assets/bootstrap-ds/tsconfig.json +18 -3
- package/dist/skills/ads-designer/assets/bootstrap-ds/vite.config.ts +1 -1
- package/dist/skills/ads-designer/references/contracts.md +250 -0
- package/dist/skills/ads-designer/references/guarded-editing.md +1 -1
- package/dist/skills/ads-designer/references/layers.md +123 -0
- package/dist/skills/ads-designer/references/review.md +96 -0
- package/dist/skills/ads-designer/references/scenarios.md +24 -3
- package/dist/skills/ads-import/SKILL.md +25 -6
- package/dist/skills/ads-import/references/contracts.md +250 -0
- package/dist/skills/ads-import/references/guarded-editing.md +1 -1
- package/dist/skills/ads-import/references/layers.md +123 -0
- package/dist/skills/ads-import/references/review.md +96 -0
- package/dist/skills/ads-import/references/scenarios.md +24 -3
- package/dist/skills/ads-migrate/SKILL.md +25 -6
- package/dist/skills/ads-migrate/references/contracts.md +250 -0
- package/dist/skills/ads-migrate/references/guarded-editing.md +1 -1
- package/dist/skills/ads-migrate/references/layers.md +123 -0
- package/dist/skills/ads-migrate/references/review.md +96 -0
- package/dist/skills/ads-migrate/references/scenarios.md +24 -3
- package/dist/skills/ads-motion/SKILL.md +26 -7
- package/dist/skills/ads-motion/references/audit.md +1 -1
- package/dist/skills/ads-motion/references/contracts.md +250 -0
- package/dist/skills/ads-motion/references/guarded-editing.md +1 -1
- package/dist/skills/ads-motion/references/layers.md +123 -0
- package/dist/skills/ads-motion/references/motion-review.md +35 -0
- package/dist/skills/ads-motion/references/review.md +96 -35
- package/dist/skills/ads-motion/references/scenarios.md +24 -3
- package/dist/token-origins.mjs +24 -0
- package/dist/tokens.mjs +4 -3
- package/dist/unit-contracts.mjs +203 -0
- package/dist/units.mjs +26 -17
- package/dist/verification/browser.mjs +156 -26
- package/dist/verification/playwright-reporter.cjs +13 -3
- package/docs/ADS.md +40 -6
- package/docs/ARCHITECTURE.md +73 -4
- package/docs/BEHAVIORS.md +15 -0
- package/docs/CHANGELOG.md +42 -0
- package/docs/CONTRACTS.md +250 -0
- package/docs/GUIDE.md +29 -3
- package/docs/MCP.md +42 -6
- package/package.json +4 -1
- package/dist/bootstrap-ds/design-system/patterns/token-preview/Scenario.tsx +0 -4
- package/dist/bootstrap-ds/design-system/patterns/token-preview/fixture.ts +0 -8
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/patterns/token-preview/Scenario.tsx +0 -4
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/patterns/token-preview/fixture.ts +0 -8
- /package/dist/bootstrap-ds/design-system/{patterns → review}/token-preview/TokenPreview.tsx +0 -0
- /package/dist/bootstrap-ds/design-system/tokens/{Foundation.fixture.ts → Foundation.ads.fixture.ts} +0 -0
- /package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/{patterns → review}/token-preview/TokenPreview.tsx +0 -0
- /package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/tokens/{Foundation.fixture.ts → Foundation.ads.fixture.ts} +0 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import selectorParser from "postcss-selector-parser";
|
|
4
|
+
import { isDevelopmentFile, isReviewFile } from "./config.mjs";
|
|
5
|
+
import { inspectUnitContracts, implementationIdentity, implementationNode } from "./unit-contracts.mjs";
|
|
6
|
+
import { tokenContract } from "./token-policy.mjs";
|
|
7
|
+
import { uiOperation } from "./ui-framework.mjs";
|
|
8
|
+
import { unwrap, createSourceValues, reference } from "./source-values.mjs";
|
|
9
|
+
|
|
10
|
+
function fn(node) { return node && ts.isVariableDeclaration(node) ? unwrap(node.initializer) : node; }
|
|
11
|
+
function all(node, predicate) { const result=[];function visit(item){if(predicate(item))result.push(item);ts.forEachChild(item,visit);}if(node)visit(node);return result; }
|
|
12
|
+
|
|
13
|
+
export function inspectRuntimeContracts(options, program) {
|
|
14
|
+
if(options.runtimeContracts?.program===program)return options.runtimeContracts;
|
|
15
|
+
const checker=program.getTypeChecker(), declarations=inspectUnitContracts(options);
|
|
16
|
+
const result={program,diagnostics:[],bindings:[],parts:[],allowedStyles:new Set(),allowedStyleTypes:new Set()};
|
|
17
|
+
options.runtimeContracts=result;
|
|
18
|
+
const key=node=>`${node.getSourceFile().fileName}:${node.pos}`;
|
|
19
|
+
const values=createSourceValues(program,options,{strict:true});
|
|
20
|
+
const canonical=node=>{
|
|
21
|
+
if(!node)return null;
|
|
22
|
+
let symbol=checker.getSymbolAtLocation(ts.isPropertyAccessExpression(node)?node.name:node);
|
|
23
|
+
if(symbol?.flags&ts.SymbolFlags.Alias)symbol=checker.getAliasedSymbol(symbol);
|
|
24
|
+
return symbol?.valueDeclaration;
|
|
25
|
+
};
|
|
26
|
+
const same=(node,reference)=>{const declaration=canonical(node);return declaration&&implementationIdentity(declaration,options.root)===reference.identity;};
|
|
27
|
+
function report(contract,message,code="runtime-contract") {result.diagnostics.push({file:contract.file,rule:code,line:1,column:1,message});}
|
|
28
|
+
function localCalls(node,seen=new Set()) {
|
|
29
|
+
if(!node||seen.has(node)||seen.size>32)return [];
|
|
30
|
+
seen=new Set([...seen,node]);const found=[node];
|
|
31
|
+
for(const call of all(node,ts.isCallExpression)) {
|
|
32
|
+
const target=canonical(call.expression);
|
|
33
|
+
if(target&&path.resolve(target.getSourceFile().fileName).startsWith(options.dsRoot+path.sep)&&(!isDevelopmentFile(target.getSourceFile().fileName,options)||isReviewFile(target.getSourceFile().fileName,options)))found.push(...localCalls(fn(target),seen));
|
|
34
|
+
}
|
|
35
|
+
return found;
|
|
36
|
+
}
|
|
37
|
+
for(const contract of declarations.contracts) {
|
|
38
|
+
for(const part of contract.declarativeParts??[]) {
|
|
39
|
+
const consumer=fn(implementationNode(program,part.consumer,options.root));
|
|
40
|
+
const bodies=localCalls(consumer),calls=bodies.flatMap(body=>all(body,ts.isCallExpression));
|
|
41
|
+
function childOrigins(expression,seen=new Set()) {
|
|
42
|
+
expression=unwrap(expression);if(!expression||seen.has(expression)||seen.size>40)return new Set();
|
|
43
|
+
seen=new Set([...seen,expression]);
|
|
44
|
+
const merge=sets=>new Set(sets.flatMap(set=>[...set]));
|
|
45
|
+
if(ts.isPropertyAccessExpression(expression)&&expression.name.text==="children") {
|
|
46
|
+
if(canonical(expression.expression)===consumer?.parameters?.[0])return new Set(["children-input"]);
|
|
47
|
+
if(ts.isPropertyAccessExpression(expression.expression)&&expression.expression.name.text==="props"&&childOrigins(expression.expression.expression,seen).size)return new Set([key(expression)]);
|
|
48
|
+
}
|
|
49
|
+
const declaration=canonical(expression);
|
|
50
|
+
if(declaration&&ts.isBindingElement(declaration)&&ts.isObjectBindingPattern(declaration.parent)&&declaration.parent.parent===consumer?.parameters?.[0]&&(declaration.propertyName??declaration.name).getText()==="children")return new Set(["children-input"]);
|
|
51
|
+
if(declaration&&ts.isVariableDeclaration(declaration)) {
|
|
52
|
+
if(declaration.initializer)return childOrigins(declaration.initializer,seen);
|
|
53
|
+
if(ts.isVariableDeclarationList(declaration.parent)&&ts.isForOfStatement(declaration.parent.parent)&&childOrigins(declaration.parent.parent.expression,seen).size)return new Set([key(declaration)]);
|
|
54
|
+
}
|
|
55
|
+
if(declaration&&ts.isParameter(declaration)) {
|
|
56
|
+
const callback=declaration.parent,index=callback.parameters.indexOf(declaration),origins=[];
|
|
57
|
+
for(const call of calls) {
|
|
58
|
+
if(fn(canonical(call.expression))===callback&&call.arguments[index])origins.push(childOrigins(call.arguments[index],seen));
|
|
59
|
+
const operation=uiOperation(call.expression,checker);
|
|
60
|
+
const childrenCall=["Children.map","Children.forEach"].includes(operation)&&call.arguments[1]&&(call.arguments[1]===callback||fn(canonical(call.arguments[1]))===callback);
|
|
61
|
+
const arrayCall=ts.isPropertyAccessExpression(call.expression)&&["map","forEach","flatMap"].includes(call.expression.name.text)&&call.arguments[0]&&(call.arguments[0]===callback||fn(canonical(call.arguments[0]))===callback);
|
|
62
|
+
if(index===0&&(childrenCall&&childOrigins(call.arguments[0],seen).size||arrayCall&&childOrigins(call.expression.expression,seen).size))origins.push(new Set([key(declaration)]));
|
|
63
|
+
}
|
|
64
|
+
return merge(origins);
|
|
65
|
+
}
|
|
66
|
+
if(ts.isCallExpression(expression)) {
|
|
67
|
+
if(["Children.toArray","toChildArray"].includes(uiOperation(expression.expression,checker)))return childOrigins(expression.arguments[0],seen);
|
|
68
|
+
if(ts.isPropertyAccessExpression(expression.expression)&&["filter","slice"].includes(expression.expression.name.text))return childOrigins(expression.expression.expression,seen);
|
|
69
|
+
}
|
|
70
|
+
return new Set();
|
|
71
|
+
}
|
|
72
|
+
const propOrigins=new Set(bodies.flatMap(body=>all(body,ts.isPropertyAccessExpression).filter(node=>node.name.text==="props").flatMap(node=>[...childOrigins(node.expression)])));
|
|
73
|
+
const readsProps=expression=>[...childOrigins(expression)].some(origin=>propOrigins.has(origin));
|
|
74
|
+
let consumed=false;
|
|
75
|
+
for(const body of bodies) {
|
|
76
|
+
for(const binary of all(body,ts.isBinaryExpression)) {
|
|
77
|
+
if(![ts.SyntaxKind.EqualsEqualsEqualsToken,ts.SyntaxKind.EqualsEqualsToken].includes(binary.operatorToken.kind))continue;
|
|
78
|
+
for(const [value,identity]of[[binary.left,binary.right],[binary.right,binary.left]])if(ts.isPropertyAccessExpression(value)&&value.name.text==="type"&&same(identity,part.api)&&readsProps(value.expression))consumed=true;
|
|
79
|
+
}
|
|
80
|
+
for(const call of all(body,ts.isCallExpression))for(const [index,arg]of call.arguments.entries())if(same(arg,part.api)) {
|
|
81
|
+
const helper=fn(canonical(call.expression)),parameter=helper?.parameters?.[index];
|
|
82
|
+
if(!parameter||!ts.isIdentifier(parameter.name))continue;
|
|
83
|
+
const parameterSymbol=checker.getSymbolAtLocation(parameter.name);
|
|
84
|
+
for(const binary of all(helper,ts.isBinaryExpression))if([ts.SyntaxKind.EqualsEqualsEqualsToken,ts.SyntaxKind.EqualsEqualsToken].includes(binary.operatorToken.kind))for(const [value,identity]of[[binary.left,binary.right],[binary.right,binary.left]]) {
|
|
85
|
+
if(!ts.isPropertyAccessExpression(value)||value.name.text!=="type"||checker.getSymbolAtLocation(identity)!==parameterSymbol)continue;
|
|
86
|
+
const childParameter=canonical(value.expression),childIndex=helper.parameters.indexOf(childParameter);
|
|
87
|
+
if(childIndex>=0&&readsProps(call.arguments[childIndex]))consumed=true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if(!consumer||!consumed)report(contract,`${part.apiName}: cannot establish that ${part.consumer.symbol} identifies this declarative child from its children input by resolved symbol and reads that same child's props. A registration, decoy object, import or unused reference is not consumption.`,"declarative-consumer");
|
|
92
|
+
else result.parts.push({...part,unit:contract.unit,file:contract.file});
|
|
93
|
+
}
|
|
94
|
+
for(const [name,binding]of Object.entries(contract.bindings??{})) {
|
|
95
|
+
const producer=fn(implementationNode(program,binding.producer,options.root));
|
|
96
|
+
const [prefix,suffix]=binding.variable.split("${key}");
|
|
97
|
+
if(!(contract.paintInputs??[]).some(input=>input.api.identity===binding.input.api.identity&&input.property===`${binding.input.property}.*.${binding.valueFrom}`)) {report(contract,`${name}: register the actual config-entry value path as a constrained paint input. A producer does not authorize untyped config colours.`,"binding-input");continue;}
|
|
98
|
+
if([...tokenContract.naming.prefixes,"--ds-","--ux-","--showcase-"].some(value=>value.startsWith(prefix)||prefix.startsWith(value))||[...tokenContract.naming.names,...Object.keys(tokenContract.tokens)].some(value=>value.startsWith(prefix)&&value.endsWith(suffix))) {
|
|
99
|
+
report(contract,`${name}: runtime family ${binding.variable} overlaps the shared token vocabulary. Use a nonconflicting private runtime family; registration cannot grant shared-token overrides.`,"binding-collision");continue;
|
|
100
|
+
}
|
|
101
|
+
const other=result.bindings.find(entry=>entry.prefix.startsWith(prefix)||prefix.startsWith(entry.prefix));
|
|
102
|
+
if(other){report(contract,`${name}: runtime family overlaps ${other.unit}/${other.name}. Keep binding ownership unambiguous.`,"binding-collision");continue;}
|
|
103
|
+
let selector;
|
|
104
|
+
try {
|
|
105
|
+
const parsed=selectorParser().astSync(binding.scope.selector);
|
|
106
|
+
if(parsed.nodes.length!==1||parsed.first.nodes.length!==1||!["class","attribute"].includes(parsed.first.first.type))throw new Error();
|
|
107
|
+
selector=parsed.first.first;
|
|
108
|
+
if(selector.type==="attribute"&&(!selector.attribute.startsWith("data-")||selector.operator!=="="))throw new Error();
|
|
109
|
+
}catch{report(contract,`${name}: scope must name one actual owner-root class or exact data-* attribute, not a broad selector.`);continue;}
|
|
110
|
+
if(!producer?.parameters?.[0]||!ts.isIdentifier(producer.parameters[0].name)){report(contract,`${name}: producer requires an explicit config-map parameter.`);continue;}
|
|
111
|
+
const ownerFile=implementationNode(program,binding.producer,options.root).getSourceFile().fileName;
|
|
112
|
+
function builtInEntries(call, input) {
|
|
113
|
+
return ts.isCallExpression(call)&&call.arguments[0]&&ts.isPropertyAccessExpression(call.expression)&&call.expression.name.text==="entries"&&call.expression.expression.getText()==="Object"&&checker.getSymbolAtLocation(call.arguments[0])===input&&!checker.getSymbolAtLocation(call.expression.expression)?.declarations?.some(node=>node.getSourceFile().fileName.startsWith(options.root+path.sep)&&!node.getSourceFile().hasNoDefaultLib&&!node.getSourceFile().fileName.includes(`${path.sep}node_modules${path.sep}`));
|
|
114
|
+
}
|
|
115
|
+
function safeKey(body, keySymbol, before) {
|
|
116
|
+
return ts.isBlock(body)&&body.statements.some(statement=>{
|
|
117
|
+
if(!ts.isIfStatement(statement)||!ts.isPrefixUnaryExpression(statement.expression)||statement.expression.operator!==ts.SyntaxKind.ExclamationToken)return false;
|
|
118
|
+
const test=statement.expression.operand,stop=ts.isBlock(statement.thenStatement)?statement.thenStatement.statements[0]:statement.thenStatement;
|
|
119
|
+
return ts.isCallExpression(test)&&ts.isPropertyAccessExpression(test.expression)&&test.expression.name.text==="test"&&ts.isRegularExpressionLiteral(test.expression.expression)&&test.expression.expression.text==="/^[A-Za-z0-9_-]+$/"&&checker.getSymbolAtLocation(test.arguments[0])===keySymbol&&(ts.isThrowStatement(stop)||ts.isContinueStatement(stop))&&statement.end<=before;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
function pairMatches(template,value,keySymbol,valueSymbol) {
|
|
123
|
+
template=unwrap(template);value=unwrap(value);
|
|
124
|
+
if(!template||!ts.isTemplateExpression(template)||template.templateSpans.length!==1||template.head.text!==prefix||template.templateSpans[0].literal.text!==suffix||checker.getSymbolAtLocation(template.templateSpans[0].expression)!==keySymbol)return false;
|
|
125
|
+
let valueRoot=value;const fields=[];while(ts.isPropertyAccessExpression(valueRoot)){fields.unshift(valueRoot.name.text);valueRoot=valueRoot.expression;}
|
|
126
|
+
return checker.getSymbolAtLocation(valueRoot)===valueSymbol&&fields.join(".")===binding.valueFrom;
|
|
127
|
+
}
|
|
128
|
+
function proveMap(body,seen=new Set()) {
|
|
129
|
+
if(!body?.parameters?.[0]||!ts.isIdentifier(body.parameters[0].name)||seen.has(body)||seen.size>8)return null;
|
|
130
|
+
seen=new Set([...seen,body]);const inputSymbol=checker.getSymbolAtLocation(body.parameters[0].name);
|
|
131
|
+
const returns=ts.isBlock(body.body)?all(body.body,ts.isReturnStatement).filter(node=>{for(let parent=node.parent;parent&&parent!==body;parent=parent.parent)if(ts.isFunctionLike(parent))return false;return true;}):[{expression:body.body}];
|
|
132
|
+
if(returns.length!==1)return null;
|
|
133
|
+
const returned=unwrap(returns[0].expression);
|
|
134
|
+
if(ts.isCallExpression(returned)) {
|
|
135
|
+
if(ts.isPropertyAccessExpression(returned.expression)&&returned.expression.expression.getText()==="Object"&&returned.expression.name.text==="fromEntries"&&!checker.getSymbolAtLocation(returned.expression.expression)?.declarations?.some(node=>node.getSourceFile().fileName.startsWith(options.root+path.sep)&&!node.getSourceFile().hasNoDefaultLib&&!node.getSourceFile().fileName.includes(`${path.sep}node_modules${path.sep}`))) {
|
|
136
|
+
const mapped=unwrap(returned.arguments[0]);
|
|
137
|
+
if(!mapped||!ts.isCallExpression(mapped)||!ts.isPropertyAccessExpression(mapped.expression)||mapped.expression.name.text!=="map"||!builtInEntries(unwrap(mapped.expression.expression),inputSymbol))return null;
|
|
138
|
+
const callback=unwrap(mapped.arguments[0]),tuple=callback?.parameters?.[0]?.name;
|
|
139
|
+
if(!callback||!ts.isArrowFunction(callback)||!tuple||!ts.isArrayBindingPattern(tuple)||tuple.elements.length!==2||!tuple.elements.every(ts.isBindingElement))return null;
|
|
140
|
+
const keySymbol=checker.getSymbolAtLocation(tuple.elements[0].name),valueSymbol=checker.getSymbolAtLocation(tuple.elements[1].name);
|
|
141
|
+
const outputs=ts.isBlock(callback.body)?all(callback.body,ts.isReturnStatement):[{expression:callback.body,pos:callback.body.pos}];
|
|
142
|
+
if(outputs.length!==1||!safeKey(callback.body,keySymbol,outputs[0].pos))return null;
|
|
143
|
+
const pair=unwrap(outputs[0].expression);
|
|
144
|
+
return pair&&ts.isArrayLiteralExpression(pair)&&pair.elements.length===2&&pairMatches(pair.elements[0],pair.elements[1],keySymbol,valueSymbol)?[body]:null;
|
|
145
|
+
}
|
|
146
|
+
const helper=canonical(returned.expression);
|
|
147
|
+
if(helper&&path.dirname(helper.getSourceFile().fileName)===path.dirname(ownerFile)&&checker.getSymbolAtLocation(returned.arguments[0])===inputSymbol){const proof=proveMap(fn(helper),seen);return proof?[body,...proof]:null;}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
let mapSymbol;const writes=[];
|
|
151
|
+
for(const loop of all(body,ts.isForOfStatement)) {
|
|
152
|
+
if(!builtInEntries(unwrap(loop.expression),inputSymbol))continue;
|
|
153
|
+
const tuple=ts.isVariableDeclarationList(loop.initializer)&&loop.initializer.declarations[0]?.name;
|
|
154
|
+
if(!tuple||!ts.isArrayBindingPattern(tuple)||tuple.elements.length!==2||!tuple.elements.every(ts.isBindingElement))continue;
|
|
155
|
+
const keySymbol=checker.getSymbolAtLocation(tuple.elements[0].name),valueSymbol=checker.getSymbolAtLocation(tuple.elements[1].name);
|
|
156
|
+
for(const write of all(loop.statement,ts.isBinaryExpression))if(write.operatorToken.kind===ts.SyntaxKind.EqualsToken&&ts.isElementAccessExpression(write.left)&&pairMatches(write.left.argumentExpression,write.right,keySymbol,valueSymbol)&&safeKey(loop.statement,keySymbol,write.pos)) {
|
|
157
|
+
const target=canonical(write.left.expression);
|
|
158
|
+
if(!target||!ts.isVariableDeclaration(target)||!target.initializer||!ts.isObjectLiteralExpression(unwrap(target.initializer))||unwrap(target.initializer).properties.length)continue;
|
|
159
|
+
const actual=checker.getSymbolAtLocation(target.name);if(mapSymbol&&mapSymbol!==actual)continue;mapSymbol=actual;writes.push(write);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const escapes=mapSymbol&&all(body,ts.isIdentifier).some(node=>checker.getSymbolAtLocation(node)===mapSymbol&&!ts.isVariableDeclaration(node.parent)&&!ts.isReturnStatement(node.parent)&&!(ts.isElementAccessExpression(node.parent)&&writes.some(write=>write.left===node.parent)));
|
|
163
|
+
return mapSymbol&&!escapes&&checker.getSymbolAtLocation(returned)===mapSymbol?[body]:null;
|
|
164
|
+
}
|
|
165
|
+
const proof=proveMap(producer);
|
|
166
|
+
if(!proof){report(contract,`${name}: producer must return only the declared config-entry paint map. Use Object.entries with a loop or Object.fromEntries/map, guard keys with /^[A-Za-z0-9_-]+$/ before writing (throw or continue), and the exact computed family. Owner-local helper returns are supported; escaped maps, extra writes and unknown returns grant no permission.`,"binding-producer");continue;}
|
|
167
|
+
const scope=fn(implementationNode(program,binding.scope.api,options.root));
|
|
168
|
+
let attached=false;
|
|
169
|
+
const returnedElements=new Set();
|
|
170
|
+
function rendered(input,context=values.state()) {
|
|
171
|
+
const value=values.resolve(input,values.next(context));
|
|
172
|
+
context=value.context??context;
|
|
173
|
+
if(value.kind==="union"||value.kind==="array"){for(const item of value.values.slice(0,32))rendered(item,values.next(context));return;}
|
|
174
|
+
if(value.kind!=="node")return;
|
|
175
|
+
const node=value.node;
|
|
176
|
+
if(ts.isJsxExpression(node)){rendered(reference(node.expression,value.env,value.via),values.next(context));return;}
|
|
177
|
+
if(ts.isJsxFragment(node)){for(const child of node.children)rendered(reference(child,value.env,value.via),values.next(context));return;}
|
|
178
|
+
if(!ts.isJsxElement(node)&&!ts.isJsxSelfClosingElement(node))return;
|
|
179
|
+
const opening=ts.isJsxElement(node)?node.openingElement:node,children=ts.isJsxElement(node)?node.children:[];
|
|
180
|
+
const attributes=values.props(opening,value.env,value.via);
|
|
181
|
+
if(children.length)attributes.layers.push(new Map([["children",{kind:"array",values:children.map(child=>reference(child,value.env,value.via))}]]));
|
|
182
|
+
if(ts.isIdentifier(opening.tagName)&&/^[a-z]/.test(opening.tagName.text)) {returnedElements.add(opening);for(const child of children)rendered(reference(child,value.env,value.via),values.next(context));return;}
|
|
183
|
+
const component=values.resolve(reference(opening.tagName,value.env,value.via),values.next(context));
|
|
184
|
+
if(["provider","context"].includes(component.kind))for(const child of children)rendered(reference(child,value.env,value.via),values.next(context));
|
|
185
|
+
else if(component.kind==="node"&&component.node.body)for(const output of values.returns(component,[attributes],values.next(context),"component"))rendered(output,values.next(context));
|
|
186
|
+
}
|
|
187
|
+
const target=scope&&values.resolve(reference(scope),values.state());
|
|
188
|
+
if(target?.kind==="node")for(const output of values.returns(target,[{kind:"unknown"}],values.state(),"component"))rendered(output);
|
|
189
|
+
function inputMatches(expression) {
|
|
190
|
+
expression=unwrap(expression);if(!expression||!scope?.parameters?.[0])return false;
|
|
191
|
+
const fields=[];while(ts.isPropertyAccessExpression(expression)){fields.unshift(expression.name.text);expression=expression.expression;}
|
|
192
|
+
const parameter=scope.parameters[0].name;
|
|
193
|
+
if(ts.isIdentifier(parameter))return checker.getSymbolAtLocation(expression)===checker.getSymbolAtLocation(parameter)&&fields.join(".")===binding.input.property;
|
|
194
|
+
const [first,...rest]=binding.input.property.split(".");
|
|
195
|
+
const item=ts.isObjectBindingPattern(parameter)&¶meter.elements.find(item=>(item.propertyName??item.name).getText()===first);
|
|
196
|
+
return item&&ts.isIdentifier(item.name)&&checker.getSymbolAtLocation(expression)===checker.getSymbolAtLocation(item.name)&&fields.join(".")===rest.join(".");
|
|
197
|
+
}
|
|
198
|
+
function produced(expression,seen=new Set()) {
|
|
199
|
+
expression=unwrap(expression);if(!expression||seen.has(expression)||seen.size>20)return false;seen=new Set([...seen,expression]);
|
|
200
|
+
if(ts.isCallExpression(expression)&&same(expression.expression,binding.producer))return inputMatches(expression.arguments[0]);
|
|
201
|
+
if(ts.isCallExpression(expression)&&uiOperation(expression.expression,checker)==="useMemo") {
|
|
202
|
+
const callback=unwrap(expression.arguments[0]);
|
|
203
|
+
const returns=callback&&ts.isBlock(callback.body)?all(callback.body,ts.isReturnStatement):[];
|
|
204
|
+
return callback&&(ts.isArrowFunction(callback)||ts.isFunctionExpression(callback))&&(ts.isBlock(callback.body)?returns.length>0&&returns.every(node=>produced(node.expression,seen)):produced(callback.body,seen));
|
|
205
|
+
}
|
|
206
|
+
const declaration=canonical(expression);
|
|
207
|
+
return declaration&&ts.isVariableDeclaration(declaration)&&produced(declaration.initializer,seen);
|
|
208
|
+
}
|
|
209
|
+
for(const element of returnedElements) {
|
|
210
|
+
if(!ts.isIdentifier(element.tagName)||!/^[a-z]/.test(element.tagName.text))continue;
|
|
211
|
+
const attributes=element.attributes.properties.filter(ts.isJsxAttribute);
|
|
212
|
+
const target=attributes.find(attribute=>attribute.name.getText()===(selector.type==="class"?"className":selector.attribute));
|
|
213
|
+
const targetValue=target?.initializer&&(ts.isStringLiteralLike(target.initializer)?target.initializer.text:ts.isJsxExpression(target.initializer)&&ts.isStringLiteralLike(target.initializer.expression)?target.initializer.expression.text:null);
|
|
214
|
+
const matched=selector.type==="class"?targetValue?.split(/\s+/).includes(selector.value):targetValue===selector.value;
|
|
215
|
+
const style=attributes.find(attribute=>attribute.name.getText()==="style");
|
|
216
|
+
const overwritten=element.attributes.properties.some((attribute,index)=>ts.isJsxSpreadAttribute(attribute)&&index>Math.min(element.attributes.properties.indexOf(style),element.attributes.properties.indexOf(target)));
|
|
217
|
+
if(matched&&!overwritten&&style?.initializer&&ts.isJsxExpression(style.initializer)&&produced(style.initializer.expression)){attached=true;result.allowedStyles.add(key(style));}
|
|
218
|
+
}
|
|
219
|
+
if(!attached){report(contract,`${name}: cannot trace the producer's returned map onto its declared component root. Unused producers, unrelated elements and arbitrary inline styles do not establish a binding.`,"binding-scope");continue;}
|
|
220
|
+
for(const node of proof.flatMap(body=>all(body,ts.isTypeReferenceNode)))result.allowedStyleTypes.add(key(node));
|
|
221
|
+
result.bindings.push({...binding,name,prefix,suffix,unit:contract.unit,file:contract.file});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return result;
|
|
225
|
+
}
|
package/dist/scenario-trace.mjs
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import ts from "typescript";
|
|
3
3
|
import postcss from "postcss";
|
|
4
|
-
import { isDevelopmentFile, isInside } from "./config.mjs";
|
|
5
|
-
import {
|
|
4
|
+
import { isDevelopmentFile, isInside, isReviewFile } from "./config.mjs";
|
|
5
|
+
import { scenarioApi } from "./public-api.mjs";
|
|
6
6
|
import { uiOperation } from "./ui-framework.mjs";
|
|
7
7
|
import { inspectUnits } from "./units.mjs";
|
|
8
8
|
import { createSourceValues, reference, union, isFunction } from "./source-values.mjs";
|
|
9
|
+
import { inspectRuntimeContracts } from "./runtime-contracts.mjs";
|
|
10
|
+
import { implementationNode } from "./unit-contracts.mjs";
|
|
9
11
|
|
|
10
12
|
/** Source evidence only. No project modules execute, and unknown paths grant no
|
|
11
13
|
* permission. Coverage uses intersections across unresolved render branches;
|
|
@@ -27,12 +29,16 @@ export function createScenarioTrace(program, options) {
|
|
|
27
29
|
const isCx = expression => linaria(expression, "cx");
|
|
28
30
|
const { state, next, at, resolve, get, arrays, returns, props } = createSourceValues(program, options, { preserveCall: expression => isCx(expression) || uiOperation(expression, checker) === "createElement", strict: true });
|
|
29
31
|
const publicNodes = new Map(), helpers = new Map(), inventory = inspectUnits(options);
|
|
32
|
+
const parts = inspectRuntimeContracts(options, program).parts.map(part => ({ ...part,
|
|
33
|
+
apiNode: resolve(reference(implementationNode(program, part.api, options.root)?.name), state()).node,
|
|
34
|
+
consumerNode: resolve(reference(implementationNode(program, part.consumer, options.root)?.name), state()).node,
|
|
35
|
+
}));
|
|
30
36
|
const locationKey = value => JSON.stringify([value.file, value.line, value.column]);
|
|
31
37
|
function runtimeOwner(file) {
|
|
32
|
-
return ["components", "patterns"].some(folder => isInside(path.join(options.dsRoot, folder), file)) && !isDevelopmentFile(file, options)
|
|
38
|
+
return (isReviewFile(file, options) || ["components", "patterns"].some(folder => isInside(path.join(options.dsRoot, folder), file)) && !isDevelopmentFile(file, options))
|
|
33
39
|
? inventory.owner(inventory.relative(path.relative(options.root, file))) : null;
|
|
34
40
|
}
|
|
35
|
-
for (const item of
|
|
41
|
+
for (const item of scenarioApi(program, options)) {
|
|
36
42
|
const value = resolve(reference(item.declaration.name ?? item.declaration), state());
|
|
37
43
|
if (value.kind !== "node") continue;
|
|
38
44
|
if (item.renderable) {
|
|
@@ -60,8 +66,20 @@ export function createScenarioTrace(program, options) {
|
|
|
60
66
|
return null;
|
|
61
67
|
}
|
|
62
68
|
|
|
63
|
-
function traceRenders(site) {
|
|
69
|
+
function traceRenders(site, { entry = false } = {}) {
|
|
64
70
|
const limits = new Set();
|
|
71
|
+
function childParts(input, context) {
|
|
72
|
+
const value = resolve(input, next(context));
|
|
73
|
+
if (value.kind === "union") return common(value.values.slice(0, maxBranches).map(item => childParts(item, next(context))));
|
|
74
|
+
if (value.kind === "array") return combine(value.values.map(item => childParts(item, next(context))));
|
|
75
|
+
if (value.kind !== "node") return new Set();
|
|
76
|
+
if (ts.isJsxExpression(value.node)) return childParts(reference(value.node.expression, value.env, value.via), next(context));
|
|
77
|
+
if (ts.isJsxFragment(value.node)) return combine(value.node.children.map(item => childParts(reference(item, value.env, value.via), next(context))));
|
|
78
|
+
const view = element(value.node, value.env, value.via);
|
|
79
|
+
if (!view) return new Set();
|
|
80
|
+
const target = resolve(view.tag, next(context));
|
|
81
|
+
return new Set([...parts.filter(part => part.apiNode && part.apiNode === target.node).map(part => part.apiName), ...childParts(get(view.attributes, "children", next(context)), next(context))]);
|
|
82
|
+
}
|
|
65
83
|
function render(input, context) {
|
|
66
84
|
const value = resolve(input, next(context));
|
|
67
85
|
context = value.context ?? input.context ?? context;
|
|
@@ -85,15 +103,19 @@ export function createScenarioTrace(program, options) {
|
|
|
85
103
|
if (target.kind === "union") return target.values.length <= maxBranches ? common(target.values.map(component)) : new Set();
|
|
86
104
|
if (target.kind !== "node") return new Set();
|
|
87
105
|
const names = new Set(publicNodes.get(target.node) ?? []);
|
|
88
|
-
if (
|
|
106
|
+
for (const part of parts) if (target.node === part.consumerNode && childParts(get(view.attributes, "children", next(context)), next(context)).has(part.apiName)) names.add(`@consumed:${part.apiName}`);
|
|
107
|
+
if (isFunction(target.node) && (entry || !isDevelopmentFile(target.node.getSourceFile().fileName, options) || isReviewFile(target.node.getSourceFile().fileName, options))) {
|
|
89
108
|
const outputs = returns(target, [view.attributes], next(context), "component");
|
|
90
109
|
for (const name of render(union(outputs), next(context))) names.add(name);
|
|
91
110
|
}
|
|
111
|
+
for (const part of parts) if (names.has(part.parentName) && names.has(`@consumed:${part.apiName}`)) names.add(`@exercised:${part.apiName}`);
|
|
92
112
|
return names;
|
|
93
113
|
}
|
|
94
114
|
return component(view.tag);
|
|
95
115
|
}
|
|
96
|
-
|
|
116
|
+
const target=entry&&resolve(reference(site),state());
|
|
117
|
+
const evidence = target?.kind==="node"&&isFunction(target.node) ? render(union(returns(target,[{kind:"object",layers:[]}],state(),"component")),state()) : render(reference(site), state());
|
|
118
|
+
return { rendered: new Set([...evidence].filter(name => !name.startsWith("@"))), declarative: new Set([...evidence].filter(name => name.startsWith("@exercised:")).map(name => name.slice(11))), limits: [...limits].slice(0, 3) };
|
|
97
119
|
}
|
|
98
120
|
|
|
99
121
|
function classValue(expression, property = false) {
|
|
@@ -122,7 +144,7 @@ export function createScenarioTrace(program, options) {
|
|
|
122
144
|
const text = ts.isNoSubstitutionTemplateLiteral(template) ? template.text : template.head.text + template.templateSpans.map(span => `__ads_expression__${span.literal.text}`).join("");
|
|
123
145
|
try { postcss.parse(text).walkDecls(declaration => { for(let parent=declaration.parent;parent;parent=parent.parent)if(parent.type==="atrule"&&/^(?:property|font-face|(?:-\w+-)?keyframes)$/i.test(parent.name))return; ++declarations; }); } catch { /* Source CSS guards own malformed templates. */ }
|
|
124
146
|
if (owned(source, via) && declarations) return result(true, true);
|
|
125
|
-
reasons.add("The returned style must originate in
|
|
147
|
+
reasons.add("The returned style must originate in component/pattern or review Linaria through its registered callable API.");
|
|
126
148
|
return result(false);
|
|
127
149
|
}
|
|
128
150
|
if (ts.isCallExpression(node) && isCx(node.expression)) {
|
|
@@ -145,5 +167,11 @@ export function createScenarioTrace(program, options) {
|
|
|
145
167
|
if (!ok && !reasons.size) reasons.add("Each nonempty return path needs owned Linaria styles; stable hooks alone are not styling evidence.");
|
|
146
168
|
return { present: !property || value.kind !== "unknown", ok, reasons: [...reasons].slice(0, 3) };
|
|
147
169
|
}
|
|
148
|
-
|
|
170
|
+
function traceEntry(source) {
|
|
171
|
+
const module=checker.getSymbolAtLocation(source),symbol=module&&checker.getExportsOfModule(module).find(symbol=>symbol.name==="default");
|
|
172
|
+
const resolved=symbol?.flags&ts.SymbolFlags.Alias?checker.getAliasedSymbol(symbol):symbol;
|
|
173
|
+
const declaration=resolved?.valueDeclaration;
|
|
174
|
+
return traceRenders(declaration&&(ts.isExportAssignment(declaration)?declaration.expression:declaration.name??declaration),{entry:true});
|
|
175
|
+
}
|
|
176
|
+
return { traceRenders, traceEntry, classValue };
|
|
149
177
|
}
|
package/dist/scenarios-vite.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import path from "node:path";
|
|
|
2
2
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { loadConfig, isInside } from "./config.mjs";
|
|
4
4
|
import { inspectScenarios } from "./scenarios.mjs";
|
|
5
|
+
import { configureReviewAlias } from "./preview-resources.mjs";
|
|
5
6
|
export { createPreviewResources, previewEntriesPlugin } from "./preview-resources.mjs";
|
|
6
7
|
|
|
7
8
|
/** Project-owned rendering; this plugin supplies only discoverable module entries. */
|
|
@@ -10,13 +11,13 @@ export function scenarioRegistryPlugin(directory = process.cwd()) {
|
|
|
10
11
|
const virtual = "virtual:ads-scenarios";
|
|
11
12
|
const resolved = "\0virtual:ads-scenarios";
|
|
12
13
|
function registry() {
|
|
13
|
-
const value = inspectScenarios(
|
|
14
|
+
const value = inspectScenarios(loadConfig(directory));
|
|
14
15
|
if (value.diagnostics.length) throw new Error(value.diagnostics.map((item) => `${item.file}: ${item.message}`).join("\n"));
|
|
15
16
|
mkdirSync(path.join(options.root, ".ds/scenarios"), { recursive: true });
|
|
16
17
|
writeFileSync(path.join(options.root, ".ds/scenarios/manifest.json"), JSON.stringify({ schemaVersion: 1, scenarios: value.scenarios }, null, 2) + "\n");
|
|
17
18
|
return `export const scenarios = [${value.scenarios.map((item) => `{...${JSON.stringify(item)}, load: () => import(${JSON.stringify(path.join(options.root, item.file).replaceAll("\\", "/"))})}`).join(",\n")}];`;
|
|
18
19
|
}
|
|
19
|
-
return { name: "ads-scenario-registry", resolveId(id) { if (id === virtual) return resolved; },
|
|
20
|
+
return { name: "ads-scenario-registry", config(config) { configureReviewAlias(config, options); }, resolveId(id) { if (id === virtual) return resolved; },
|
|
20
21
|
load(id) { if (id === resolved) return registry(); },
|
|
21
22
|
handleHotUpdate({ file, server }) {
|
|
22
23
|
if (!isInside(options.dsRoot, file)) return;
|
package/dist/scenarios.mjs
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import ts from "typescript";
|
|
4
|
-
import { publicApi } from "./public-api.mjs";
|
|
4
|
+
import { publicApi, scenarioApi } from "./public-api.mjs";
|
|
5
5
|
import { uiOperation } from "./ui-framework.mjs";
|
|
6
|
-
import { isDevelopmentFile, isInside, isScenarioFile, sourceFiles } from "./config.mjs";
|
|
6
|
+
import { isDevelopmentFile, isInside, isScenarioFile, scenarioIdentity, sourceFiles } from "./config.mjs";
|
|
7
7
|
import { projectCompilerOptions } from "./boundaries.mjs";
|
|
8
8
|
import { inspectTokens } from "./tokens.mjs";
|
|
9
9
|
import { isSharedToken } from "./token-policy.mjs";
|
|
10
10
|
import { validTokenSamples } from "./token-samples.mjs";
|
|
11
11
|
import { scopedTokens } from "./scoped-tokens.mjs";
|
|
12
12
|
import { createScenarioTrace } from "./scenario-trace.mjs";
|
|
13
|
+
import { inspectRuntimeContracts } from "./runtime-contracts.mjs";
|
|
14
|
+
import { inspectUnitContracts } from "./unit-contracts.mjs";
|
|
15
|
+
import { inspectPaintContracts } from "./paint-contracts.mjs";
|
|
13
16
|
|
|
14
17
|
export const scenarioArtifact = (root, id) => path.join(root, ".ds/scenarios", id, "index.html");
|
|
15
18
|
|
|
@@ -37,39 +40,53 @@ export function publicUi(program, options) {
|
|
|
37
40
|
export function inspectScenarios(options, suppliedProgram) {
|
|
38
41
|
const { root, dsRoot } = options;
|
|
39
42
|
const files = sourceFiles(dsRoot).filter(isScenarioFile);
|
|
40
|
-
const program = suppliedProgram ?? ts.createProgram([path.join(dsRoot, options.config.publicEntry), ...files], { ...projectCompilerOptions(options), allowJs: true });
|
|
41
|
-
const
|
|
43
|
+
const program = suppliedProgram ?? inspectUnitContracts(options).program ?? ts.createProgram([path.join(dsRoot, options.config.publicEntry), path.join(dsRoot, options.config.reviewEntry), ...files], { ...projectCompilerOptions(options), allowJs: true });
|
|
44
|
+
const apis = scenarioApi(program, options);
|
|
45
|
+
const duplicate = apis.find((item, index) => apis.some((other, otherIndex) => index !== otherIndex && item.name === other.name));
|
|
46
|
+
const known = new Map(apis.filter(item => item.kind !== "controllers").map((item) => [item.name, item]));
|
|
42
47
|
const checker = program.getTypeChecker();
|
|
48
|
+
const declarativeParts = inspectRuntimeContracts(options, program).parts;
|
|
49
|
+
const contractDeclarations=inspectUnitContracts(options).contracts;
|
|
50
|
+
const bindingTrace = contractDeclarations.some(contract=>contract.paintInputs?.length) ? createScenarioTrace(program, options) : null;
|
|
43
51
|
const behaviorApis = new Map(publicApi(program, options).filter(item => !item.renderable && item.behavioral).map(item => [item.name, item]));
|
|
44
|
-
const tokenInventory = inspectTokens(root);
|
|
52
|
+
const tokenInventory = inspectTokens(root, options);
|
|
45
53
|
const tokenNames = new Set(tokenInventory.tokens.map(token => token.name));
|
|
46
54
|
const scoped = scopedTokens(options);
|
|
47
55
|
const scenarios = [];
|
|
48
|
-
const diagnostics = [];
|
|
56
|
+
const diagnostics = [...inspectUnitContracts(options).diagnostics,...inspectRuntimeContracts(options,program).diagnostics,...inspectPaintContracts(options).diagnostics];
|
|
49
57
|
const ids = new Set();
|
|
50
|
-
function report(file, message
|
|
58
|
+
function report(file, message, node, detail = {}) {
|
|
59
|
+
const position = node?.getSourceFile().getLineAndCharacterOfPosition(node.getStart());
|
|
60
|
+
diagnostics.push({ file: path.relative(root, file).replaceAll("\\", "/"), rule: "scenario-contract", line: (position?.line ?? 0) + 1, column: (position?.character ?? 0) + 1, message, ...detail });
|
|
61
|
+
}
|
|
62
|
+
if (duplicate) report(path.join(dsRoot, options.config.reviewEntry), `Review export ${duplicate.name} collides with the production API. Use a distinct development export so scenario coverage is unambiguous.`);
|
|
51
63
|
for (const file of files) {
|
|
52
64
|
const relative = path.relative(dsRoot, file).replaceAll("\\", "/");
|
|
53
65
|
let kind = isInside(path.join(dsRoot, options.config.tokenDir), file) ? "tokens" : relative.split("/")[0];
|
|
54
66
|
if (!["tokens", "components", "patterns", "composition"].includes(kind)) { report(file, "Scenarios live beside tokens/components/patterns or under composition/, not inside showcase pages."); continue; }
|
|
55
|
-
|
|
56
|
-
if (id.length > 256) { report(file, "Scenario paths must fit within 256 characters."); continue; }
|
|
57
|
-
if (ids.has(id)) { report(file, `Duplicate scenario id ${id}. Use one entrypoint per identity.`); continue; }
|
|
58
|
-
ids.add(id);
|
|
67
|
+
let id = scenarioIdentity(file, dsRoot);
|
|
59
68
|
const source = program.getSourceFile(file);
|
|
60
69
|
if (!source) { report(file, "Cannot inspect scenario source."); continue; }
|
|
61
|
-
let metadata;
|
|
70
|
+
let metadata, metadataExpression;
|
|
62
71
|
let entry = false;
|
|
63
72
|
for (const node of source.statements) {
|
|
64
73
|
if (ts.isExportAssignment(node) || node.modifiers?.some((item) => item.kind === ts.SyntaxKind.DefaultKeyword)) entry = true;
|
|
65
74
|
if (ts.isVariableStatement(node) && node.modifiers?.some((item) => item.kind === ts.SyntaxKind.ExportKeyword)) {
|
|
66
|
-
for (const declaration of node.declarationList.declarations) if (ts.isIdentifier(declaration.name) && declaration.name.text === "scenario") metadata = literal(
|
|
75
|
+
for (const declaration of node.declarationList.declarations) if (ts.isIdentifier(declaration.name) && declaration.name.text === "scenario") { metadataExpression = declaration.initializer; metadata = literal(metadataExpression, checker); }
|
|
67
76
|
}
|
|
68
77
|
}
|
|
78
|
+
if(metadata?.id!==undefined) {
|
|
79
|
+
const owner=scoped.inventory.owner(relative);
|
|
80
|
+
if(typeof metadata.id!=="string"||metadata.id.length>256||!/^[\w-]+(?:[/.][\w-]+)*$/.test(metadata.id)||!owner||!(metadata.id===owner||metadata.id.startsWith(owner+"/")||metadata.id.startsWith(owner+".")))report(file,"Scenario id must be a safe stable identity inside this owner; it cannot capture another layer/owner.",metadataExpression,{entry:"id"});
|
|
81
|
+
else id=metadata.id;
|
|
82
|
+
}
|
|
83
|
+
if (id.length > 256) { report(file, "Scenario paths must fit within 256 characters."); continue; }
|
|
84
|
+
if (ids.has(id)) { report(file, `Duplicate scenario id ${id}. Use one entrypoint per identity.`); continue; }
|
|
85
|
+
ids.add(id);
|
|
69
86
|
if (metadata?.kind !== undefined) {
|
|
70
87
|
const owner = scoped.inventory.owner(relative);
|
|
71
|
-
if (metadata.kind !== "tokens" || !scoped.definitions.some(entry => entry.unit === owner))
|
|
72
|
-
kind = "tokens";
|
|
88
|
+
if (metadata.kind !== "tokens" || !scoped.definitions.some(entry => entry.unit === owner)) report(file, "Only an owner with registered local tokens may set kind: tokens for a colocated token preview.",entryNode("kind"),{entry:"kind"});
|
|
89
|
+
else kind = "tokens";
|
|
73
90
|
}
|
|
74
91
|
if (!entry) report(file, "Export a default independently mountable scenario entrypoint.");
|
|
75
92
|
const module = checker.getSymbolAtLocation(source);
|
|
@@ -87,12 +104,45 @@ export function inspectScenarios(options, suppliedProgram) {
|
|
|
87
104
|
const fixtureBase = path.basename(file).startsWith("Scenario.") ? path.join(path.dirname(file), "fixture") : file.replace(/\.scenario\.[^.]+$/, ".fixture");
|
|
88
105
|
const fixture = [".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs", ".json"].map(extension => fixtureBase + extension).find(existsSync);
|
|
89
106
|
if (!fixture) report(file, "Keep this scenario's inputs beside it: fixture.ts (directory entry) or <Owner>.fixture.ts (flat entry). An empty fixture is valid for a component without inputs.");
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
107
|
+
function entryNode(field, index) {
|
|
108
|
+
let value = metadataExpression;
|
|
109
|
+
const seen = new Set();
|
|
110
|
+
while (value && !seen.has(value)) {
|
|
111
|
+
seen.add(value);
|
|
112
|
+
if (ts.isSatisfiesExpression(value) || ts.isAsExpression(value) || ts.isParenthesizedExpression(value)) value = value.expression;
|
|
113
|
+
else if (ts.isIdentifier(value)) { let symbol = checker.getSymbolAtLocation(value); if (symbol?.flags & ts.SymbolFlags.Alias) symbol = checker.getAliasedSymbol(symbol); value = symbol?.valueDeclaration?.initializer; }
|
|
114
|
+
else break;
|
|
115
|
+
}
|
|
116
|
+
const property = value && ts.isObjectLiteralExpression(value) && value.properties.find(item => ts.isPropertyAssignment(item) && (ts.isIdentifier(item.name) || ts.isStringLiteralLike(item.name)) && item.name.text === field);
|
|
117
|
+
return index !== undefined && property && ts.isArrayLiteralExpression(property.initializer) ? property.initializer.elements[index] : property?.initializer ?? metadataExpression;
|
|
118
|
+
}
|
|
119
|
+
if (!metadata) {
|
|
120
|
+
report(file, "Scenario metadata is not statically readable. Render coverage is not established until this prerequisite is repaired.", metadataExpression, {coverage:"not-established",owner:scoped.inventory.owner(relative)});
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
for(const field of Object.keys(metadata))if(!["id","title","kind","covers","exercises","tokens","samples","captureTargets"].includes(field))report(file,`Unknown scenario field ${field}.`,entryNode(field),{entry:field});
|
|
124
|
+
if(typeof metadata.title!=="string"||!metadata.title.trim()) {
|
|
125
|
+
report(file,"Scenario title must be a nonempty static string; independent render evidence is retained.",entryNode("title"),{entry:"title"});
|
|
126
|
+
metadata.title=id;
|
|
127
|
+
}
|
|
128
|
+
if(!Array.isArray(metadata.covers)||!metadata.covers.length) {
|
|
129
|
+
report(file,"Scenario covers must be a nonempty static array naming rendered public UI. Coverage is not established until repaired.",entryNode("covers"),{entry:"covers",coverage:"not-established",owner:scoped.inventory.owner(relative)});
|
|
130
|
+
metadata.covers=[];
|
|
131
|
+
}
|
|
132
|
+
if(Object.hasOwn(metadata,"tokens")&&(!Array.isArray(metadata.tokens)||metadata.tokens.some(name=>typeof name!=="string"||!name.startsWith("--")))) {
|
|
133
|
+
report(file,"Scenario tokens must be a static list of real token names.",entryNode("tokens"),{entry:"tokens"});metadata.tokens=[];
|
|
134
|
+
}
|
|
135
|
+
if(kind==="tokens"&&Object.hasOwn(metadata,"tokens")||kind==="tokens"&&!validTokenSamples(metadata.samples)||Object.hasOwn(metadata,"samples")&&!validTokenSamples(metadata.samples)) {
|
|
136
|
+
report(file,"Token scenarios need samples: fixture.samples, a literal list of { token, property, probe } real CSS bindings. Printing values or covers: [] is incomplete.",entryNode("samples"),{entry:"samples"});delete metadata.samples;
|
|
137
|
+
}
|
|
138
|
+
const covers = [];
|
|
139
|
+
for (const [index, name] of metadata.covers.entries()) {
|
|
140
|
+
const api = typeof name === "string" && known.get(name);
|
|
141
|
+
if (!api?.renderable || declarativeParts.some(part=>part.apiName===name)) report(file, `Scenario covers[${index}] ${JSON.stringify(name) ?? "(unresolved)"} ${api ? "is not independently rendered UI. Use exercises for a behavioral or registered declarative API." : "does not name a known public UI export."} Independent valid render evidence is retained.`, entryNode("covers", index), { entry: `covers[${index}]`, ...(typeof name === "string" ? { api: name } : {}), reason: api ? "wrong-api-role" : "unknown-api" });
|
|
142
|
+
else covers.push(name);
|
|
95
143
|
}
|
|
144
|
+
metadata = { ...metadata, covers };
|
|
145
|
+
if (kind === "tokens" && !covers.some(name => ["patterns", "review"].includes(known.get(name)?.kind))) report(file, "Token covers must name the real development review renderer or product pattern consuming samples.", entryNode("covers"));
|
|
96
146
|
// A bad behavioral entry must fail validation without erasing independent
|
|
97
147
|
// render evidence and manufacturing missing-coverage errors for real UI.
|
|
98
148
|
const exercises = [];
|
|
@@ -104,8 +154,8 @@ export function inspectScenarios(options, suppliedProgram) {
|
|
|
104
154
|
if (Object.hasOwn(metadata, "exercises")) {
|
|
105
155
|
if (!Array.isArray(metadata.exercises)) report(file, "Scenario exercises must be a static array naming public behavioral APIs.");
|
|
106
156
|
else for (const [index, name] of metadata.exercises.entries()) {
|
|
107
|
-
if (typeof name !== "string") report(file, `Scenario exercises[${index}] must name a public behavioral API, not ${JSON.stringify(name) ?? "an unresolved value"}
|
|
108
|
-
else if (!behaviorApis.has(name)) report(file, `Scenario exercises[${index}] ${JSON.stringify(name)} is not a recognized public behavioral API. Use a public callable function, hook or controller; rendered UI belongs in covers. Discover the project's exports before declaring exercises
|
|
157
|
+
if (typeof name !== "string") report(file, `Scenario exercises[${index}] must name a public behavioral API, not ${JSON.stringify(name) ?? "an unresolved value"}.`, entryNode("exercises", index), { entry: `exercises[${index}]` });
|
|
158
|
+
else if (!behaviorApis.has(name) && !declarativeParts.some(part=>part.apiName===name)) report(file, `Scenario exercises[${index}] ${JSON.stringify(name)} is not a recognized public behavioral API. Use a public callable function, hook or controller; rendered UI belongs in covers. Discover the project's exports before declaring exercises.`, entryNode("exercises", index), { entry: `exercises[${index}]`, api: name });
|
|
109
159
|
else exercises.push(name);
|
|
110
160
|
}
|
|
111
161
|
}
|
|
@@ -117,16 +167,22 @@ export function inspectScenarios(options, suppliedProgram) {
|
|
|
117
167
|
for (const name of tokens) if (tokenInventory.tokens.find(token => token.name === name)?.owner !== owner) report(file, `Token ${name} needs a preview beside its defining owner. Shared tokens belong under the configured token directory; local tokens stay with their component/pattern.`);
|
|
118
168
|
}
|
|
119
169
|
const stylesheets = kind === "tokens" ? scoped.definitions.filter(entry => entry.unit === scoped.inventory.owner(relative)).map(entry => entry.file) : [];
|
|
120
|
-
|
|
170
|
+
const reachable = new Set(metadata.covers);
|
|
171
|
+
function findBindings(node) { if(ts.isJsxElement(node)||ts.isJsxSelfClosingElement(node))for(const name of bindingTrace.traceRenders(node).rendered)reachable.add(name);ts.forEachChild(node,findBindings); }
|
|
172
|
+
if(bindingTrace) findBindings(source);
|
|
173
|
+
const paintBindings = inspectRuntimeContracts(options,program).bindings.filter(binding=>reachable.has(binding.scope.apiName)).map(binding=>({owner:binding.unit,name:binding.name,scope:binding.scope.selector,prefix:binding.prefix,suffix:binding.suffix,tokens:tokenInventory.tokens.filter(token=>!token.owner||token.owner==="tokens"||token.owner===binding.unit).map(token=>token.name)}));
|
|
174
|
+
const paintAudit=contractDeclarations.some(contract=>contract.paintInputs?.some(input=>reachable.has(input.apiName)));
|
|
175
|
+
scenarios.push({ id, kind, title: metadata.title, ...(fixture ? { fixture: path.relative(root, fixture).replaceAll("\\", "/") } : {}), covers: [...new Set(metadata.covers)], ...(captureTargets ? { captureTargets } : {}), ...(Object.hasOwn(metadata, "exercises") ? { exercises: [...new Set(exercises)] } : {}), tokens, ...(metadata.samples ? { samples: metadata.samples } : {}), ...(stylesheets.length ? { stylesheets } : {}), ...(paintBindings.length?{paintBindings}:{}), ...(paintAudit?{paintAudit:true}:{}), file: path.relative(root, file).replaceAll("\\", "/"), artifact: path.relative(root, scenarioArtifact(root, id)).replaceAll("\\", "/") });
|
|
121
176
|
}
|
|
122
177
|
return { scenarios: scenarios.sort((a, b) => a.id.localeCompare(b.id)), diagnostics };
|
|
123
178
|
}
|
|
124
179
|
|
|
125
180
|
export function inspectScenarioCoverage(options, program, report, advise = () => {}, trace = createScenarioTrace(program, options)) {
|
|
126
181
|
const registry = inspectScenarios(options, program);
|
|
127
|
-
for (const
|
|
182
|
+
for (const {file,rule,message,line,column,...detail} of registry.diagnostics) report(path.join(options.root, file), rule, message, line, column, detail);
|
|
128
183
|
const checker = program.getTypeChecker();
|
|
129
184
|
const ui = publicUi(program, options);
|
|
185
|
+
const parts = inspectRuntimeContracts(options, program).parts;
|
|
130
186
|
function symbolOf(expression) {
|
|
131
187
|
while (expression && (ts.isParenthesizedExpression(expression) || ts.isAsExpression(expression) || ts.isSatisfiesExpression(expression) || ts.isNonNullExpression(expression))) expression = expression.expression;
|
|
132
188
|
if (!expression) return null;
|
|
@@ -156,7 +212,7 @@ export function inspectScenarioCoverage(options, program, report, advise = () =>
|
|
|
156
212
|
return symbol.declarations?.length ? symbol : null;
|
|
157
213
|
}
|
|
158
214
|
const identities = new Map(), behaviorIdentities = new Set();
|
|
159
|
-
for (const item of
|
|
215
|
+
for (const item of scenarioApi(program, options)) {
|
|
160
216
|
if (!identities.has(item.declaration)) identities.set(item.declaration, new Set());
|
|
161
217
|
identities.get(item.declaration).add(item.name);
|
|
162
218
|
if (item.behavioral && !item.renderable) behaviorIdentities.add(item.declaration);
|
|
@@ -164,12 +220,12 @@ export function inspectScenarioCoverage(options, program, report, advise = () =>
|
|
|
164
220
|
|
|
165
221
|
const covered = new Set();
|
|
166
222
|
const coveredTokens = new Set();
|
|
167
|
-
const tokenInventory = inspectTokens(options.root);
|
|
223
|
+
const tokenInventory = inspectTokens(options.root, options);
|
|
168
224
|
const declaredTokens = new Set(tokenInventory.tokens.map(token => token.name));
|
|
169
225
|
for (const scenario of registry.scenarios) {
|
|
170
226
|
const source = program.getSourceFile(path.join(options.root, scenario.file));
|
|
171
227
|
const rendered = new Set();
|
|
172
|
-
const exercised = new Set(), referencedApis = new Set();
|
|
228
|
+
const exercised = new Set(), referencedApis = new Set(), declarative = new Set();
|
|
173
229
|
const referencedTokens = new Set();
|
|
174
230
|
let samplesPassed = false;
|
|
175
231
|
const renderLimits = new Set();
|
|
@@ -226,22 +282,25 @@ export function inspectScenarioCoverage(options, program, report, advise = () =>
|
|
|
226
282
|
ts.forEachChild(node, child => visit(child, reached && (ts.isExportAssignment(node) || ts.isVariableDeclaration(node))));
|
|
227
283
|
}
|
|
228
284
|
for (const node of source.statements) if (ts.isExportAssignment(node) || node.modifiers?.some(item => item.kind === ts.SyntaxKind.DefaultKeyword)) visit(node, true);
|
|
285
|
+
if(parts.some(part=>scenario.exercises?.includes(part.apiName)))for(const name of trace.traceEntry(source).declarative)declarative.add(name);
|
|
229
286
|
for (const name of scenario.covers) {
|
|
230
287
|
if (!rendered.has(name)) report(source.fileName, "scenario-render", `${name} is declared in covers but has no reachable public render reference. Render it directly or through a traced parent with fixture props/defaults selecting that child. Imports, unused JSX and unknown conditional paths do not establish coverage.${renderLimits.size ? ` Source tracing could not establish: ${[...renderLimits].slice(0, 2).join(" ")}` : ""} Review the real scenario in a browser; do not hide a valid public part just to avoid coverage. See ads://guides/scenarios.`);
|
|
231
288
|
else covered.add(name);
|
|
232
289
|
}
|
|
233
290
|
for (const name of scenario.exercises ?? []) {
|
|
234
|
-
if (
|
|
291
|
+
if (parts.some(part=>part.apiName===name) && !declarative.has(name)) report(source.fileName, "scenario-exercise", `${name} is a registered declarative API, but its child does not reach the declared consumer through a rendered parent. Imports, unused children and unrelated parent renders do not establish consumption.`);
|
|
292
|
+
else if (!exercised.has(name) && !referencedApis.has(name)) report(source.fileName, "scenario-exercise", `${name} is declared in exercises but has no reachable behavioral use. Imports and unused adapters do not count.`);
|
|
235
293
|
else if (!exercised.has(name)) advise(source.fileName, "scenario-exercise-review", `${name} is referenced dynamically. Verify its invocation in the registered browser/behavior checks; a reference alone does not prove execution.`);
|
|
236
294
|
}
|
|
237
295
|
for (const name of referencedTokens) if (!declaredTokens.has(name)) report(source.fileName, "token-scenario-reference", `Reachable scenario input references undeclared token ${name}.`);
|
|
238
|
-
if (scenario.kind === "tokens" && !samplesPassed) report(source.fileName, "token-scenario-render", "Pass the declared fixture.samples into a rendered
|
|
296
|
+
if (scenario.kind === "tokens" && !samplesPassed) report(source.fileName, "token-scenario-render", "Pass the declared fixture.samples into a rendered review renderer's samples prop. Printing token names or reading unused fixtures is not sample coverage.");
|
|
239
297
|
for (const name of scenario.tokens) {
|
|
240
298
|
if (!referencedTokens.has(name)) report(source.fileName, "token-scenario-render", `${name} is declared in tokens but has no reachable reference from the default scenario entry. Consume the real token fixture; metadata alone does not count.`);
|
|
241
299
|
else if (scenario.kind === "tokens" && samplesPassed && declaredTokens.has(name)) coveredTokens.add(name);
|
|
242
300
|
}
|
|
243
301
|
}
|
|
244
|
-
|
|
302
|
+
const blockedOwners=new Set(registry.diagnostics.filter(item=>item.coverage==="not-established").map(item=>item.owner));
|
|
303
|
+
for (const item of ui.filter((item) => item.renderable && !covered.has(item.name) && !blockedOwners.has(scopedTokens(options).inventory.owner(scopedTokens(options).inventory.relative(path.relative(options.root,item.file)))))) report(item.file, "scenario-coverage", `Public UI ${item.name} needs a colocated, registered scenario render reference. Related parts may share a scenario.`);
|
|
245
304
|
for (const token of tokenInventory.tokens.filter(token => !coveredTokens.has(token.name))) {
|
|
246
305
|
const definition = token.definitions[0];
|
|
247
306
|
report(path.join(options.root, definition.file), "token-scenario-coverage", `Token ${token.name} needs a registered scenario with a reachable token fixture reference.`, definition.line, definition.column);
|