@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.
Files changed (42) hide show
  1. package/README.md +295 -0
  2. package/ROADMAP.md +175 -0
  3. package/package.json +46 -0
  4. package/src/cli/bin.js +325 -0
  5. package/src/cli/check.js +232 -0
  6. package/src/cli/index.js +1 -0
  7. package/src/core/analyze.js +227 -0
  8. package/src/core/discovery.js +83 -0
  9. package/src/core/exportedFunctions.js +235 -0
  10. package/src/core/model.js +898 -0
  11. package/src/core/parsers.js +2726 -0
  12. package/src/core/registry.js +779 -0
  13. package/src/core/schema.js +161 -0
  14. package/src/core/scopeGraph.js +1400 -0
  15. package/src/core/semantic.js +329 -0
  16. package/src/diagnostics/autofix.js +191 -0
  17. package/src/diagnostics/catalog.js +89 -0
  18. package/src/index.js +2 -0
  19. package/src/reporters/index.js +13 -0
  20. package/src/reporters/json.js +42 -0
  21. package/src/reporters/sarif.js +213 -0
  22. package/src/reporters/text.js +145 -0
  23. package/src/rules/compatibility.js +318 -0
  24. package/src/rules/constants.js +22 -0
  25. package/src/rules/crossFileSymbols.js +108 -0
  26. package/src/rules/expression.js +338 -0
  27. package/src/rules/feParity.js +65 -0
  28. package/src/rules/index.js +39 -0
  29. package/src/rules/jempl.js +80 -0
  30. package/src/rules/lifecycle.js +4 -0
  31. package/src/rules/listenerConfig.js +556 -0
  32. package/src/rules/listenerSymbols.js +49 -0
  33. package/src/rules/methods.js +117 -0
  34. package/src/rules/refs.js +20 -0
  35. package/src/rules/schema.js +118 -0
  36. package/src/rules/shared.js +20 -0
  37. package/src/rules/yahtmlAttrs.js +238 -0
  38. package/src/semantic/engine.js +778 -0
  39. package/src/semantic/index.js +9 -0
  40. package/src/types/lattice.js +281 -0
  41. package/src/utils/case.js +9 -0
  42. package/src/utils/fs.js +30 -0
