@jay-framework/plugin-validator 0.15.5 → 0.15.6
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/dist/index.d.ts +38 -2
- package/dist/index.js +537 -33
- package/package.json +6 -4
package/dist/index.d.ts
CHANGED
|
@@ -18,7 +18,7 @@ interface ValidationResult {
|
|
|
18
18
|
typesGenerated?: number;
|
|
19
19
|
}
|
|
20
20
|
interface ValidationError {
|
|
21
|
-
type: 'schema' | 'file-missing' | 'export-mismatch' | 'contract-invalid' | 'type-generation-failed';
|
|
21
|
+
type: 'schema' | 'file-missing' | 'export-mismatch' | 'contract-invalid' | 'component-contract-mismatch' | 'type-generation-failed';
|
|
22
22
|
message: string;
|
|
23
23
|
location?: string;
|
|
24
24
|
suggestion?: string;
|
|
@@ -43,4 +43,40 @@ interface PluginContext {
|
|
|
43
43
|
*/
|
|
44
44
|
declare function validatePlugin(options?: ValidatePluginOptions): Promise<ValidationResult>;
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Component-contract consistency checker (Design Log #124, Phase 3).
|
|
48
|
+
*
|
|
49
|
+
* Single-file AST analysis: detects .withProps<T>() and .withLoadParams(fn)
|
|
50
|
+
* in a component's TypeScript source, then compares the type's properties
|
|
51
|
+
* against the contract's props/params declarations.
|
|
52
|
+
*
|
|
53
|
+
* Uses @jay-framework/typescript-bridge for AST parsing — same approach as
|
|
54
|
+
* source-file-binding-resolver.ts in the compiler package.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
interface ContractPropsAndParams {
|
|
58
|
+
props?: Array<{
|
|
59
|
+
name: string;
|
|
60
|
+
required?: boolean;
|
|
61
|
+
}>;
|
|
62
|
+
params?: Array<{
|
|
63
|
+
name: string;
|
|
64
|
+
}>;
|
|
65
|
+
}
|
|
66
|
+
interface CheckResult {
|
|
67
|
+
errors: ValidationError[];
|
|
68
|
+
warnings: ValidationWarning[];
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Check that a component's .withProps<T>() and .withLoadParams(fn) usage
|
|
72
|
+
* is consistent with its contract's props/params declarations.
|
|
73
|
+
*
|
|
74
|
+
* @param sourceCode - The component's TypeScript source code
|
|
75
|
+
* @param contract - The parsed contract (props/params from the .jay-contract file)
|
|
76
|
+
* @param contractName - The contract name (without .jay-contract suffix), used in error messages
|
|
77
|
+
* @param contractPath - Path to the contract file (for error location)
|
|
78
|
+
* @param sourcePath - Path to the component source (for error location)
|
|
79
|
+
*/
|
|
80
|
+
declare function checkComponentPropsAndParams(sourceCode: string, contract: ContractPropsAndParams, contractName: string, contractPath: string, sourcePath: string): CheckResult;
|
|
81
|
+
|
|
82
|
+
export { type PluginContext, type ValidatePluginOptions, type ValidationError, type ValidationResult, type ValidationWarning, checkComponentPropsAndParams, validatePlugin };
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,276 @@ import fs from "fs";
|
|
|
2
2
|
import path from "path";
|
|
3
3
|
import YAML from "yaml";
|
|
4
4
|
import { loadPluginManifest } from "@jay-framework/compiler-shared";
|
|
5
|
+
import { parseContract } from "@jay-framework/compiler-jay-html";
|
|
6
|
+
import { ts } from "@jay-framework/typescript-bridge";
|
|
7
|
+
const FRAMEWORK_PROP_TYPES = /* @__PURE__ */ new Set(["PageProps", "RequestQuery"]);
|
|
8
|
+
function checkComponentPropsAndParams(sourceCode, contract, contractName, contractPath, sourcePath) {
|
|
9
|
+
const errors = [];
|
|
10
|
+
const warnings = [];
|
|
11
|
+
const sourceFile = ts.createSourceFile(
|
|
12
|
+
sourcePath,
|
|
13
|
+
sourceCode,
|
|
14
|
+
ts.ScriptTarget.Latest,
|
|
15
|
+
true,
|
|
16
|
+
ts.ScriptKind.TS
|
|
17
|
+
);
|
|
18
|
+
const localInterfaces = collectLocalInterfaces(sourceFile);
|
|
19
|
+
const contractImportedTypes = collectContractImportedTypes(sourceFile);
|
|
20
|
+
const builderInfo = analyzeBuilderChains(sourceFile);
|
|
21
|
+
for (const propsTypeName of builderInfo.propsTypeNames) {
|
|
22
|
+
checkPropsConsistency(
|
|
23
|
+
propsTypeName,
|
|
24
|
+
localInterfaces,
|
|
25
|
+
contractImportedTypes,
|
|
26
|
+
contract,
|
|
27
|
+
contractName,
|
|
28
|
+
contractPath,
|
|
29
|
+
sourcePath,
|
|
30
|
+
errors,
|
|
31
|
+
warnings
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
if (builderInfo.hasLoadParams) {
|
|
35
|
+
checkParamsConsistency(
|
|
36
|
+
builderInfo.paramsTypeNames,
|
|
37
|
+
localInterfaces,
|
|
38
|
+
contractImportedTypes,
|
|
39
|
+
contract,
|
|
40
|
+
contractName,
|
|
41
|
+
contractPath,
|
|
42
|
+
sourcePath,
|
|
43
|
+
errors,
|
|
44
|
+
warnings
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
return { errors, warnings };
|
|
48
|
+
}
|
|
49
|
+
function collectLocalInterfaces(sourceFile) {
|
|
50
|
+
const interfaces = /* @__PURE__ */ new Map();
|
|
51
|
+
for (const statement of sourceFile.statements) {
|
|
52
|
+
if (ts.isInterfaceDeclaration(statement)) {
|
|
53
|
+
const name = statement.name.text;
|
|
54
|
+
const properties = [];
|
|
55
|
+
for (const member of statement.members) {
|
|
56
|
+
if (ts.isPropertySignature(member) && member.name) {
|
|
57
|
+
if (ts.isIdentifier(member.name)) {
|
|
58
|
+
properties.push(member.name.text);
|
|
59
|
+
} else if (ts.isStringLiteral(member.name)) {
|
|
60
|
+
properties.push(member.name.text);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const extendsTypes = [];
|
|
65
|
+
if (statement.heritageClauses) {
|
|
66
|
+
for (const clause of statement.heritageClauses) {
|
|
67
|
+
for (const type of clause.types) {
|
|
68
|
+
if (ts.isIdentifier(type.expression)) {
|
|
69
|
+
extendsTypes.push(type.expression.text);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
interfaces.set(name, { name, properties, extendsTypes });
|
|
75
|
+
}
|
|
76
|
+
if (ts.isTypeAliasDeclaration(statement)) {
|
|
77
|
+
const name = statement.name.text;
|
|
78
|
+
if (ts.isTypeLiteralNode(statement.type)) {
|
|
79
|
+
const properties = [];
|
|
80
|
+
for (const member of statement.type.members) {
|
|
81
|
+
if (ts.isPropertySignature(member) && member.name) {
|
|
82
|
+
if (ts.isIdentifier(member.name)) {
|
|
83
|
+
properties.push(member.name.text);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
interfaces.set(name, { name, properties, extendsTypes: [] });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return interfaces;
|
|
92
|
+
}
|
|
93
|
+
function collectContractImportedTypes(sourceFile) {
|
|
94
|
+
const contractTypes = /* @__PURE__ */ new Set();
|
|
95
|
+
for (const statement of sourceFile.statements) {
|
|
96
|
+
if (!ts.isImportDeclaration(statement))
|
|
97
|
+
continue;
|
|
98
|
+
const moduleSpecifier = statement.moduleSpecifier;
|
|
99
|
+
if (!ts.isStringLiteral(moduleSpecifier))
|
|
100
|
+
continue;
|
|
101
|
+
const modulePath = moduleSpecifier.text;
|
|
102
|
+
if (!modulePath.includes(".jay-contract"))
|
|
103
|
+
continue;
|
|
104
|
+
const importClause = statement.importClause;
|
|
105
|
+
if (!importClause)
|
|
106
|
+
continue;
|
|
107
|
+
const namedBindings = importClause.namedBindings;
|
|
108
|
+
if (namedBindings && ts.isNamedImports(namedBindings)) {
|
|
109
|
+
for (const element of namedBindings.elements) {
|
|
110
|
+
contractTypes.add(element.name.text);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return contractTypes;
|
|
115
|
+
}
|
|
116
|
+
function analyzeBuilderChains(sourceFile) {
|
|
117
|
+
const result = {
|
|
118
|
+
propsTypeNames: [],
|
|
119
|
+
hasLoadParams: false,
|
|
120
|
+
paramsTypeNames: []
|
|
121
|
+
};
|
|
122
|
+
visitNode(sourceFile, result);
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
function visitNode(node, result) {
|
|
126
|
+
if (ts.isCallExpression(node)) {
|
|
127
|
+
const expr = node.expression;
|
|
128
|
+
if (ts.isPropertyAccessExpression(expr) && expr.name.text === "withProps") {
|
|
129
|
+
if (node.typeArguments && node.typeArguments.length > 0) {
|
|
130
|
+
const typeNames = extractTypeNames(node.typeArguments[0]);
|
|
131
|
+
result.propsTypeNames.push(...typeNames);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (ts.isPropertyAccessExpression(expr) && expr.name.text === "withLoadParams") {
|
|
135
|
+
result.hasLoadParams = true;
|
|
136
|
+
if (node.typeArguments && node.typeArguments.length > 0) {
|
|
137
|
+
const typeNames = extractTypeNames(node.typeArguments[0]);
|
|
138
|
+
result.paramsTypeNames.push(...typeNames);
|
|
139
|
+
}
|
|
140
|
+
if (node.arguments.length > 0) {
|
|
141
|
+
const arg = node.arguments[0];
|
|
142
|
+
if (ts.isIdentifier(arg))
|
|
143
|
+
;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
ts.forEachChild(node, (child) => visitNode(child, result));
|
|
148
|
+
}
|
|
149
|
+
function extractTypeNames(typeNode) {
|
|
150
|
+
if (ts.isTypeReferenceNode(typeNode)) {
|
|
151
|
+
if (ts.isIdentifier(typeNode.typeName)) {
|
|
152
|
+
return [typeNode.typeName.text];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (ts.isIntersectionTypeNode(typeNode)) {
|
|
156
|
+
const names = [];
|
|
157
|
+
for (const member of typeNode.types) {
|
|
158
|
+
names.push(...extractTypeNames(member));
|
|
159
|
+
}
|
|
160
|
+
return names;
|
|
161
|
+
}
|
|
162
|
+
return [];
|
|
163
|
+
}
|
|
164
|
+
function checkPropsConsistency(propsTypeName, localInterfaces, contractImportedTypes, contract, contractName, contractPath, sourcePath, errors, warnings) {
|
|
165
|
+
if (FRAMEWORK_PROP_TYPES.has(propsTypeName))
|
|
166
|
+
return;
|
|
167
|
+
if (contractImportedTypes.has(propsTypeName))
|
|
168
|
+
return;
|
|
169
|
+
const prefix = `[${contractName}]`;
|
|
170
|
+
const iface = localInterfaces.get(propsTypeName);
|
|
171
|
+
if (!iface) {
|
|
172
|
+
if (!contract.props || contract.props.length === 0) {
|
|
173
|
+
errors.push({
|
|
174
|
+
type: "contract-invalid",
|
|
175
|
+
message: `${prefix} component uses .withProps<${propsTypeName}>() but the contract does not declare any props`,
|
|
176
|
+
location: contractPath,
|
|
177
|
+
suggestion: `Add a props section to the contract`
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const ownProperties = iface.properties;
|
|
183
|
+
if (ownProperties.length === 0)
|
|
184
|
+
return;
|
|
185
|
+
if (!contract.props || contract.props.length === 0) {
|
|
186
|
+
errors.push({
|
|
187
|
+
type: "contract-invalid",
|
|
188
|
+
message: `${prefix} component uses .withProps<${propsTypeName}>() with properties [${ownProperties.join(", ")}] but the contract does not declare any props`,
|
|
189
|
+
location: contractPath,
|
|
190
|
+
suggestion: `Add to contract: props:
|
|
191
|
+
` + ownProperties.map((p) => ` - name: ${p}
|
|
192
|
+
type: string`).join("\n")
|
|
193
|
+
});
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const contractPropNames = new Set(contract.props.map((p) => p.name));
|
|
197
|
+
for (const prop of ownProperties) {
|
|
198
|
+
if (!contractPropNames.has(prop)) {
|
|
199
|
+
errors.push({
|
|
200
|
+
type: "contract-invalid",
|
|
201
|
+
message: `${prefix} component prop "${prop}" (from ${propsTypeName}) is not declared in the contract`,
|
|
202
|
+
location: contractPath,
|
|
203
|
+
suggestion: `Add to contract props: - name: ${prop}`
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
for (const contractProp of contract.props) {
|
|
208
|
+
if (!ownProperties.includes(contractProp.name)) {
|
|
209
|
+
warnings.push({
|
|
210
|
+
type: "contract-invalid",
|
|
211
|
+
message: `${prefix} contract declares prop "${contractProp.name}" but the component's ${propsTypeName} interface does not include it`,
|
|
212
|
+
location: sourcePath,
|
|
213
|
+
suggestion: `Add "${contractProp.name}" to the ${propsTypeName} interface, or remove it from the contract`
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
function checkParamsConsistency(paramsTypeNames, localInterfaces, contractImportedTypes, contract, contractName, contractPath, sourcePath, errors, warnings) {
|
|
219
|
+
const prefix = `[${contractName}]`;
|
|
220
|
+
if (!contract.params || contract.params.length === 0) {
|
|
221
|
+
errors.push({
|
|
222
|
+
type: "contract-invalid",
|
|
223
|
+
message: `${prefix} component uses .withLoadParams() but the contract does not declare any params`,
|
|
224
|
+
location: contractPath,
|
|
225
|
+
suggestion: `Add a params section to the contract (e.g., params: { slug: string })`
|
|
226
|
+
});
|
|
227
|
+
for (const typeName of paramsTypeNames) {
|
|
228
|
+
if (FRAMEWORK_PROP_TYPES.has(typeName))
|
|
229
|
+
continue;
|
|
230
|
+
if (contractImportedTypes.has(typeName))
|
|
231
|
+
continue;
|
|
232
|
+
const iface = localInterfaces.get(typeName);
|
|
233
|
+
if (iface) {
|
|
234
|
+
const ownProps = iface.properties;
|
|
235
|
+
if (ownProps.length > 0) {
|
|
236
|
+
errors[errors.length - 1].suggestion = `Add to contract: params:
|
|
237
|
+
` + ownProps.map((p) => ` ${p}: string`).join("\n");
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
for (const typeName of paramsTypeNames) {
|
|
244
|
+
if (FRAMEWORK_PROP_TYPES.has(typeName))
|
|
245
|
+
continue;
|
|
246
|
+
if (contractImportedTypes.has(typeName))
|
|
247
|
+
continue;
|
|
248
|
+
const iface = localInterfaces.get(typeName);
|
|
249
|
+
if (!iface)
|
|
250
|
+
continue;
|
|
251
|
+
const ownProperties = iface.properties;
|
|
252
|
+
const contractParamNames = new Set(contract.params.map((p) => p.name));
|
|
253
|
+
for (const prop of ownProperties) {
|
|
254
|
+
if (!contractParamNames.has(prop)) {
|
|
255
|
+
errors.push({
|
|
256
|
+
type: "contract-invalid",
|
|
257
|
+
message: `${prefix} component param "${prop}" (from ${typeName}) is not declared in the contract`,
|
|
258
|
+
location: contractPath,
|
|
259
|
+
suggestion: `Add to contract params: ${prop}: string`
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
for (const contractParam of contract.params) {
|
|
264
|
+
if (!ownProperties.includes(contractParam.name)) {
|
|
265
|
+
warnings.push({
|
|
266
|
+
type: "contract-invalid",
|
|
267
|
+
message: `${prefix} contract declares param "${contractParam.name}" but the component's ${typeName} interface does not include it`,
|
|
268
|
+
location: sourcePath,
|
|
269
|
+
suggestion: `Add "${contractParam.name}" to the ${typeName} interface, or remove it from the contract`
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
5
275
|
async function validatePlugin(options = {}) {
|
|
6
276
|
const pluginPath = options.pluginPath || process.cwd();
|
|
7
277
|
if (options.local) {
|
|
@@ -103,6 +373,36 @@ async function validateLocalPlugins(projectPath, options) {
|
|
|
103
373
|
typesGenerated: allResults.reduce((sum, r) => sum + (r.typesGenerated || 0), 0)
|
|
104
374
|
};
|
|
105
375
|
}
|
|
376
|
+
function validateDocFile(docPath, label, context, result) {
|
|
377
|
+
const resolvedPath = path.join(context.pluginPath, docPath);
|
|
378
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
379
|
+
result.errors.push({
|
|
380
|
+
type: "file-missing",
|
|
381
|
+
message: `Doc file for ${label} not found: ${docPath}`,
|
|
382
|
+
location: "plugin.yaml",
|
|
383
|
+
suggestion: `Create the documentation file at ${resolvedPath}`
|
|
384
|
+
});
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (context.isNpmPackage) {
|
|
388
|
+
const packageJsonPath = path.join(context.pluginPath, "package.json");
|
|
389
|
+
try {
|
|
390
|
+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
391
|
+
if (packageJson.exports) {
|
|
392
|
+
const exportKey = "./" + docPath.replace(/^\.\//, "");
|
|
393
|
+
if (!packageJson.exports[exportKey]) {
|
|
394
|
+
result.errors.push({
|
|
395
|
+
type: "export-mismatch",
|
|
396
|
+
message: `Doc file for ${label} is not exported in package.json: ${docPath}`,
|
|
397
|
+
location: packageJsonPath,
|
|
398
|
+
suggestion: `Add "${exportKey}": "${docPath}" to the exports field`
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
} catch {
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
106
406
|
async function validateSchema(context, result) {
|
|
107
407
|
const { manifest } = context;
|
|
108
408
|
if (!manifest.name) {
|
|
@@ -193,46 +493,111 @@ async function validateSchema(context, result) {
|
|
|
193
493
|
suggestion: 'Add either "contracts" or "dynamic_contracts" to expose functionality'
|
|
194
494
|
});
|
|
195
495
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
result.contractsChecked = (result.contractsChecked || 0) + 1;
|
|
199
|
-
let contractPath;
|
|
200
|
-
if (context.isNpmPackage) {
|
|
201
|
-
const contractSpec = contract.contract;
|
|
202
|
-
const possiblePaths = [
|
|
203
|
-
path.join(context.pluginPath, "dist", contractSpec),
|
|
204
|
-
path.join(context.pluginPath, "lib", contractSpec),
|
|
205
|
-
path.join(context.pluginPath, contractSpec)
|
|
206
|
-
];
|
|
207
|
-
let found = false;
|
|
208
|
-
for (const possiblePath of possiblePaths) {
|
|
209
|
-
if (fs.existsSync(possiblePath)) {
|
|
210
|
-
contractPath = possiblePath;
|
|
211
|
-
found = true;
|
|
212
|
-
break;
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
if (!found) {
|
|
496
|
+
if (manifest.services) {
|
|
497
|
+
if (!Array.isArray(manifest.services)) {
|
|
216
498
|
result.errors.push({
|
|
217
|
-
type: "
|
|
218
|
-
message:
|
|
219
|
-
location:
|
|
220
|
-
|
|
499
|
+
type: "schema",
|
|
500
|
+
message: 'Field "services" must be an array',
|
|
501
|
+
location: "plugin.yaml"
|
|
502
|
+
});
|
|
503
|
+
} else {
|
|
504
|
+
manifest.services.forEach((service, index) => {
|
|
505
|
+
if (!service.name) {
|
|
506
|
+
result.errors.push({
|
|
507
|
+
type: "schema",
|
|
508
|
+
message: `Service at index ${index} is missing "name" field`,
|
|
509
|
+
location: "plugin.yaml"
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
if (!service.marker) {
|
|
513
|
+
result.errors.push({
|
|
514
|
+
type: "schema",
|
|
515
|
+
message: `Service "${service.name || index}" is missing "marker" field`,
|
|
516
|
+
location: "plugin.yaml",
|
|
517
|
+
suggestion: "Specify the exported service marker constant name"
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
if (service.doc) {
|
|
521
|
+
validateDocFile(service.doc, `service "${service.name}"`, context, result);
|
|
522
|
+
}
|
|
221
523
|
});
|
|
222
|
-
return;
|
|
223
524
|
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
if (!
|
|
525
|
+
}
|
|
526
|
+
if (manifest.contexts) {
|
|
527
|
+
if (!Array.isArray(manifest.contexts)) {
|
|
227
528
|
result.errors.push({
|
|
228
|
-
type: "
|
|
229
|
-
message:
|
|
230
|
-
location:
|
|
231
|
-
|
|
529
|
+
type: "schema",
|
|
530
|
+
message: 'Field "contexts" must be an array',
|
|
531
|
+
location: "plugin.yaml"
|
|
532
|
+
});
|
|
533
|
+
} else {
|
|
534
|
+
manifest.contexts.forEach((ctx, index) => {
|
|
535
|
+
if (!ctx.name) {
|
|
536
|
+
result.errors.push({
|
|
537
|
+
type: "schema",
|
|
538
|
+
message: `Context at index ${index} is missing "name" field`,
|
|
539
|
+
location: "plugin.yaml"
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
if (!ctx.marker) {
|
|
543
|
+
result.errors.push({
|
|
544
|
+
type: "schema",
|
|
545
|
+
message: `Context "${ctx.name || index}" is missing "marker" field`,
|
|
546
|
+
location: "plugin.yaml",
|
|
547
|
+
suggestion: "Specify the exported context marker constant name"
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
if (ctx.doc) {
|
|
551
|
+
validateDocFile(ctx.doc, `context "${ctx.name}"`, context, result);
|
|
552
|
+
}
|
|
232
553
|
});
|
|
233
|
-
return;
|
|
234
554
|
}
|
|
235
555
|
}
|
|
556
|
+
}
|
|
557
|
+
function resolveContractFile(contractSpec, context) {
|
|
558
|
+
if (context.isNpmPackage) {
|
|
559
|
+
const packageJsonPath = path.join(context.pluginPath, "package.json");
|
|
560
|
+
if (fs.existsSync(packageJsonPath)) {
|
|
561
|
+
try {
|
|
562
|
+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
563
|
+
if (packageJson.exports) {
|
|
564
|
+
const exportKey = "./" + contractSpec;
|
|
565
|
+
const exportValue = packageJson.exports[exportKey];
|
|
566
|
+
if (exportValue) {
|
|
567
|
+
const resolvedPath = typeof exportValue === "string" ? exportValue : exportValue.default || exportValue.import || exportValue.require;
|
|
568
|
+
if (resolvedPath) {
|
|
569
|
+
const fullPath = path.join(context.pluginPath, resolvedPath);
|
|
570
|
+
if (fs.existsSync(fullPath))
|
|
571
|
+
return fullPath;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
} catch {
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
for (const dir of ["dist", "lib", ""]) {
|
|
579
|
+
const candidate = path.join(context.pluginPath, dir, contractSpec);
|
|
580
|
+
if (fs.existsSync(candidate))
|
|
581
|
+
return candidate;
|
|
582
|
+
}
|
|
583
|
+
return void 0;
|
|
584
|
+
} else {
|
|
585
|
+
const candidate = path.join(context.pluginPath, contractSpec);
|
|
586
|
+
return fs.existsSync(candidate) ? candidate : void 0;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
async function validateContract(contract, index, context, generateTypes, result) {
|
|
590
|
+
result.contractsChecked = (result.contractsChecked || 0) + 1;
|
|
591
|
+
const contractPath = resolveContractFile(contract.contract, context);
|
|
592
|
+
if (!contractPath) {
|
|
593
|
+
result.errors.push({
|
|
594
|
+
type: "file-missing",
|
|
595
|
+
message: `Contract file not found: ${contract.contract}`,
|
|
596
|
+
location: `plugin.yaml contracts[${index}]`,
|
|
597
|
+
suggestion: context.isNpmPackage ? `Ensure the contract is exported in package.json and the file exists` : `Create the contract file at ${path.join(context.pluginPath, contract.contract)}`
|
|
598
|
+
});
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
236
601
|
try {
|
|
237
602
|
const contractContent = await fs.promises.readFile(contractPath, "utf-8");
|
|
238
603
|
const parsedContract = YAML.parse(contractContent);
|
|
@@ -283,6 +648,7 @@ async function validateComponent(contract, index, context, result) {
|
|
|
283
648
|
location: `plugin.yaml contracts[${index}]`,
|
|
284
649
|
suggestion: 'Component should be the exported member name (e.g., "moodTracker")'
|
|
285
650
|
});
|
|
651
|
+
return;
|
|
286
652
|
}
|
|
287
653
|
if (contract.component.includes("/") || contract.component.includes(".")) {
|
|
288
654
|
result.warnings.push({
|
|
@@ -292,6 +658,143 @@ async function validateComponent(contract, index, context, result) {
|
|
|
292
658
|
suggestion: 'Component should be the exported member name (e.g., "moodTracker"), not a file path'
|
|
293
659
|
});
|
|
294
660
|
}
|
|
661
|
+
await checkComponentContractConsistency(contract, context, result);
|
|
662
|
+
}
|
|
663
|
+
function hasExportModifier(node) {
|
|
664
|
+
return node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) ?? false;
|
|
665
|
+
}
|
|
666
|
+
function resolveModulePath(basePath) {
|
|
667
|
+
for (const ext of ["", ".ts", ".js", "/index.ts", "/index.js"]) {
|
|
668
|
+
const candidate = basePath + ext;
|
|
669
|
+
if (fs.existsSync(candidate))
|
|
670
|
+
return candidate;
|
|
671
|
+
}
|
|
672
|
+
return void 0;
|
|
673
|
+
}
|
|
674
|
+
function resolveComponentSourcePath(componentName, context) {
|
|
675
|
+
const modulePath = context.manifest.module || "index";
|
|
676
|
+
const entryBase = path.join(context.pluginPath, modulePath);
|
|
677
|
+
const entryFile = resolveModulePath(entryBase);
|
|
678
|
+
const libEntryFile = !entryFile ? resolveModulePath(path.join(context.pluginPath, "lib", modulePath)) : void 0;
|
|
679
|
+
const sourceEntry = entryFile || libEntryFile;
|
|
680
|
+
if (!sourceEntry)
|
|
681
|
+
return void 0;
|
|
682
|
+
if (!sourceEntry.endsWith(".ts"))
|
|
683
|
+
return void 0;
|
|
684
|
+
let sourceCode;
|
|
685
|
+
try {
|
|
686
|
+
sourceCode = fs.readFileSync(sourceEntry, "utf-8");
|
|
687
|
+
} catch {
|
|
688
|
+
return void 0;
|
|
689
|
+
}
|
|
690
|
+
const sourceFile = ts.createSourceFile(
|
|
691
|
+
sourceEntry,
|
|
692
|
+
sourceCode,
|
|
693
|
+
ts.ScriptTarget.Latest,
|
|
694
|
+
true,
|
|
695
|
+
ts.ScriptKind.TS
|
|
696
|
+
);
|
|
697
|
+
const starReexportModules = [];
|
|
698
|
+
for (const statement of sourceFile.statements) {
|
|
699
|
+
if (!ts.isExportDeclaration(statement))
|
|
700
|
+
continue;
|
|
701
|
+
if (!statement.moduleSpecifier)
|
|
702
|
+
continue;
|
|
703
|
+
if (!ts.isStringLiteral(statement.moduleSpecifier))
|
|
704
|
+
continue;
|
|
705
|
+
const moduleSpec = statement.moduleSpecifier.text;
|
|
706
|
+
const exportClause = statement.exportClause;
|
|
707
|
+
if (!exportClause) {
|
|
708
|
+
starReexportModules.push(moduleSpec);
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
if (ts.isNamedExports(exportClause)) {
|
|
712
|
+
for (const element of exportClause.elements) {
|
|
713
|
+
const exportedName = element.name.text;
|
|
714
|
+
if (exportedName === componentName) {
|
|
715
|
+
const resolvedBase = path.resolve(path.dirname(sourceEntry), moduleSpec);
|
|
716
|
+
return resolveModulePath(resolvedBase);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
for (const moduleSpec of starReexportModules) {
|
|
722
|
+
if (!moduleSpec.startsWith("."))
|
|
723
|
+
continue;
|
|
724
|
+
const resolvedBase = path.resolve(path.dirname(sourceEntry), moduleSpec);
|
|
725
|
+
const resolvedPath = resolveModulePath(resolvedBase);
|
|
726
|
+
if (!resolvedPath || !resolvedPath.endsWith(".ts"))
|
|
727
|
+
continue;
|
|
728
|
+
try {
|
|
729
|
+
const modSource = fs.readFileSync(resolvedPath, "utf-8");
|
|
730
|
+
const modFile = ts.createSourceFile(
|
|
731
|
+
resolvedPath,
|
|
732
|
+
modSource,
|
|
733
|
+
ts.ScriptTarget.Latest,
|
|
734
|
+
true,
|
|
735
|
+
ts.ScriptKind.TS
|
|
736
|
+
);
|
|
737
|
+
for (const stmt of modFile.statements) {
|
|
738
|
+
if (ts.isVariableStatement(stmt) && hasExportModifier(stmt)) {
|
|
739
|
+
for (const decl of stmt.declarationList.declarations) {
|
|
740
|
+
if (ts.isIdentifier(decl.name) && decl.name.text === componentName) {
|
|
741
|
+
return resolvedPath;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
if (ts.isFunctionDeclaration(stmt) && hasExportModifier(stmt) && stmt.name?.text === componentName) {
|
|
746
|
+
return resolvedPath;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
} catch {
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
return sourceEntry;
|
|
754
|
+
}
|
|
755
|
+
function resolveContractPath(contract, context) {
|
|
756
|
+
return resolveContractFile(contract.contract, context);
|
|
757
|
+
}
|
|
758
|
+
async function checkComponentContractConsistency(contract, context, result) {
|
|
759
|
+
const componentName = contract.component;
|
|
760
|
+
if (!componentName)
|
|
761
|
+
return;
|
|
762
|
+
const sourcePath = resolveComponentSourcePath(componentName, context);
|
|
763
|
+
if (!sourcePath)
|
|
764
|
+
return;
|
|
765
|
+
if (!sourcePath.endsWith(".ts"))
|
|
766
|
+
return;
|
|
767
|
+
const contractPath = resolveContractPath(contract, context);
|
|
768
|
+
if (!contractPath)
|
|
769
|
+
return;
|
|
770
|
+
let contractContent;
|
|
771
|
+
try {
|
|
772
|
+
contractContent = await fs.promises.readFile(contractPath, "utf-8");
|
|
773
|
+
} catch {
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
const parsed = parseContract(contractContent, path.basename(contractPath));
|
|
777
|
+
if (parsed.validations.length > 0)
|
|
778
|
+
return;
|
|
779
|
+
let sourceCode;
|
|
780
|
+
try {
|
|
781
|
+
sourceCode = await fs.promises.readFile(sourcePath, "utf-8");
|
|
782
|
+
} catch {
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
const contractName = contract.contract.replace(/\.jay-contract$/, "");
|
|
786
|
+
const checkResult = checkComponentPropsAndParams(
|
|
787
|
+
sourceCode,
|
|
788
|
+
{
|
|
789
|
+
props: parsed.val?.props,
|
|
790
|
+
params: parsed.val?.params
|
|
791
|
+
},
|
|
792
|
+
contractName,
|
|
793
|
+
contractPath,
|
|
794
|
+
sourcePath
|
|
795
|
+
);
|
|
796
|
+
result.errors.push(...checkResult.errors);
|
|
797
|
+
result.warnings.push(...checkResult.warnings);
|
|
295
798
|
}
|
|
296
799
|
async function validatePackageJson(context, result) {
|
|
297
800
|
const packageJsonPath = path.join(context.pluginPath, "package.json");
|
|
@@ -423,5 +926,6 @@ async function validateDynamicContracts(context, result) {
|
|
|
423
926
|
}
|
|
424
927
|
}
|
|
425
928
|
export {
|
|
929
|
+
checkComponentPropsAndParams,
|
|
426
930
|
validatePlugin
|
|
427
931
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jay-framework/plugin-validator",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"description": "Validation tool for Jay Stack plugins",
|
|
@@ -25,13 +25,15 @@
|
|
|
25
25
|
"test:watch": ":"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@jay-framework/compiler-jay-html": "^0.15.
|
|
29
|
-
"@jay-framework/
|
|
28
|
+
"@jay-framework/compiler-jay-html": "^0.15.6",
|
|
29
|
+
"@jay-framework/compiler-shared": "^0.15.6",
|
|
30
|
+
"@jay-framework/editor-protocol": "^0.15.6",
|
|
31
|
+
"@jay-framework/typescript-bridge": "^0.15.6",
|
|
30
32
|
"chalk": "^4.1.2",
|
|
31
33
|
"yaml": "^2.3.4"
|
|
32
34
|
},
|
|
33
35
|
"devDependencies": {
|
|
34
|
-
"@jay-framework/dev-environment": "^0.15.
|
|
36
|
+
"@jay-framework/dev-environment": "^0.15.6",
|
|
35
37
|
"@types/node": "^22.15.21",
|
|
36
38
|
"rimraf": "^5.0.5",
|
|
37
39
|
"tsup": "^8.0.1",
|