@jay-framework/plugin-validator 0.15.5 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +38 -2
- package/dist/index.js +582 -32
- 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,157 @@ async function validateSchema(context, result) {
|
|
|
193
493
|
suggestion: 'Add either "contracts" or "dynamic_contracts" to expose functionality'
|
|
194
494
|
});
|
|
195
495
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
496
|
+
if (manifest.services) {
|
|
497
|
+
if (!Array.isArray(manifest.services)) {
|
|
498
|
+
result.errors.push({
|
|
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
|
+
}
|
|
523
|
+
});
|
|
214
524
|
}
|
|
215
|
-
|
|
525
|
+
}
|
|
526
|
+
if (manifest.contexts) {
|
|
527
|
+
if (!Array.isArray(manifest.contexts)) {
|
|
216
528
|
result.errors.push({
|
|
217
|
-
type: "
|
|
218
|
-
message:
|
|
219
|
-
location:
|
|
220
|
-
|
|
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
|
+
}
|
|
221
553
|
});
|
|
222
|
-
return;
|
|
223
554
|
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
if (!
|
|
555
|
+
}
|
|
556
|
+
if (manifest.routes) {
|
|
557
|
+
if (!Array.isArray(manifest.routes)) {
|
|
227
558
|
result.errors.push({
|
|
228
|
-
type: "
|
|
229
|
-
message:
|
|
230
|
-
location:
|
|
231
|
-
|
|
559
|
+
type: "schema",
|
|
560
|
+
message: 'Field "routes" must be an array',
|
|
561
|
+
location: "plugin.yaml"
|
|
562
|
+
});
|
|
563
|
+
} else {
|
|
564
|
+
manifest.routes.forEach((route, index) => {
|
|
565
|
+
if (!route.path) {
|
|
566
|
+
result.errors.push({
|
|
567
|
+
type: "schema",
|
|
568
|
+
message: `Route at index ${index} is missing "path" field`,
|
|
569
|
+
location: "plugin.yaml"
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
if (!route.jayHtml) {
|
|
573
|
+
result.errors.push({
|
|
574
|
+
type: "schema",
|
|
575
|
+
message: `Route "${route.path || index}" is missing "jayHtml" field`,
|
|
576
|
+
location: "plugin.yaml",
|
|
577
|
+
suggestion: "Specify the export subpath for the jay-html file"
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
if (!route.component) {
|
|
581
|
+
result.errors.push({
|
|
582
|
+
type: "schema",
|
|
583
|
+
message: `Route "${route.path || index}" is missing "component" field`,
|
|
584
|
+
location: "plugin.yaml",
|
|
585
|
+
suggestion: "Specify the exported member name for the page component"
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
if (route.jayHtml) {
|
|
589
|
+
validateDocFile(
|
|
590
|
+
route.jayHtml,
|
|
591
|
+
`route "${route.path}" jayHtml`,
|
|
592
|
+
context,
|
|
593
|
+
result
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
if (route.css) {
|
|
597
|
+
validateDocFile(route.css, `route "${route.path}" css`, context, result);
|
|
598
|
+
}
|
|
232
599
|
});
|
|
233
|
-
return;
|
|
234
600
|
}
|
|
235
601
|
}
|
|
602
|
+
}
|
|
603
|
+
function resolveContractFile(contractSpec, context) {
|
|
604
|
+
if (context.isNpmPackage) {
|
|
605
|
+
const packageJsonPath = path.join(context.pluginPath, "package.json");
|
|
606
|
+
if (fs.existsSync(packageJsonPath)) {
|
|
607
|
+
try {
|
|
608
|
+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
609
|
+
if (packageJson.exports) {
|
|
610
|
+
const exportKey = "./" + contractSpec;
|
|
611
|
+
const exportValue = packageJson.exports[exportKey];
|
|
612
|
+
if (exportValue) {
|
|
613
|
+
const resolvedPath = typeof exportValue === "string" ? exportValue : exportValue.default || exportValue.import || exportValue.require;
|
|
614
|
+
if (resolvedPath) {
|
|
615
|
+
const fullPath = path.join(context.pluginPath, resolvedPath);
|
|
616
|
+
if (fs.existsSync(fullPath))
|
|
617
|
+
return fullPath;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
} catch {
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
for (const dir of ["dist", "lib", ""]) {
|
|
625
|
+
const candidate = path.join(context.pluginPath, dir, contractSpec);
|
|
626
|
+
if (fs.existsSync(candidate))
|
|
627
|
+
return candidate;
|
|
628
|
+
}
|
|
629
|
+
return void 0;
|
|
630
|
+
} else {
|
|
631
|
+
const candidate = path.join(context.pluginPath, contractSpec);
|
|
632
|
+
return fs.existsSync(candidate) ? candidate : void 0;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
async function validateContract(contract, index, context, generateTypes, result) {
|
|
636
|
+
result.contractsChecked = (result.contractsChecked || 0) + 1;
|
|
637
|
+
const contractPath = resolveContractFile(contract.contract, context);
|
|
638
|
+
if (!contractPath) {
|
|
639
|
+
result.errors.push({
|
|
640
|
+
type: "file-missing",
|
|
641
|
+
message: `Contract file not found: ${contract.contract}`,
|
|
642
|
+
location: `plugin.yaml contracts[${index}]`,
|
|
643
|
+
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)}`
|
|
644
|
+
});
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
236
647
|
try {
|
|
237
648
|
const contractContent = await fs.promises.readFile(contractPath, "utf-8");
|
|
238
649
|
const parsedContract = YAML.parse(contractContent);
|
|
@@ -283,6 +694,7 @@ async function validateComponent(contract, index, context, result) {
|
|
|
283
694
|
location: `plugin.yaml contracts[${index}]`,
|
|
284
695
|
suggestion: 'Component should be the exported member name (e.g., "moodTracker")'
|
|
285
696
|
});
|
|
697
|
+
return;
|
|
286
698
|
}
|
|
287
699
|
if (contract.component.includes("/") || contract.component.includes(".")) {
|
|
288
700
|
result.warnings.push({
|
|
@@ -292,6 +704,143 @@ async function validateComponent(contract, index, context, result) {
|
|
|
292
704
|
suggestion: 'Component should be the exported member name (e.g., "moodTracker"), not a file path'
|
|
293
705
|
});
|
|
294
706
|
}
|
|
707
|
+
await checkComponentContractConsistency(contract, context, result);
|
|
708
|
+
}
|
|
709
|
+
function hasExportModifier(node) {
|
|
710
|
+
return node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) ?? false;
|
|
711
|
+
}
|
|
712
|
+
function resolveModulePath(basePath) {
|
|
713
|
+
for (const ext of ["", ".ts", ".js", "/index.ts", "/index.js"]) {
|
|
714
|
+
const candidate = basePath + ext;
|
|
715
|
+
if (fs.existsSync(candidate))
|
|
716
|
+
return candidate;
|
|
717
|
+
}
|
|
718
|
+
return void 0;
|
|
719
|
+
}
|
|
720
|
+
function resolveComponentSourcePath(componentName, context) {
|
|
721
|
+
const modulePath = context.manifest.module || "index";
|
|
722
|
+
const entryBase = path.join(context.pluginPath, modulePath);
|
|
723
|
+
const entryFile = resolveModulePath(entryBase);
|
|
724
|
+
const libEntryFile = !entryFile ? resolveModulePath(path.join(context.pluginPath, "lib", modulePath)) : void 0;
|
|
725
|
+
const sourceEntry = entryFile || libEntryFile;
|
|
726
|
+
if (!sourceEntry)
|
|
727
|
+
return void 0;
|
|
728
|
+
if (!sourceEntry.endsWith(".ts"))
|
|
729
|
+
return void 0;
|
|
730
|
+
let sourceCode;
|
|
731
|
+
try {
|
|
732
|
+
sourceCode = fs.readFileSync(sourceEntry, "utf-8");
|
|
733
|
+
} catch {
|
|
734
|
+
return void 0;
|
|
735
|
+
}
|
|
736
|
+
const sourceFile = ts.createSourceFile(
|
|
737
|
+
sourceEntry,
|
|
738
|
+
sourceCode,
|
|
739
|
+
ts.ScriptTarget.Latest,
|
|
740
|
+
true,
|
|
741
|
+
ts.ScriptKind.TS
|
|
742
|
+
);
|
|
743
|
+
const starReexportModules = [];
|
|
744
|
+
for (const statement of sourceFile.statements) {
|
|
745
|
+
if (!ts.isExportDeclaration(statement))
|
|
746
|
+
continue;
|
|
747
|
+
if (!statement.moduleSpecifier)
|
|
748
|
+
continue;
|
|
749
|
+
if (!ts.isStringLiteral(statement.moduleSpecifier))
|
|
750
|
+
continue;
|
|
751
|
+
const moduleSpec = statement.moduleSpecifier.text;
|
|
752
|
+
const exportClause = statement.exportClause;
|
|
753
|
+
if (!exportClause) {
|
|
754
|
+
starReexportModules.push(moduleSpec);
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
if (ts.isNamedExports(exportClause)) {
|
|
758
|
+
for (const element of exportClause.elements) {
|
|
759
|
+
const exportedName = element.name.text;
|
|
760
|
+
if (exportedName === componentName) {
|
|
761
|
+
const resolvedBase = path.resolve(path.dirname(sourceEntry), moduleSpec);
|
|
762
|
+
return resolveModulePath(resolvedBase);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
for (const moduleSpec of starReexportModules) {
|
|
768
|
+
if (!moduleSpec.startsWith("."))
|
|
769
|
+
continue;
|
|
770
|
+
const resolvedBase = path.resolve(path.dirname(sourceEntry), moduleSpec);
|
|
771
|
+
const resolvedPath = resolveModulePath(resolvedBase);
|
|
772
|
+
if (!resolvedPath || !resolvedPath.endsWith(".ts"))
|
|
773
|
+
continue;
|
|
774
|
+
try {
|
|
775
|
+
const modSource = fs.readFileSync(resolvedPath, "utf-8");
|
|
776
|
+
const modFile = ts.createSourceFile(
|
|
777
|
+
resolvedPath,
|
|
778
|
+
modSource,
|
|
779
|
+
ts.ScriptTarget.Latest,
|
|
780
|
+
true,
|
|
781
|
+
ts.ScriptKind.TS
|
|
782
|
+
);
|
|
783
|
+
for (const stmt of modFile.statements) {
|
|
784
|
+
if (ts.isVariableStatement(stmt) && hasExportModifier(stmt)) {
|
|
785
|
+
for (const decl of stmt.declarationList.declarations) {
|
|
786
|
+
if (ts.isIdentifier(decl.name) && decl.name.text === componentName) {
|
|
787
|
+
return resolvedPath;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
if (ts.isFunctionDeclaration(stmt) && hasExportModifier(stmt) && stmt.name?.text === componentName) {
|
|
792
|
+
return resolvedPath;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
} catch {
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
return sourceEntry;
|
|
800
|
+
}
|
|
801
|
+
function resolveContractPath(contract, context) {
|
|
802
|
+
return resolveContractFile(contract.contract, context);
|
|
803
|
+
}
|
|
804
|
+
async function checkComponentContractConsistency(contract, context, result) {
|
|
805
|
+
const componentName = contract.component;
|
|
806
|
+
if (!componentName)
|
|
807
|
+
return;
|
|
808
|
+
const sourcePath = resolveComponentSourcePath(componentName, context);
|
|
809
|
+
if (!sourcePath)
|
|
810
|
+
return;
|
|
811
|
+
if (!sourcePath.endsWith(".ts"))
|
|
812
|
+
return;
|
|
813
|
+
const contractPath = resolveContractPath(contract, context);
|
|
814
|
+
if (!contractPath)
|
|
815
|
+
return;
|
|
816
|
+
let contractContent;
|
|
817
|
+
try {
|
|
818
|
+
contractContent = await fs.promises.readFile(contractPath, "utf-8");
|
|
819
|
+
} catch {
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
const parsed = parseContract(contractContent, path.basename(contractPath));
|
|
823
|
+
if (parsed.validations.length > 0)
|
|
824
|
+
return;
|
|
825
|
+
let sourceCode;
|
|
826
|
+
try {
|
|
827
|
+
sourceCode = await fs.promises.readFile(sourcePath, "utf-8");
|
|
828
|
+
} catch {
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
const contractName = contract.contract.replace(/\.jay-contract$/, "");
|
|
832
|
+
const checkResult = checkComponentPropsAndParams(
|
|
833
|
+
sourceCode,
|
|
834
|
+
{
|
|
835
|
+
props: parsed.val?.props,
|
|
836
|
+
params: parsed.val?.params
|
|
837
|
+
},
|
|
838
|
+
contractName,
|
|
839
|
+
contractPath,
|
|
840
|
+
sourcePath
|
|
841
|
+
);
|
|
842
|
+
result.errors.push(...checkResult.errors);
|
|
843
|
+
result.warnings.push(...checkResult.warnings);
|
|
295
844
|
}
|
|
296
845
|
async function validatePackageJson(context, result) {
|
|
297
846
|
const packageJsonPath = path.join(context.pluginPath, "package.json");
|
|
@@ -423,5 +972,6 @@ async function validateDynamicContracts(context, result) {
|
|
|
423
972
|
}
|
|
424
973
|
}
|
|
425
974
|
export {
|
|
975
|
+
checkComponentPropsAndParams,
|
|
426
976
|
validatePlugin
|
|
427
977
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jay-framework/plugin-validator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
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.
|
|
29
|
-
"@jay-framework/
|
|
28
|
+
"@jay-framework/compiler-jay-html": "^0.16.0",
|
|
29
|
+
"@jay-framework/compiler-shared": "^0.16.0",
|
|
30
|
+
"@jay-framework/editor-protocol": "^0.16.0",
|
|
31
|
+
"@jay-framework/typescript-bridge": "^0.16.0",
|
|
30
32
|
"chalk": "^4.1.2",
|
|
31
33
|
"yaml": "^2.3.4"
|
|
32
34
|
},
|
|
33
35
|
"devDependencies": {
|
|
34
|
-
"@jay-framework/dev-environment": "^0.
|
|
36
|
+
"@jay-framework/dev-environment": "^0.16.0",
|
|
35
37
|
"@types/node": "^22.15.21",
|
|
36
38
|
"rimraf": "^5.0.5",
|
|
37
39
|
"tsup": "^8.0.1",
|