@@ -0,0 +1,227 @@
1
+ import { statSync } from "node:fs";
2
+ import { buildComponentModel, buildProjectModel } from "./model.js";
3
+ import { discoverComponentEntries, groupEntriesByComponent } from "./discovery.js";
4
+ import { buildMergedRegistry } from "./registry.js";
5
+ import { runRules } from "../rules/index.js";
6
+ import { runSemanticEngine } from "../semantic/engine.js";
7
+ import { getDiagnosticCatalogEntry } from "../diagnostics/catalog.js";
8
+
9
+ const summarizeDiagnostics = (diagnostics = []) => {
10
+ const bySeverity = { error: 0, warn: 0 };
11
+ const byCodeMap = new Map();
12
+
13
+ diagnostics.forEach((diag) => {
14
+ const severity = diag.severity === "warn" ? "warn" : "error";
15
+ bySeverity[severity] += 1;
16
+ byCodeMap.set(diag.code, (byCodeMap.get(diag.code) || 0) + 1);
17
+ });
18
+
19
+ const byCode = [...byCodeMap.entries()]
20
+ .map(([code, count]) => ({ code, count }))
21
+ .sort((a, b) => a.code.localeCompare(b.code));
22
+
23
+ return {
24
+ total: diagnostics.length,
25
+ bySeverity,
26
+ byCode,
27
+ };
28
+ };
29
+
30
+ const categorizeDiagnosticCode = (code = "") => {
31
+ if (code.includes("YAHTML")) return "template";
32
+ if (code.includes("JEMPL")) return "template";
33
+ if (code.includes("SYMBOL")) return "symbols";
34
+ if (code.includes("SCHEMA")) return "schema";
35
+ if (code.includes("LIFECYCLE")) return "lifecycle";
36
+ if (code.includes("EXPR")) return "expression";
37
+ if (code.includes("COMPAT")) return "compatibility";
38
+ if (code.includes("CONTRACT")) return "contracts";
39
+ return "general";
40
+ };
41
+
42
+ const normalizeRelatedLocations = (related = []) => {
43
+ if (!Array.isArray(related) || related.length === 0) {
44
+ return undefined;
45
+ }
46
+
47
+ const normalized = related
48
+ .filter((entry) => entry && typeof entry === "object")
49
+ .map((entry) => ({
50
+ message: typeof entry.message === "string" && entry.message.trim()
51
+ ? entry.message.trim()
52
+ : undefined,
53
+ filePath: typeof entry.filePath === "string" && entry.filePath
54
+ ? entry.filePath
55
+ : "unknown",
56
+ line: Number.isInteger(entry.line) ? entry.line : undefined,
57
+ column: Number.isInteger(entry.column) ? entry.column : undefined,
58
+ endLine: Number.isInteger(entry.endLine) ? entry.endLine : undefined,
59
+ endColumn: Number.isInteger(entry.endColumn) ? entry.endColumn : undefined,
60
+ }))
61
+ .sort((left, right) => (
62
+ left.filePath.localeCompare(right.filePath)
63
+ || (left.line || 0) - (right.line || 0)
64
+ || (left.column || 0) - (right.column || 0)
65
+ || (left.message || "").localeCompare(right.message || "")
66
+ ));
67
+
68
+ return normalized.length > 0 ? normalized : undefined;
69
+ };
70
+
71
+ const normalizeFix = (fix = {}, diagnostic = {}) => {
72
+ if (!fix || typeof fix !== "object") {
73
+ return undefined;
74
+ }
75
+
76
+ const normalized = {
77
+ kind: typeof fix.kind === "string" ? fix.kind : undefined,
78
+ description: typeof fix.description === "string" && fix.description.trim()
79
+ ? fix.description.trim()
80
+ : undefined,
81
+ safe: fix.safe !== false,
82
+ confidence: Number.isFinite(fix.confidence) ? Number(fix.confidence) : undefined,
83
+ filePath: typeof fix.filePath === "string" && fix.filePath
84
+ ? fix.filePath
85
+ : (diagnostic.filePath || "unknown"),
86
+ line: Number.isInteger(fix.line) ? fix.line : undefined,
87
+ column: Number.isInteger(fix.column) ? fix.column : undefined,
88
+ endLine: Number.isInteger(fix.endLine) ? fix.endLine : undefined,
89
+ endColumn: Number.isInteger(fix.endColumn) ? fix.endColumn : undefined,
90
+ pattern: typeof fix.pattern === "string" && fix.pattern ? fix.pattern : undefined,
91
+ replacement: typeof fix.replacement === "string" ? fix.replacement : undefined,
92
+ flags: typeof fix.flags === "string" && fix.flags ? fix.flags : undefined,
93
+ };
94
+
95
+ if (!normalized.kind) {
96
+ return undefined;
97
+ }
98
+
99
+ return normalized;
100
+ };
101
+
102
+ const normalizeTrace = (trace = []) => {
103
+ if (!Array.isArray(trace) || trace.length === 0) {
104
+ return undefined;
105
+ }
106
+
107
+ const normalized = trace
108
+ .map((entry) => (entry === null || entry === undefined ? "" : String(entry).trim()))
109
+ .filter(Boolean)
110
+ .sort((left, right) => left.localeCompare(right));
111
+ return normalized.length > 0 ? normalized : undefined;
112
+ };
113
+
114
+ const normalizeDiagnostics = (diagnostics = []) => {
115
+ return diagnostics
116
+ .filter(Boolean)
117
+ .map((diag) => {
118
+ const code = diag.code || "RTGL-CHECK-UNKNOWN";
119
+ const catalog = getDiagnosticCatalogEntry(code);
120
+ const related = normalizeRelatedLocations(diag.related);
121
+ const trace = normalizeTrace(diag.trace || related?.map((entry) => entry.message).filter(Boolean));
122
+ return {
123
+ code,
124
+ category: categorizeDiagnosticCode(code),
125
+ severity: diag.severity === "warn" ? "warn" : "error",
126
+ message: String(diag.message || "Unknown diagnostic"),
127
+ filePath: diag.filePath || "unknown",
128
+ line: Number.isInteger(diag.line) ? diag.line : undefined,
129
+ column: Number.isInteger(diag.column) ? diag.column : undefined,
130
+ endLine: Number.isInteger(diag.endLine) ? diag.endLine : undefined,
131
+ endColumn: Number.isInteger(diag.endColumn) ? diag.endColumn : undefined,
132
+ title: catalog.title,
133
+ family: catalog.family,
134
+ docsPath: catalog.docsPath,
135
+ namespaceValid: catalog.namespaceValid,
136
+ tags: catalog.tags,
137
+ related,
138
+ trace,
139
+ fix: normalizeFix(diag.fix, diag),
140
+ };
141
+ });
142
+ };
143
+
144
+ export const analyzeProject = async ({
145
+ cwd = process.cwd(),
146
+ dirs = [],
147
+ workspaceRoot = cwd,
148
+ includeYahtml = true,
149
+ includeExpression = false,
150
+ includeSemantic = false,
151
+ incrementalState,
152
+ } = {}) => {
153
+ const discovery = discoverComponentEntries({ cwd, dirs });
154
+ const componentGroups = groupEntriesByComponent(discovery.entries);
155
+ const models = (() => {
156
+ if (!incrementalState || !(incrementalState.componentCache instanceof Map)) {
157
+ return buildProjectModel(componentGroups);
158
+ }
159
+
160
+ const buildFingerprint = (files = {}) => {
161
+ return Object.entries(files)
162
+ .sort(([left], [right]) => left.localeCompare(right))
163
+ .map(([fileType, filePath]) => {
164
+ try {
165
+ const stat = statSync(filePath);
166
+ return `${fileType}:${filePath}:${stat.mtimeMs}:${stat.size}`;
167
+ } catch {
168
+ return `${fileType}:${filePath}:missing`;
169
+ }
170
+ })
171
+ .join("|");
172
+ };
173
+
174
+ const nextCache = new Map();
175
+ const nextModels = componentGroups.map((componentGroup) => {
176
+ const fingerprint = buildFingerprint(componentGroup.files);
177
+ const cached = incrementalState.componentCache.get(componentGroup.componentKey);
178
+ if (cached && cached.fingerprint === fingerprint) {
179
+ nextCache.set(componentGroup.componentKey, cached);
180
+ return cached.model;
181
+ }
182
+
183
+ const model = buildComponentModel(componentGroup);
184
+ nextCache.set(componentGroup.componentKey, {
185
+ fingerprint,
186
+ model,
187
+ });
188
+ return model;
189
+ });
190
+
191
+ incrementalState.componentCache = nextCache;
192
+ return nextModels;
193
+ })();
194
+
195
+ const modelDiagnostics = normalizeDiagnostics(models.flatMap((model) => model.diagnostics || []));
196
+ const registry = await buildMergedRegistry({ models, workspaceRoot });
197
+ const ruleDiagnostics = normalizeDiagnostics(runRules({
198
+ models,
199
+ registry,
200
+ includeYahtml,
201
+ includeExpression,
202
+ }));
203
+ const semanticResult = includeSemantic
204
+ ? runSemanticEngine({ models, registry })
205
+ : null;
206
+ const semanticDiagnostics = includeSemantic
207
+ ? normalizeDiagnostics(semanticResult?.diagnostics || [])
208
+ : [];
209
+ const diagnostics = [...modelDiagnostics, ...ruleDiagnostics, ...semanticDiagnostics];
210
+ const summary = summarizeDiagnostics(diagnostics);
211
+
212
+ return {
213
+ ok: summary.bySeverity.error === 0,
214
+ cwd,
215
+ dirs: discovery.resolvedDirs,
216
+ componentCount: models.length,
217
+ diagnostics,
218
+ summary,
219
+ registryTagCount: registry.size,
220
+ semantic: semanticResult
221
+ ? {
222
+ diagnosticsCount: semanticDiagnostics.length,
223
+ invariants: semanticResult.invariants,
224
+ }
225
+ : undefined,
226
+ };
227
+ };
@@ -0,0 +1,83 @@
1
+ import path from "node:path";
2
+ import { walkFiles, isDirectoryPath } from "../utils/fs.js";
3
+
4
+ const FILE_SUFFIX_MAP = [
5
+ [".view.yaml", "view"],
6
+ [".schema.yaml", "schema"],
7
+ [".store.js", "store"],
8
+ [".handlers.js", "handlers"],
9
+ [".methods.js", "methods"],
10
+ [".constants.yaml", "constants"],
11
+ ];
12
+
13
+ export const SUPPORTED_FILE_SUFFIXES = FILE_SUFFIX_MAP.map(([suffix]) => suffix);
14
+
15
+ const detectFileType = (filePath) => {
16
+ for (const [suffix, fileType] of FILE_SUFFIX_MAP) {
17
+ if (filePath.endsWith(suffix)) {
18
+ return { suffix, fileType };
19
+ }
20
+ }
21
+ return null;
22
+ };
23
+
24
+ export const resolveDirs = ({ cwd = process.cwd(), dirs = [] }) => {
25
+ return dirs
26
+ .map((dirPath) => path.resolve(cwd, dirPath))
27
+ .filter((dirPath) => isDirectoryPath(dirPath));
28
+ };
29
+
30
+ export const discoverComponentEntries = ({ cwd = process.cwd(), dirs = [] }) => {
31
+ const resolvedDirs = resolveDirs({ cwd, dirs });
32
+ const allFiles = walkFiles(resolvedDirs);
33
+ const supportedEntries = [];
34
+
35
+ allFiles.forEach((filePath) => {
36
+ const fileTypeInfo = detectFileType(filePath);
37
+ if (!fileTypeInfo) {
38
+ return;
39
+ }
40
+
41
+ const normalizedPath = path.resolve(filePath);
42
+ const fileName = path.basename(normalizedPath);
43
+ const componentDir = path.basename(path.dirname(normalizedPath));
44
+ const categoryDir = path.basename(path.dirname(path.dirname(normalizedPath)));
45
+ const componentNameFromFile = fileName.slice(0, -fileTypeInfo.suffix.length);
46
+
47
+ supportedEntries.push({
48
+ filePath: normalizedPath,
49
+ fileName,
50
+ fileType: fileTypeInfo.fileType,
51
+ category: categoryDir,
52
+ component: componentDir,
53
+ componentNameFromFile,
54
+ componentKey: `${categoryDir}/${componentDir}`,
55
+ });
56
+ });
57
+
58
+ return {
59
+ resolvedDirs,
60
+ allFiles,
61
+ entries: supportedEntries,
62
+ };
63
+ };
64
+
65
+ export const groupEntriesByComponent = (entries = []) => {
66
+ const byComponent = new Map();
67
+
68
+ entries.forEach((entry) => {
69
+ const existing = byComponent.get(entry.componentKey) || {
70
+ componentKey: entry.componentKey,
71
+ category: entry.category,
72
+ component: entry.component,
73
+ files: {},
74
+ entries: [],
75
+ };
76
+
77
+ existing.files[entry.fileType] = entry.filePath;
78
+ existing.entries.push(entry);
79
+ byComponent.set(entry.componentKey, existing);
80
+ });
81
+
82
+ return [...byComponent.values()];
83
+ };
@@ -0,0 +1,235 @@
1
+ import { parseSync } from "oxc-parser";
2
+
3
+ const createLineOffsets = (source = "") => {
4
+ const offsets = [0];
5
+ for (let index = 0; index < source.length; index += 1) {
6
+ if (source[index] === "\n") {
7
+ offsets.push(index + 1);
8
+ }
9
+ }
10
+ return offsets;
11
+ };
12
+
13
+ const offsetToLine = ({ lineOffsets = [], offset = 0 }) => {
14
+ let low = 0;
15
+ let high = lineOffsets.length - 1;
16
+ while (low <= high) {
17
+ const mid = Math.floor((low + high) / 2);
18
+ const start = lineOffsets[mid];
19
+ const next = lineOffsets[mid + 1] ?? Number.POSITIVE_INFINITY;
20
+ if (offset >= start && offset < next) {
21
+ return mid + 1;
22
+ }
23
+ if (offset < start) {
24
+ high = mid - 1;
25
+ } else {
26
+ low = mid + 1;
27
+ }
28
+ }
29
+ return undefined;
30
+ };
31
+
32
+ const getPropertyKeyName = (keyNode) => {
33
+ if (!keyNode || typeof keyNode !== "object") {
34
+ return null;
35
+ }
36
+ if (keyNode.type === "Identifier" && typeof keyNode.name === "string" && keyNode.name) {
37
+ return keyNode.name;
38
+ }
39
+ if (keyNode.type === "StringLiteral" && typeof keyNode.value === "string" && keyNode.value) {
40
+ return keyNode.value;
41
+ }
42
+ if (keyNode.type === "Literal" && typeof keyNode.value === "string" && keyNode.value) {
43
+ return keyNode.value;
44
+ }
45
+ return null;
46
+ };
47
+
48
+ const describeObjectPattern = (pattern = {}) => {
49
+ if (!pattern || pattern.type !== "ObjectPattern" || !Array.isArray(pattern.properties)) {
50
+ return null;
51
+ }
52
+
53
+ const keys = new Set();
54
+ let hasRest = false;
55
+
56
+ pattern.properties.forEach((property) => {
57
+ if (!property || typeof property !== "object") {
58
+ return;
59
+ }
60
+
61
+ if (property.type === "RestElement") {
62
+ hasRest = true;
63
+ return;
64
+ }
65
+
66
+ if (property.type !== "Property" || property.computed) {
67
+ return;
68
+ }
69
+
70
+ const keyName = getPropertyKeyName(property.key);
71
+ if (keyName) {
72
+ keys.add(keyName);
73
+ }
74
+ });
75
+
76
+ return {
77
+ kind: "object",
78
+ objectKeys: [...keys].sort((left, right) => left.localeCompare(right)),
79
+ hasRest,
80
+ };
81
+ };
82
+
83
+ const describeParam = (param = {}) => {
84
+ if (!param || typeof param !== "object") {
85
+ return {
86
+ kind: "unknown",
87
+ name: null,
88
+ objectKeys: [],
89
+ hasRest: false,
90
+ };
91
+ }
92
+
93
+ if (param.type === "Identifier") {
94
+ return {
95
+ kind: "identifier",
96
+ name: param.name || null,
97
+ objectKeys: [],
98
+ hasRest: false,
99
+ };
100
+ }
101
+
102
+ if (param.type === "AssignmentPattern") {
103
+ const left = describeParam(param.left);
104
+ return {
105
+ ...left,
106
+ kind: left.kind === "unknown" ? "assignment" : left.kind,
107
+ };
108
+ }
109
+
110
+ const objectPattern = describeObjectPattern(param);
111
+ if (objectPattern) {
112
+ return {
113
+ kind: "object",
114
+ name: null,
115
+ objectKeys: objectPattern.objectKeys,
116
+ hasRest: objectPattern.hasRest,
117
+ };
118
+ }
119
+
120
+ return {
121
+ kind: "pattern",
122
+ name: null,
123
+ objectKeys: [],
124
+ hasRest: false,
125
+ };
126
+ };
127
+
128
+ const toFunctionMeta = ({ name, node, lineOffsets = [] }) => {
129
+ if (!name || !node || typeof node !== "object") {
130
+ return null;
131
+ }
132
+
133
+ const params = Array.isArray(node.params) ? node.params : [];
134
+ const firstParam = params.length > 0
135
+ ? describeParam(params[0])
136
+ : {
137
+ kind: "none",
138
+ name: null,
139
+ objectKeys: [],
140
+ hasRest: false,
141
+ };
142
+ const secondParam = params.length > 1
143
+ ? describeParam(params[1])
144
+ : {
145
+ kind: "none",
146
+ name: null,
147
+ objectKeys: [],
148
+ hasRest: false,
149
+ };
150
+
151
+ return {
152
+ name,
153
+ async: Boolean(node.async),
154
+ paramCount: params.length,
155
+ firstParamName: firstParam.name,
156
+ secondParamName: secondParam.name,
157
+ firstParam,
158
+ secondParam,
159
+ line: offsetToLine({
160
+ lineOffsets,
161
+ offset: Number(node.start) || 0,
162
+ }),
163
+ };
164
+ };
165
+
166
+ export const parseNamedExportedFunctions = ({
167
+ sourceText = "",
168
+ filePath = "unknown.js",
169
+ } = {}) => {
170
+ const functions = new Map();
171
+ let parsed = null;
172
+
173
+ try {
174
+ parsed = parseSync(filePath, sourceText, { sourceType: "unambiguous" });
175
+ } catch {
176
+ return functions;
177
+ }
178
+
179
+ if (!parsed?.program?.body || !Array.isArray(parsed.program.body)) {
180
+ return functions;
181
+ }
182
+ if (Array.isArray(parsed.errors) && parsed.errors.length > 0) {
183
+ return functions;
184
+ }
185
+
186
+ const lineOffsets = createLineOffsets(sourceText);
187
+
188
+ parsed.program.body.forEach((statement) => {
189
+ if (!statement || statement.type !== "ExportNamedDeclaration") {
190
+ return;
191
+ }
192
+
193
+ const declaration = statement.declaration;
194
+ if (!declaration) {
195
+ return;
196
+ }
197
+
198
+ if (declaration.type === "FunctionDeclaration" && declaration.id?.name) {
199
+ const meta = toFunctionMeta({
200
+ name: declaration.id.name,
201
+ node: declaration,
202
+ lineOffsets,
203
+ });
204
+ if (meta) {
205
+ functions.set(meta.name, meta);
206
+ }
207
+ return;
208
+ }
209
+
210
+ if (declaration.type !== "VariableDeclaration" || !Array.isArray(declaration.declarations)) {
211
+ return;
212
+ }
213
+
214
+ declaration.declarations.forEach((declarator) => {
215
+ const exportName = declarator?.id?.type === "Identifier" ? declarator.id.name : null;
216
+ if (!exportName) {
217
+ return;
218
+ }
219
+ const init = declarator.init;
220
+ if (!init || (init.type !== "ArrowFunctionExpression" && init.type !== "FunctionExpression")) {
221
+ return;
222
+ }
223
+ const meta = toFunctionMeta({
224
+ name: exportName,
225
+ node: init,
226
+ lineOffsets,
227
+ });
228
+ if (meta) {
229
+ functions.set(meta.name, meta);
230
+ }
231
+ });
232
+ });
233
+
234
+ return functions;
235
+ };