@rettangoli/check 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +295 -0
- package/ROADMAP.md +175 -0
- package/package.json +46 -0
- package/src/cli/bin.js +325 -0
- package/src/cli/check.js +232 -0
- package/src/cli/index.js +1 -0
- package/src/core/analyze.js +227 -0
- package/src/core/discovery.js +83 -0
- package/src/core/exportedFunctions.js +235 -0
- package/src/core/model.js +898 -0
- package/src/core/parsers.js +2726 -0
- package/src/core/registry.js +779 -0
- package/src/core/schema.js +161 -0
- package/src/core/scopeGraph.js +1400 -0
- package/src/core/semantic.js +329 -0
- package/src/diagnostics/autofix.js +191 -0
- package/src/diagnostics/catalog.js +89 -0
- package/src/index.js +2 -0
- package/src/reporters/index.js +13 -0
- package/src/reporters/json.js +42 -0
- package/src/reporters/sarif.js +213 -0
- package/src/reporters/text.js +145 -0
- package/src/rules/compatibility.js +318 -0
- package/src/rules/constants.js +22 -0
- package/src/rules/crossFileSymbols.js +108 -0
- package/src/rules/expression.js +338 -0
- package/src/rules/feParity.js +65 -0
- package/src/rules/index.js +39 -0
- package/src/rules/jempl.js +80 -0
- package/src/rules/lifecycle.js +4 -0
- package/src/rules/listenerConfig.js +556 -0
- package/src/rules/listenerSymbols.js +49 -0
- package/src/rules/methods.js +117 -0
- package/src/rules/refs.js +20 -0
- package/src/rules/schema.js +118 -0
- package/src/rules/shared.js +20 -0
- package/src/rules/yahtmlAttrs.js +238 -0
- package/src/semantic/engine.js +778 -0
- package/src/semantic/index.js +9 -0
- package/src/types/lattice.js +281 -0
- package/src/utils/case.js +9 -0
- package/src/utils/fs.js +30 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
DIAGNOSTIC_CATALOG_VERSION,
|
|
5
|
+
getDiagnosticCatalogEntry,
|
|
6
|
+
} from "../diagnostics/catalog.js";
|
|
7
|
+
|
|
8
|
+
const toPosixPath = (value = "") => String(value).replaceAll(path.sep, "/");
|
|
9
|
+
|
|
10
|
+
const mapLevel = (severity = "error") => (severity === "warn" ? "warning" : "error");
|
|
11
|
+
|
|
12
|
+
const compareDiagnostics = (left = {}, right = {}) => {
|
|
13
|
+
const leftLine = Number.isInteger(left.line) ? left.line : 0;
|
|
14
|
+
const rightLine = Number.isInteger(right.line) ? right.line : 0;
|
|
15
|
+
const leftColumn = Number.isInteger(left.column) ? left.column : 0;
|
|
16
|
+
const rightColumn = Number.isInteger(right.column) ? right.column : 0;
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
String(left.code || "").localeCompare(String(right.code || ""))
|
|
20
|
+
|| mapLevel(left.severity).localeCompare(mapLevel(right.severity))
|
|
21
|
+
|| String(left.filePath || "").localeCompare(String(right.filePath || ""))
|
|
22
|
+
|| leftLine - rightLine
|
|
23
|
+
|| leftColumn - rightColumn
|
|
24
|
+
|| String(left.message || "").localeCompare(String(right.message || ""))
|
|
25
|
+
);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const normalizeDiagnostics = (diagnostics = []) => {
|
|
29
|
+
return [...(Array.isArray(diagnostics) ? diagnostics : [])].sort(compareDiagnostics);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const toRuleMap = (diagnostics = []) => {
|
|
33
|
+
const codes = [...new Set(
|
|
34
|
+
normalizeDiagnostics(diagnostics)
|
|
35
|
+
.map((diagnostic) => String(diagnostic?.code || "RTGL-CHECK-UNKNOWN")),
|
|
36
|
+
)];
|
|
37
|
+
|
|
38
|
+
return codes.map((code) => {
|
|
39
|
+
const catalog = getDiagnosticCatalogEntry(code);
|
|
40
|
+
return {
|
|
41
|
+
id: code,
|
|
42
|
+
shortDescription: {
|
|
43
|
+
text: catalog.title,
|
|
44
|
+
},
|
|
45
|
+
fullDescription: {
|
|
46
|
+
text: catalog.description,
|
|
47
|
+
},
|
|
48
|
+
helpUri: catalog.docsPath,
|
|
49
|
+
defaultConfiguration: {
|
|
50
|
+
level: mapLevel(catalog.defaultSeverity),
|
|
51
|
+
},
|
|
52
|
+
properties: {
|
|
53
|
+
category: catalog.family,
|
|
54
|
+
namespaceValid: catalog.namespaceValid,
|
|
55
|
+
tags: Array.isArray(catalog.tags) ? catalog.tags : [],
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const toSarifPhysicalLocation = ({ cwd, location = {} }) => {
|
|
62
|
+
const relPath = location.filePath && location.filePath !== "unknown"
|
|
63
|
+
? toPosixPath(path.relative(cwd, location.filePath))
|
|
64
|
+
: "unknown";
|
|
65
|
+
|
|
66
|
+
const region = {};
|
|
67
|
+
if (Number.isInteger(location.line) && location.line > 0) {
|
|
68
|
+
region.startLine = location.line;
|
|
69
|
+
}
|
|
70
|
+
if (Number.isInteger(location.column) && location.column > 0) {
|
|
71
|
+
region.startColumn = location.column;
|
|
72
|
+
}
|
|
73
|
+
if (Number.isInteger(location.endLine) && location.endLine > 0) {
|
|
74
|
+
region.endLine = location.endLine;
|
|
75
|
+
}
|
|
76
|
+
if (Number.isInteger(location.endColumn) && location.endColumn > 0) {
|
|
77
|
+
region.endColumn = location.endColumn;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
artifactLocation: {
|
|
82
|
+
uri: relPath || "unknown",
|
|
83
|
+
},
|
|
84
|
+
...(Object.keys(region).length > 0 ? { region } : {}),
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const toRelatedLocations = ({ cwd, diagnostic = {} }) => {
|
|
89
|
+
const related = Array.isArray(diagnostic.related) ? diagnostic.related : [];
|
|
90
|
+
return related
|
|
91
|
+
.filter((location) => location && typeof location === "object")
|
|
92
|
+
.map((location, index) => ({
|
|
93
|
+
id: index + 1,
|
|
94
|
+
message: {
|
|
95
|
+
text: location.message || "related location",
|
|
96
|
+
},
|
|
97
|
+
physicalLocation: toSarifPhysicalLocation({ cwd, location }),
|
|
98
|
+
}));
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const toCodeFlows = (diagnostic = {}) => {
|
|
102
|
+
const trace = Array.isArray(diagnostic.trace)
|
|
103
|
+
? diagnostic.trace
|
|
104
|
+
.map((entry) => String(entry || "").trim())
|
|
105
|
+
.filter(Boolean)
|
|
106
|
+
: [];
|
|
107
|
+
if (trace.length === 0) {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return [
|
|
112
|
+
{
|
|
113
|
+
threadFlows: [
|
|
114
|
+
{
|
|
115
|
+
locations: trace.map((entry) => ({
|
|
116
|
+
location: {
|
|
117
|
+
message: {
|
|
118
|
+
text: entry,
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
})),
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
},
|
|
125
|
+
];
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const toPartialFingerprints = (diagnostic = {}) => {
|
|
129
|
+
const source = [
|
|
130
|
+
String(diagnostic.code || "RTGL-CHECK-UNKNOWN"),
|
|
131
|
+
mapLevel(diagnostic.severity),
|
|
132
|
+
String(diagnostic.filePath || "unknown"),
|
|
133
|
+
Number.isInteger(diagnostic.line) ? diagnostic.line : 0,
|
|
134
|
+
Number.isInteger(diagnostic.column) ? diagnostic.column : 0,
|
|
135
|
+
String(diagnostic.message || ""),
|
|
136
|
+
].join("|");
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
primaryLocationLineHash: createHash("sha1").update(source).digest("hex"),
|
|
140
|
+
};
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const toSarifResult = ({ cwd, diagnostic = {} }) => {
|
|
144
|
+
const code = diagnostic.code || "RTGL-CHECK-UNKNOWN";
|
|
145
|
+
const relatedLocations = toRelatedLocations({ cwd, diagnostic });
|
|
146
|
+
const codeFlows = toCodeFlows(diagnostic);
|
|
147
|
+
const catalog = getDiagnosticCatalogEntry(code);
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
ruleId: code,
|
|
151
|
+
level: mapLevel(diagnostic.severity),
|
|
152
|
+
message: {
|
|
153
|
+
text: diagnostic.message || code,
|
|
154
|
+
},
|
|
155
|
+
locations: [
|
|
156
|
+
{
|
|
157
|
+
physicalLocation: toSarifPhysicalLocation({
|
|
158
|
+
cwd,
|
|
159
|
+
location: diagnostic,
|
|
160
|
+
}),
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
partialFingerprints: toPartialFingerprints(diagnostic),
|
|
164
|
+
...(relatedLocations.length > 0 ? { relatedLocations } : {}),
|
|
165
|
+
...(codeFlows ? { codeFlows } : {}),
|
|
166
|
+
properties: {
|
|
167
|
+
category: catalog.family,
|
|
168
|
+
docsPath: catalog.docsPath,
|
|
169
|
+
...(diagnostic.fix ? {
|
|
170
|
+
autofix: {
|
|
171
|
+
kind: diagnostic.fix.kind,
|
|
172
|
+
safe: diagnostic.fix.safe !== false,
|
|
173
|
+
confidence: diagnostic.fix.confidence,
|
|
174
|
+
description: diagnostic.fix.description,
|
|
175
|
+
},
|
|
176
|
+
} : {}),
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
export const formatSarifReport = ({ result }) => {
|
|
182
|
+
const diagnostics = normalizeDiagnostics(result.diagnostics);
|
|
183
|
+
const rules = toRuleMap(diagnostics);
|
|
184
|
+
const sarif = {
|
|
185
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
186
|
+
version: "2.1.0",
|
|
187
|
+
runs: [
|
|
188
|
+
{
|
|
189
|
+
tool: {
|
|
190
|
+
driver: {
|
|
191
|
+
name: "@rettangoli/check",
|
|
192
|
+
informationUri: "https://github.com/yuusoft-org/rettangoli",
|
|
193
|
+
rules,
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
automationDetails: {
|
|
197
|
+
id: "rtgl-check/default",
|
|
198
|
+
},
|
|
199
|
+
results: diagnostics.map((diagnostic) => toSarifResult({
|
|
200
|
+
cwd: result.cwd,
|
|
201
|
+
diagnostic,
|
|
202
|
+
})),
|
|
203
|
+
properties: {
|
|
204
|
+
componentCount: result.componentCount,
|
|
205
|
+
registryTagCount: result.registryTagCount,
|
|
206
|
+
diagnosticCatalogVersion: DIAGNOSTIC_CATALOG_VERSION,
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
],
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
return JSON.stringify(sarif, null, 2);
|
|
213
|
+
};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
const formatLocation = ({ cwd, filePath, line, column }) => {
|
|
5
|
+
const relPath = filePath === "unknown" ? "unknown" : path.relative(cwd, filePath);
|
|
6
|
+
if (!line) {
|
|
7
|
+
return relPath;
|
|
8
|
+
}
|
|
9
|
+
if (!column) {
|
|
10
|
+
return `${relPath}:${line}`;
|
|
11
|
+
}
|
|
12
|
+
return `${relPath}:${line}:${column}`;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const readFileLines = (filePath, cache = new Map()) => {
|
|
16
|
+
if (!filePath || filePath === "unknown") {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
if (cache.has(filePath)) {
|
|
20
|
+
return cache.get(filePath);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!existsSync(filePath)) {
|
|
24
|
+
cache.set(filePath, null);
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const lines = readFileSync(filePath, "utf8").split("\n");
|
|
29
|
+
cache.set(filePath, lines);
|
|
30
|
+
return lines;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const formatCodeFrame = ({ diag, fileLines }) => {
|
|
34
|
+
if (!Number.isInteger(diag.line) || !Array.isArray(fileLines)) {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
const lineIndex = diag.line - 1;
|
|
38
|
+
if (lineIndex < 0 || lineIndex >= fileLines.length) {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const start = Math.max(0, lineIndex - 1);
|
|
43
|
+
const end = Math.min(fileLines.length - 1, lineIndex + 1);
|
|
44
|
+
const frameLines = [" codeframe:"];
|
|
45
|
+
for (let index = start; index <= end; index += 1) {
|
|
46
|
+
const marker = index === lineIndex ? ">" : " ";
|
|
47
|
+
frameLines.push(` ${marker} ${String(index + 1).padStart(4, " ")} | ${fileLines[index]}`);
|
|
48
|
+
if (index === lineIndex && Number.isInteger(diag.column) && diag.column > 0) {
|
|
49
|
+
frameLines.push(` | ${" ".repeat(Math.max(0, diag.column - 1))}^`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return frameLines;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const formatTextReport = ({ result, warnAsError = false }) => {
|
|
57
|
+
const lines = [];
|
|
58
|
+
const { summary } = result;
|
|
59
|
+
const fileCache = new Map();
|
|
60
|
+
|
|
61
|
+
lines.push(`[Check] Scanned ${result.componentCount} component(s); registry tags: ${result.registryTagCount}.`);
|
|
62
|
+
lines.push(`[Check] Errors: ${summary.bySeverity.error}, Warnings: ${summary.bySeverity.warn}.`);
|
|
63
|
+
|
|
64
|
+
if (summary.byCode.length > 0) {
|
|
65
|
+
lines.push("[Check] By code:");
|
|
66
|
+
summary.byCode.forEach(({ code, count }) => {
|
|
67
|
+
lines.push(`- ${code}: ${count}`);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (result.diagnostics.length > 0) {
|
|
72
|
+
lines.push("[Check] Diagnostics:");
|
|
73
|
+
result.diagnostics.forEach((diag) => {
|
|
74
|
+
const severity = warnAsError && diag.severity === "warn" ? "error" : diag.severity;
|
|
75
|
+
lines.push(`${diag.code} [${severity}] ${diag.message} [${formatLocation({
|
|
76
|
+
cwd: result.cwd,
|
|
77
|
+
filePath: diag.filePath,
|
|
78
|
+
line: diag.line,
|
|
79
|
+
column: diag.column,
|
|
80
|
+
})}]`);
|
|
81
|
+
if (Array.isArray(diag.trace) && diag.trace.length > 0) {
|
|
82
|
+
diag.trace.forEach((traceEntry) => {
|
|
83
|
+
lines.push(` trace: ${traceEntry}`);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
if (Array.isArray(diag.related) && diag.related.length > 0) {
|
|
87
|
+
diag.related.forEach((relatedLocation) => {
|
|
88
|
+
lines.push(` related: ${relatedLocation.message || "context"} [${formatLocation({
|
|
89
|
+
cwd: result.cwd,
|
|
90
|
+
filePath: relatedLocation.filePath || "unknown",
|
|
91
|
+
line: relatedLocation.line,
|
|
92
|
+
column: relatedLocation.column,
|
|
93
|
+
})}]`);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
if (diag.fix && diag.fix.safe !== false) {
|
|
97
|
+
const confidence = Number.isFinite(diag.fix.confidence) ? ` (${Math.round(diag.fix.confidence * 100)}%)` : "";
|
|
98
|
+
lines.push(` fix: ${diag.fix.description || "autofix available"}${confidence}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const frame = formatCodeFrame({
|
|
102
|
+
diag,
|
|
103
|
+
fileLines: readFileLines(diag.filePath, fileCache),
|
|
104
|
+
});
|
|
105
|
+
if (frame.length > 0) {
|
|
106
|
+
lines.push(...frame);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (result.diagnostics.length === 0) {
|
|
112
|
+
lines.push("[Check] No issues found.");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (result.autofix) {
|
|
116
|
+
lines.push(
|
|
117
|
+
`[Autofix] mode=${result.autofix.mode || "off"}, candidates=${result.autofix.candidateCount}, applied=${result.autofix.appliedCount}, skipped=${result.autofix.skippedCount}, dryRun=${result.autofix.dryRun ? "yes" : "no"}, patchOutput=${result.autofix.patchOutput ? "yes" : "no"}.`,
|
|
118
|
+
);
|
|
119
|
+
if (result.autofix.patchOutput && Array.isArray(result.autofix.patches) && result.autofix.patches.length > 0) {
|
|
120
|
+
lines.push("[Autofix] Patches:");
|
|
121
|
+
result.autofix.patches.forEach((patch) => {
|
|
122
|
+
lines.push(`- ${patch.code}: ${patch.description} [${formatLocation({
|
|
123
|
+
cwd: result.cwd,
|
|
124
|
+
filePath: patch.filePath,
|
|
125
|
+
line: patch.line,
|
|
126
|
+
})}]`);
|
|
127
|
+
if (typeof patch.patch === "string" && patch.patch) {
|
|
128
|
+
patch.patch.split("\n").forEach((patchLine) => {
|
|
129
|
+
lines.push(` ${patchLine}`);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
} else if (result.autofix.dryRun && Array.isArray(result.autofix.patches) && result.autofix.patches.length > 0) {
|
|
134
|
+
result.autofix.patches.forEach((patch) => {
|
|
135
|
+
lines.push(`- ${patch.code}: ${patch.description} [${formatLocation({
|
|
136
|
+
cwd: result.cwd,
|
|
137
|
+
filePath: patch.filePath,
|
|
138
|
+
line: patch.line,
|
|
139
|
+
})}]`);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return lines.join("\n");
|
|
145
|
+
};
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { toCamelCase, toKebabCase } from "../utils/case.js";
|
|
2
|
+
import { resolveExpressionPathType } from "../core/scopeGraph.js";
|
|
3
|
+
import { parseNamedExportedFunctions } from "../core/exportedFunctions.js";
|
|
4
|
+
import {
|
|
5
|
+
areTypesCompatible,
|
|
6
|
+
inferLiteralLatticeType,
|
|
7
|
+
inferSchemaNodePrimitiveType,
|
|
8
|
+
schemaNodeToLatticeType,
|
|
9
|
+
} from "../types/lattice.js";
|
|
10
|
+
|
|
11
|
+
const normalizeBindingName = (bindingName = "") => {
|
|
12
|
+
if (bindingName.startsWith(":")) return { sourceType: "prop", name: bindingName.slice(1) };
|
|
13
|
+
if (bindingName.startsWith("?")) return { sourceType: "boolean-attr", name: bindingName.slice(1) };
|
|
14
|
+
if (bindingName.startsWith(".")) return { sourceType: "legacy-prop", name: bindingName.slice(1) };
|
|
15
|
+
if (bindingName.startsWith("@")) return { sourceType: "event", name: bindingName.slice(1) };
|
|
16
|
+
return { sourceType: "attr", name: bindingName };
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const getContractPropSchema = ({ contract, propName }) => {
|
|
20
|
+
if (!(contract?.propTypes instanceof Map) || !propName) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
const candidates = [propName, toCamelCase(propName), toKebabCase(propName)];
|
|
24
|
+
for (let index = 0; index < candidates.length; index += 1) {
|
|
25
|
+
const candidate = candidates[index];
|
|
26
|
+
if (contract.propTypes.has(candidate)) {
|
|
27
|
+
return contract.propTypes.get(candidate);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const hasPropDefaultValue = ({ contract, propName }) => {
|
|
34
|
+
const schema = getContractPropSchema({ contract, propName });
|
|
35
|
+
return Boolean(
|
|
36
|
+
schema
|
|
37
|
+
&& typeof schema === "object"
|
|
38
|
+
&& !Array.isArray(schema)
|
|
39
|
+
&& Object.prototype.hasOwnProperty.call(schema, "default")
|
|
40
|
+
);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const getContractEventSchema = ({ contract, eventName }) => {
|
|
44
|
+
if (!(contract?.eventTypes instanceof Map) || !eventName) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
return contract.eventTypes.get(eventName) || null;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const toSchemaRequiredKeys = (schemaNode) => {
|
|
51
|
+
if (!schemaNode || typeof schemaNode !== "object" || Array.isArray(schemaNode)) {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
if (!Array.isArray(schemaNode.required)) {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
return [...new Set(
|
|
58
|
+
schemaNode.required
|
|
59
|
+
.filter((key) => typeof key === "string" && key.trim() === key && key.length > 0),
|
|
60
|
+
)].sort((left, right) => left.localeCompare(right));
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const isValidSymbolName = (value = "") => /^[A-Za-z_$][A-Za-z0-9_$]*$/u.test(value);
|
|
64
|
+
|
|
65
|
+
const isValidHandlerName = (value = "") => (
|
|
66
|
+
isValidSymbolName(value)
|
|
67
|
+
&& value.startsWith("handle")
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const latticeTypeToText = (value) => {
|
|
71
|
+
if (typeof value === "string") {
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
if (!value || typeof value !== "object") {
|
|
75
|
+
return "unknown";
|
|
76
|
+
}
|
|
77
|
+
if (value.kind === "union" && Array.isArray(value.options)) {
|
|
78
|
+
const labels = value.options.map((option) => latticeTypeToText(option));
|
|
79
|
+
return [...new Set(labels)].join(" | ");
|
|
80
|
+
}
|
|
81
|
+
return String(value.kind || "unknown");
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const inferAttributeExpressionType = ({ model, attribute }) => {
|
|
85
|
+
if (!attribute || typeof attribute !== "object") {
|
|
86
|
+
return "unknown";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const firstExpression = Array.isArray(attribute.expressions) ? attribute.expressions[0] : null;
|
|
90
|
+
if (typeof firstExpression === "string" && firstExpression.trim()) {
|
|
91
|
+
const pathType = resolveExpressionPathType({
|
|
92
|
+
model,
|
|
93
|
+
expression: firstExpression,
|
|
94
|
+
localSchemaTypes: new Map(),
|
|
95
|
+
});
|
|
96
|
+
if (pathType?.resolved) {
|
|
97
|
+
return inferSchemaNodePrimitiveType(pathType.resolved);
|
|
98
|
+
}
|
|
99
|
+
return "unknown";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return inferLiteralLatticeType(attribute.valueText).kind;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export const runCompatibilityRules = ({ models = [], registry = new Map() }) => {
|
|
106
|
+
const diagnostics = [];
|
|
107
|
+
|
|
108
|
+
models.forEach((model) => {
|
|
109
|
+
const exportedHandlerFunctions = parseNamedExportedFunctions({
|
|
110
|
+
sourceText: model?.handlers?.sourceText || "",
|
|
111
|
+
filePath: model?.handlers?.filePath || model?.files?.handlers || "unknown.handlers.js",
|
|
112
|
+
});
|
|
113
|
+
const templateNodes = Array.isArray(model?.view?.templateAst?.nodes)
|
|
114
|
+
? model.view.templateAst.nodes
|
|
115
|
+
: [];
|
|
116
|
+
const nodeByKey = new Map();
|
|
117
|
+
templateNodes.forEach((node) => {
|
|
118
|
+
nodeByKey.set(`${node?.range?.line || 0}::${node?.rawKey || ""}`, node);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
model?.view?.selectorBindings?.forEach((bindingLine) => {
|
|
122
|
+
const tagName = bindingLine?.tagName;
|
|
123
|
+
if (!tagName || !tagName.includes("-")) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const contract = registry.get(tagName);
|
|
128
|
+
if (!contract) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const astNode = nodeByKey.get(`${bindingLine.line || 0}::${bindingLine.rawKey || ""}`);
|
|
133
|
+
const astAttributes = Array.isArray(astNode?.attributes) ? astNode.attributes : [];
|
|
134
|
+
const provided = new Set();
|
|
135
|
+
|
|
136
|
+
(bindingLine.bindingNames || []).forEach((bindingName) => {
|
|
137
|
+
const normalized = normalizeBindingName(String(bindingName || "").trim());
|
|
138
|
+
if (!normalized.name || normalized.sourceType === "event") {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
provided.add(normalized.name);
|
|
142
|
+
provided.add(toCamelCase(normalized.name));
|
|
143
|
+
provided.add(toKebabCase(normalized.name));
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const missingRequired = [...(contract.requiredProps || [])].filter((requiredProp) => (
|
|
147
|
+
!provided.has(requiredProp)
|
|
148
|
+
&& !provided.has(toCamelCase(requiredProp))
|
|
149
|
+
&& !provided.has(toKebabCase(requiredProp))
|
|
150
|
+
&& !hasPropDefaultValue({ contract, propName: requiredProp })
|
|
151
|
+
));
|
|
152
|
+
if (missingRequired.length > 0) {
|
|
153
|
+
diagnostics.push({
|
|
154
|
+
code: "RTGL-CHECK-COMPAT-001",
|
|
155
|
+
severity: "error",
|
|
156
|
+
filePath: bindingLine.filePath || model?.view?.filePath || "unknown",
|
|
157
|
+
line: bindingLine.line,
|
|
158
|
+
message: `${model.componentKey}: missing required prop(s) [${missingRequired.join(", ")}] for '${tagName}'.`,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (contract.events instanceof Set && contract.events.size > 0) {
|
|
163
|
+
astAttributes.forEach((attribute) => {
|
|
164
|
+
if (attribute?.sourceType !== "event" || !attribute?.name) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (contract.events.has(attribute.name)) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
diagnostics.push({
|
|
171
|
+
code: "RTGL-CHECK-COMPAT-002",
|
|
172
|
+
severity: "error",
|
|
173
|
+
filePath: bindingLine.filePath || model?.view?.filePath || "unknown",
|
|
174
|
+
line: attribute?.range?.line || bindingLine.line,
|
|
175
|
+
column: attribute?.range?.column,
|
|
176
|
+
endLine: attribute?.range?.endLine,
|
|
177
|
+
endColumn: attribute?.range?.endColumn,
|
|
178
|
+
message: `${model.componentKey}: unsupported event '${attribute.name}' on '${tagName}'.`,
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
astAttributes.forEach((attribute) => {
|
|
184
|
+
if (attribute?.sourceType === "event" && attribute?.name) {
|
|
185
|
+
const rawHandlerSymbol = String(attribute.valueText || "").trim();
|
|
186
|
+
if (!rawHandlerSymbol) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (!isValidHandlerName(rawHandlerSymbol)) {
|
|
191
|
+
diagnostics.push({
|
|
192
|
+
code: "RTGL-CHECK-HANDLER-003",
|
|
193
|
+
severity: "error",
|
|
194
|
+
filePath: bindingLine.filePath || model?.view?.filePath || "unknown",
|
|
195
|
+
line: attribute?.range?.line || bindingLine.line,
|
|
196
|
+
column: attribute?.range?.column,
|
|
197
|
+
endLine: attribute?.range?.endLine,
|
|
198
|
+
endColumn: attribute?.range?.endColumn,
|
|
199
|
+
message: `${model.componentKey}: invalid handler '${rawHandlerSymbol}' for event '${attribute.name}' on '${tagName}'. Handler names must start with 'handle'.`,
|
|
200
|
+
});
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (!model?.handlers?.exports?.has(rawHandlerSymbol)) {
|
|
205
|
+
diagnostics.push({
|
|
206
|
+
code: "RTGL-CHECK-COMPAT-005",
|
|
207
|
+
severity: "error",
|
|
208
|
+
filePath: bindingLine.filePath || model?.view?.filePath || "unknown",
|
|
209
|
+
line: attribute?.range?.line || bindingLine.line,
|
|
210
|
+
column: attribute?.range?.column,
|
|
211
|
+
endLine: attribute?.range?.endLine,
|
|
212
|
+
endColumn: attribute?.range?.endColumn,
|
|
213
|
+
message: `${model.componentKey}: handler '${rawHandlerSymbol}' for event '${attribute.name}' on '${tagName}' is missing in .handlers.js exports.`,
|
|
214
|
+
});
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const eventSchema = getContractEventSchema({
|
|
219
|
+
contract,
|
|
220
|
+
eventName: attribute.name,
|
|
221
|
+
});
|
|
222
|
+
const requiredPayloadKeys = toSchemaRequiredKeys(eventSchema);
|
|
223
|
+
if (requiredPayloadKeys.length === 0) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const handlerMeta = exportedHandlerFunctions.get(rawHandlerSymbol);
|
|
228
|
+
if (!handlerMeta) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (handlerMeta.paramCount < 2) {
|
|
232
|
+
diagnostics.push({
|
|
233
|
+
code: "RTGL-CHECK-COMPAT-006",
|
|
234
|
+
severity: "error",
|
|
235
|
+
filePath: bindingLine.filePath || model?.view?.filePath || "unknown",
|
|
236
|
+
line: attribute?.range?.line || bindingLine.line,
|
|
237
|
+
column: attribute?.range?.column,
|
|
238
|
+
endLine: attribute?.range?.endLine,
|
|
239
|
+
endColumn: attribute?.range?.endColumn,
|
|
240
|
+
message: `${model.componentKey}: handler '${rawHandlerSymbol}' for event '${attribute.name}' on '${tagName}' must accept a payload parameter for required payload keys [${requiredPayloadKeys.join(", ")}].`,
|
|
241
|
+
});
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (handlerMeta.secondParam?.kind !== "object") {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (handlerMeta.secondParam?.hasRest) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const payloadKeys = new Set(handlerMeta.secondParam.objectKeys || []);
|
|
253
|
+
const missingPayloadKeys = requiredPayloadKeys.filter((key) => !payloadKeys.has(key));
|
|
254
|
+
if (missingPayloadKeys.length === 0) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
diagnostics.push({
|
|
259
|
+
code: "RTGL-CHECK-COMPAT-006",
|
|
260
|
+
severity: "error",
|
|
261
|
+
filePath: bindingLine.filePath || model?.view?.filePath || "unknown",
|
|
262
|
+
line: attribute?.range?.line || bindingLine.line,
|
|
263
|
+
column: attribute?.range?.column,
|
|
264
|
+
endLine: attribute?.range?.endLine,
|
|
265
|
+
endColumn: attribute?.range?.endColumn,
|
|
266
|
+
message: `${model.componentKey}: handler '${rawHandlerSymbol}' for event '${attribute.name}' on '${tagName}' does not cover required payload key(s) [${missingPayloadKeys.join(", ")}].`,
|
|
267
|
+
});
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (attribute?.sourceType === "prop" && attribute?.name) {
|
|
272
|
+
const schema = getContractPropSchema({
|
|
273
|
+
contract,
|
|
274
|
+
propName: attribute.name,
|
|
275
|
+
});
|
|
276
|
+
const expectedType = schemaNodeToLatticeType(schema);
|
|
277
|
+
const actualType = inferAttributeExpressionType({ model, attribute });
|
|
278
|
+
if (!areTypesCompatible({ expected: expectedType, actual: actualType })) {
|
|
279
|
+
diagnostics.push({
|
|
280
|
+
code: "RTGL-CHECK-COMPAT-004",
|
|
281
|
+
severity: "error",
|
|
282
|
+
filePath: bindingLine.filePath || model?.view?.filePath || "unknown",
|
|
283
|
+
line: attribute?.range?.line || bindingLine.line,
|
|
284
|
+
column: attribute?.range?.column,
|
|
285
|
+
endLine: attribute?.range?.endLine,
|
|
286
|
+
endColumn: attribute?.range?.endColumn,
|
|
287
|
+
message: `${model.componentKey}: prop binding ':${attribute.name}' is incompatible with '${tagName}' expected type '${latticeTypeToText(expectedType)}' but resolved '${actualType}'.`,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (attribute?.sourceType !== "boolean-attr" || !attribute?.name) {
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const schema = getContractPropSchema({
|
|
296
|
+
contract,
|
|
297
|
+
propName: attribute.name,
|
|
298
|
+
});
|
|
299
|
+
const schemaType = schemaNodeToLatticeType(schema);
|
|
300
|
+
if (areTypesCompatible({ expected: schemaType, actual: "boolean" })) {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
diagnostics.push({
|
|
304
|
+
code: "RTGL-CHECK-COMPAT-003",
|
|
305
|
+
severity: "error",
|
|
306
|
+
filePath: bindingLine.filePath || model?.view?.filePath || "unknown",
|
|
307
|
+
line: attribute?.range?.line || bindingLine.line,
|
|
308
|
+
column: attribute?.range?.column,
|
|
309
|
+
endLine: attribute?.range?.endLine,
|
|
310
|
+
endColumn: attribute?.range?.endColumn,
|
|
311
|
+
message: `${model.componentKey}: boolean binding '?${attribute.name}' is incompatible with '${tagName}' prop type '${latticeTypeToText(schemaType)}'.`,
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
return diagnostics;
|
|
318
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { isObjectRecord } from "./shared.js";
|
|
2
|
+
|
|
3
|
+
export const runConstantsRules = ({ models = [] }) => {
|
|
4
|
+
const diagnostics = [];
|
|
5
|
+
|
|
6
|
+
models.forEach((model) => {
|
|
7
|
+
if (!model.constants.filePath) {
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
if (!isObjectRecord(model.constants.yaml)) {
|
|
12
|
+
diagnostics.push({
|
|
13
|
+
code: "RTGL-CHECK-CONSTANTS-001",
|
|
14
|
+
severity: "error",
|
|
15
|
+
filePath: model.constants.filePath,
|
|
16
|
+
message: `${model.componentKey}: .constants.yaml must contain a YAML object at the root.`,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
return diagnostics;
|
|
22
|
+
};
|