@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,898 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import {
4
+ collectSelectorBindingsFromView,
5
+ collectTemplateAstFromView,
6
+ collectSelectorBindingsFromViewText,
7
+ collectYamlKeyPathLines,
8
+ collectTopLevelYamlKeyLines,
9
+ collectRefListeners,
10
+ extractModuleExports,
11
+ hasLegacyDotPropBinding,
12
+ parseYamlSafe,
13
+ } from "./parsers.js";
14
+ import { normalizeSchemaYaml } from "./schema.js";
15
+ import { buildComponentScopeGraph } from "./scopeGraph.js";
16
+ import { parseNamedExportedFunctions } from "./exportedFunctions.js";
17
+
18
+ const attachFilePathToBindings = (bindings = [], filePath) => {
19
+ return bindings.map((binding) => ({
20
+ ...binding,
21
+ filePath,
22
+ }));
23
+ };
24
+
25
+ const REEXPORT_TARGET_MISSING_CODE = "RTGL-CHECK-SYMBOL-006";
26
+ const REEXPORT_SYMBOL_MISSING_CODE = "RTGL-CHECK-SYMBOL-007";
27
+ const HANDLER_EXPORT_PREFIX_CODE = "RTGL-CHECK-HANDLER-002";
28
+ const LIFECYCLE_ASYNC_BEFORE_MOUNT_CODE = "RTGL-CHECK-LIFECYCLE-001";
29
+ const LIFECYCLE_DEPS_FIRST_PARAM_CODE = "RTGL-CHECK-LIFECYCLE-002";
30
+ const LIFECYCLE_ON_UPDATE_PAYLOAD_CODE = "RTGL-CHECK-LIFECYCLE-003";
31
+ const LIFECYCLE_ON_UPDATE_PAYLOAD_NAME_CODE = "RTGL-CHECK-LIFECYCLE-004";
32
+ const COMPONENT_IDENTITY_CANONICAL_CODE = "RTGL-CHECK-COMPONENT-001";
33
+ const COMPONENT_IDENTITY_FILE_STEM_CODE = "RTGL-CHECK-COMPONENT-002";
34
+ const COMPONENT_IDENTITY_COLLISION_CODE = "RTGL-CHECK-COMPONENT-003";
35
+ const COMPONENT_IDENTITY_SEGMENT_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
36
+ const COMPONENT_FILE_SUFFIX_BY_TYPE = {
37
+ view: ".view.yaml",
38
+ schema: ".schema.yaml",
39
+ store: ".store.js",
40
+ handlers: ".handlers.js",
41
+ methods: ".methods.js",
42
+ constants: ".constants.yaml",
43
+ };
44
+ const LIFECYCLE_HANDLER_NAMES = new Set([
45
+ "handleBeforeMount",
46
+ "handleAfterMount",
47
+ "handleOnUpdate",
48
+ ]);
49
+
50
+ const normalizeComponentIdentitySegment = (segment = "") => {
51
+ if (typeof segment !== "string") {
52
+ return "";
53
+ }
54
+ return segment.trim().toLowerCase();
55
+ };
56
+
57
+ const toNormalizedComponentIdentity = ({ category = "", component = "" }) => {
58
+ const normalizedCategory = normalizeComponentIdentitySegment(category);
59
+ const normalizedComponent = normalizeComponentIdentitySegment(component);
60
+ if (!normalizedCategory || !normalizedComponent) {
61
+ return "";
62
+ }
63
+ return `${normalizedCategory}/${normalizedComponent}`;
64
+ };
65
+
66
+ const isCanonicalComponentIdentitySegment = (segment = "") => {
67
+ return typeof segment === "string"
68
+ && segment === normalizeComponentIdentitySegment(segment)
69
+ && COMPONENT_IDENTITY_SEGMENT_REGEX.test(segment);
70
+ };
71
+
72
+ const getPrimaryComponentFilePath = (files = {}) => {
73
+ const orderedFileTypes = ["schema", "view", "handlers", "store", "methods", "constants"];
74
+ for (let index = 0; index < orderedFileTypes.length; index += 1) {
75
+ const fileType = orderedFileTypes[index];
76
+ if (files[fileType]) {
77
+ return files[fileType];
78
+ }
79
+ }
80
+ return null;
81
+ };
82
+
83
+ const validateComponentIdentity = ({ componentGroup, model }) => {
84
+ const diagnostics = [];
85
+ const normalizedIdentity = toNormalizedComponentIdentity({
86
+ category: componentGroup?.category,
87
+ component: componentGroup?.component,
88
+ });
89
+ const primaryFilePath = getPrimaryComponentFilePath(componentGroup?.files);
90
+
91
+ if (
92
+ !isCanonicalComponentIdentitySegment(componentGroup?.category)
93
+ || !isCanonicalComponentIdentitySegment(componentGroup?.component)
94
+ ) {
95
+ diagnostics.push({
96
+ code: COMPONENT_IDENTITY_CANONICAL_CODE,
97
+ severity: "error",
98
+ filePath: primaryFilePath || "unknown",
99
+ line: primaryFilePath ? 1 : undefined,
100
+ message: `${model.componentKey}: component identity must use lowercase kebab-case category/component segments. Expected '${normalizedIdentity || "unknown/unknown"}'.`,
101
+ });
102
+ }
103
+
104
+ const expectedBaseName = normalizeComponentIdentitySegment(componentGroup?.component);
105
+ const entries = Array.isArray(componentGroup?.entries)
106
+ ? [...componentGroup.entries].sort((left, right) => (
107
+ left.filePath.localeCompare(right.filePath)
108
+ || left.fileType.localeCompare(right.fileType)
109
+ ))
110
+ : [];
111
+
112
+ entries.forEach((entry) => {
113
+ const expectedSuffix = COMPONENT_FILE_SUFFIX_BY_TYPE[entry.fileType];
114
+ if (!expectedSuffix || !expectedBaseName) {
115
+ return;
116
+ }
117
+
118
+ const normalizedFileStem = normalizeComponentIdentitySegment(entry.componentNameFromFile);
119
+ if (normalizedFileStem === expectedBaseName) {
120
+ return;
121
+ }
122
+
123
+ diagnostics.push({
124
+ code: COMPONENT_IDENTITY_FILE_STEM_CODE,
125
+ severity: "error",
126
+ filePath: entry.filePath || "unknown",
127
+ line: entry.filePath ? 1 : undefined,
128
+ message: `${model.componentKey}: file '${entry.fileName}' does not match component identity '${normalizedIdentity}'. Expected basename '${expectedBaseName}' before '${expectedSuffix}'.`,
129
+ });
130
+ });
131
+
132
+ return diagnostics;
133
+ };
134
+
135
+ const validateLifecycleHandlers = ({
136
+ model,
137
+ handlersPath,
138
+ handlersSourceText,
139
+ handlersExports,
140
+ }) => {
141
+ const diagnostics = [];
142
+ let hasLifecycleHandler = false;
143
+ for (const lifecycleName of LIFECYCLE_HANDLER_NAMES) {
144
+ if (handlersExports.has(lifecycleName)) {
145
+ hasLifecycleHandler = true;
146
+ break;
147
+ }
148
+ }
149
+ if (!hasLifecycleHandler) {
150
+ return diagnostics;
151
+ }
152
+
153
+ const exportedFunctions = parseNamedExportedFunctions({
154
+ sourceText: handlersSourceText,
155
+ filePath: handlersPath,
156
+ });
157
+
158
+ if (handlersExports.has("handleBeforeMount")) {
159
+ const meta = exportedFunctions.get("handleBeforeMount");
160
+ if (meta?.async) {
161
+ diagnostics.push({
162
+ code: LIFECYCLE_ASYNC_BEFORE_MOUNT_CODE,
163
+ severity: "error",
164
+ filePath: handlersPath,
165
+ line: meta.line,
166
+ message: `${model.componentKey}: lifecycle handler 'handleBeforeMount' must be synchronous.`,
167
+ });
168
+ }
169
+ }
170
+
171
+ LIFECYCLE_HANDLER_NAMES.forEach((name) => {
172
+ if (!handlersExports.has(name)) {
173
+ return;
174
+ }
175
+ const meta = exportedFunctions.get(name);
176
+ if (!meta) {
177
+ return;
178
+ }
179
+
180
+ if (meta.firstParamName !== "deps") {
181
+ diagnostics.push({
182
+ code: LIFECYCLE_DEPS_FIRST_PARAM_CODE,
183
+ severity: "error",
184
+ filePath: handlersPath,
185
+ line: meta.line,
186
+ message: `${model.componentKey}: lifecycle handler '${name}' must use 'deps' as first parameter.`,
187
+ });
188
+ }
189
+ });
190
+
191
+ if (handlersExports.has("handleOnUpdate")) {
192
+ const meta = exportedFunctions.get("handleOnUpdate");
193
+ if (meta && meta.paramCount < 2) {
194
+ diagnostics.push({
195
+ code: LIFECYCLE_ON_UPDATE_PAYLOAD_CODE,
196
+ severity: "error",
197
+ filePath: handlersPath,
198
+ line: meta.line,
199
+ message: `${model.componentKey}: lifecycle handler 'handleOnUpdate' should accept a second 'payload' parameter.`,
200
+ });
201
+ } else if (
202
+ meta
203
+ && meta.paramCount >= 2
204
+ && meta.secondParam?.kind === "identifier"
205
+ && meta.secondParamName !== "payload"
206
+ ) {
207
+ diagnostics.push({
208
+ code: LIFECYCLE_ON_UPDATE_PAYLOAD_NAME_CODE,
209
+ severity: "error",
210
+ filePath: handlersPath,
211
+ line: meta.line,
212
+ message: `${model.componentKey}: lifecycle handler 'handleOnUpdate' must use 'payload' as second parameter name.`,
213
+ });
214
+ }
215
+ }
216
+
217
+ return diagnostics;
218
+ };
219
+
220
+ const readTextFileSafe = (filePath) => {
221
+ try {
222
+ return {
223
+ ok: true,
224
+ value: readFileSync(filePath, "utf8"),
225
+ error: null,
226
+ };
227
+ } catch (err) {
228
+ return {
229
+ ok: false,
230
+ value: null,
231
+ error: {
232
+ code: "RTGL-CHECK-READ-001",
233
+ severity: "error",
234
+ message: `Failed to read file: ${err.message}`,
235
+ filePath,
236
+ },
237
+ };
238
+ }
239
+ };
240
+
241
+ const buildLocalExportTargetCandidates = ({ importerFilePath, specifier }) => {
242
+ if (!specifier || !specifier.startsWith(".")) {
243
+ return [];
244
+ }
245
+
246
+ const basePath = path.resolve(path.dirname(importerFilePath), specifier);
247
+ const candidates = [];
248
+ const scriptExtensions = [
249
+ ".js",
250
+ ".mjs",
251
+ ".cjs",
252
+ ".ts",
253
+ ".mts",
254
+ ".cts",
255
+ ];
256
+ const knownScriptExtensions = new Set(scriptExtensions);
257
+ const scriptExtensionFallbacks = {
258
+ ".js": [".ts", ".mts", ".cts", ".mjs", ".cjs"],
259
+ ".mjs": [".mts", ".ts", ".js", ".cts", ".cjs"],
260
+ ".cjs": [".cts", ".ts", ".js", ".mts", ".mjs"],
261
+ ".ts": [".js", ".mts", ".cts", ".mjs", ".cjs"],
262
+ ".mts": [".mjs", ".ts", ".js", ".cts", ".cjs"],
263
+ ".cts": [".cjs", ".ts", ".js", ".mts", ".mjs"],
264
+ };
265
+ const explicitExt = path.extname(basePath);
266
+ const hasKnownExplicitExt = explicitExt && knownScriptExtensions.has(explicitExt);
267
+
268
+ if (hasKnownExplicitExt) {
269
+ const fallbackExts = scriptExtensionFallbacks[explicitExt]
270
+ || scriptExtensions.filter((ext) => ext !== explicitExt);
271
+ const extensionlessBasePath = basePath.slice(0, -explicitExt.length);
272
+ candidates.push(basePath);
273
+ fallbackExts.forEach((fallbackExt) => {
274
+ candidates.push(`${extensionlessBasePath}${fallbackExt}`);
275
+ });
276
+ } else {
277
+ candidates.push(
278
+ basePath,
279
+ `${basePath}.js`,
280
+ `${basePath}.mjs`,
281
+ `${basePath}.cjs`,
282
+ `${basePath}.ts`,
283
+ `${basePath}.mts`,
284
+ `${basePath}.cts`,
285
+ path.join(basePath, "index.js"),
286
+ path.join(basePath, "index.mjs"),
287
+ path.join(basePath, "index.cjs"),
288
+ path.join(basePath, "index.ts"),
289
+ path.join(basePath, "index.mts"),
290
+ path.join(basePath, "index.cts"),
291
+ );
292
+ }
293
+
294
+ return candidates;
295
+ };
296
+
297
+ const resolveLocalExportTarget = ({ importerFilePath, specifier }) => {
298
+ const candidates = buildLocalExportTargetCandidates({ importerFilePath, specifier });
299
+
300
+ for (let index = 0; index < candidates.length; index += 1) {
301
+ const candidatePath = candidates[index];
302
+ const readResult = readTextFileSafe(candidatePath);
303
+ if (readResult.ok) {
304
+ return {
305
+ filePath: candidatePath,
306
+ text: readResult.value,
307
+ };
308
+ }
309
+ }
310
+
311
+ return null;
312
+ };
313
+
314
+ const pushUniqueDiagnostic = ({
315
+ diagnostics = [],
316
+ diagnostic,
317
+ diagnosticKeys = new Set(),
318
+ }) => {
319
+ if (!diagnostic || typeof diagnostic !== "object") {
320
+ return;
321
+ }
322
+
323
+ const key = JSON.stringify({
324
+ code: diagnostic.code,
325
+ message: diagnostic.message,
326
+ filePath: diagnostic.filePath,
327
+ line: diagnostic.line,
328
+ column: diagnostic.column,
329
+ endLine: diagnostic.endLine,
330
+ endColumn: diagnostic.endColumn,
331
+ });
332
+
333
+ if (diagnosticKeys.has(key)) {
334
+ return;
335
+ }
336
+
337
+ diagnosticKeys.add(key);
338
+ diagnostics.push(diagnostic);
339
+ };
340
+
341
+ const isValidHandlerExportName = (symbol = "") => {
342
+ return typeof symbol === "string"
343
+ && symbol.length > 0
344
+ && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(symbol)
345
+ && symbol.startsWith("handle");
346
+ };
347
+
348
+ const popMatchingReference = ({ references = [], predicate }) => {
349
+ const index = references.findIndex(predicate);
350
+ if (index === -1) {
351
+ return null;
352
+ }
353
+ const [reference] = references.splice(index, 1);
354
+ return reference;
355
+ };
356
+
357
+ const toDiagnosticRange = (reference = {}) => {
358
+ const range = reference?.moduleRequestRange || reference?.importedNameRange || reference?.range;
359
+ if (!range || typeof range !== "object") {
360
+ return {};
361
+ }
362
+
363
+ return {
364
+ line: Number.isInteger(range.line) ? range.line : undefined,
365
+ column: Number.isInteger(range.column) ? range.column : undefined,
366
+ endLine: Number.isInteger(range.endLine) ? range.endLine : undefined,
367
+ endColumn: Number.isInteger(range.endColumn) ? range.endColumn : undefined,
368
+ };
369
+ };
370
+
371
+ const toRelatedLocation = ({ message, filePath, range = {} }) => {
372
+ if (!filePath) {
373
+ return null;
374
+ }
375
+
376
+ return {
377
+ message,
378
+ filePath,
379
+ line: Number.isInteger(range.line) ? range.line : undefined,
380
+ column: Number.isInteger(range.column) ? range.column : undefined,
381
+ endLine: Number.isInteger(range.endLine) ? range.endLine : undefined,
382
+ endColumn: Number.isInteger(range.endColumn) ? range.endColumn : undefined,
383
+ };
384
+ };
385
+
386
+ const collectNamedExportsWithStar = ({
387
+ filePath,
388
+ sourceCode,
389
+ cache = new Map(),
390
+ stack = new Set(),
391
+ diagnostics = [],
392
+ diagnosticKeys = new Set(),
393
+ }) => {
394
+ if (cache.has(filePath)) {
395
+ return cache.get(filePath);
396
+ }
397
+
398
+ if (stack.has(filePath)) {
399
+ return new Set();
400
+ }
401
+
402
+ stack.add(filePath);
403
+ const exportsSource = extractModuleExports({
404
+ sourceCode,
405
+ filePath,
406
+ });
407
+ const oxcReferenceSource = extractModuleExports({
408
+ sourceCode,
409
+ filePath,
410
+ });
411
+ const reExportReferences = Array.isArray(oxcReferenceSource?.reExportReferences)
412
+ ? [...oxcReferenceSource.reExportReferences]
413
+ : [];
414
+
415
+ const exports = new Set(exportsSource.namedExports);
416
+ const exportStarSpecifiers = exportsSource.exportStarSpecifiers;
417
+ const namespaceReExports = Array.isArray(exportsSource.namespaceReExports)
418
+ ? exportsSource.namespaceReExports
419
+ : [];
420
+ const namedReExports = Array.isArray(exportsSource.namedReExports)
421
+ ? exportsSource.namedReExports
422
+ : [];
423
+ exportStarSpecifiers.forEach((specifier) => {
424
+ if (!specifier.startsWith(".")) {
425
+ return;
426
+ }
427
+
428
+ const reference = popMatchingReference({
429
+ references: reExportReferences,
430
+ predicate: (candidate) => candidate?.kind === "export-star"
431
+ && candidate?.moduleRequest === specifier,
432
+ });
433
+ const resolvedTarget = resolveLocalExportTarget({ importerFilePath: filePath, specifier });
434
+ if (!resolvedTarget) {
435
+ const attemptedCandidates = buildLocalExportTargetCandidates({
436
+ importerFilePath: filePath,
437
+ specifier,
438
+ });
439
+ const attemptedSuffix = attemptedCandidates.length > 0
440
+ ? ` Tried: ${attemptedCandidates.map((candidate) => path.basename(candidate)).join(", ")}.`
441
+ : "";
442
+ const related = [];
443
+ if (reference?.range) {
444
+ const declarationRelated = toRelatedLocation({
445
+ message: "Re-export declaration span.",
446
+ filePath,
447
+ range: reference.range,
448
+ });
449
+ if (declarationRelated) {
450
+ related.push(declarationRelated);
451
+ }
452
+ }
453
+ const diagnosticRange = toDiagnosticRange(reference);
454
+ pushUniqueDiagnostic({
455
+ diagnostics,
456
+ diagnosticKeys,
457
+ diagnostic: {
458
+ code: REEXPORT_TARGET_MISSING_CODE,
459
+ severity: "error",
460
+ message: `${path.basename(filePath)}: unable to resolve re-export target '${specifier}'.${attemptedSuffix}`,
461
+ filePath,
462
+ ...diagnosticRange,
463
+ related,
464
+ },
465
+ });
466
+ return;
467
+ }
468
+
469
+ const nestedExports = collectNamedExportsWithStar({
470
+ filePath: resolvedTarget.filePath,
471
+ sourceCode: resolvedTarget.text,
472
+ cache,
473
+ stack,
474
+ diagnostics,
475
+ diagnosticKeys,
476
+ });
477
+ nestedExports.forEach((name) => {
478
+ if (name !== "default") {
479
+ exports.add(name);
480
+ }
481
+ });
482
+ });
483
+
484
+ namespaceReExports.forEach(({ moduleRequest, exportedName }) => {
485
+ if (!moduleRequest || !exportedName) {
486
+ return;
487
+ }
488
+ if (!moduleRequest.startsWith(".")) {
489
+ return;
490
+ }
491
+
492
+ const reference = popMatchingReference({
493
+ references: reExportReferences,
494
+ predicate: (candidate) => candidate?.kind === "namespace-reexport"
495
+ && candidate?.moduleRequest === moduleRequest
496
+ && candidate?.exportedName === exportedName,
497
+ });
498
+ const resolvedTarget = resolveLocalExportTarget({
499
+ importerFilePath: filePath,
500
+ specifier: moduleRequest,
501
+ });
502
+ if (resolvedTarget) {
503
+ return;
504
+ }
505
+
506
+ exports.delete(exportedName);
507
+ const attemptedCandidates = buildLocalExportTargetCandidates({
508
+ importerFilePath: filePath,
509
+ specifier: moduleRequest,
510
+ });
511
+ const attemptedSuffix = attemptedCandidates.length > 0
512
+ ? ` Tried: ${attemptedCandidates.map((candidate) => path.basename(candidate)).join(", ")}.`
513
+ : "";
514
+ const related = [];
515
+ if (reference?.range) {
516
+ const declarationRelated = toRelatedLocation({
517
+ message: "Re-export declaration span.",
518
+ filePath,
519
+ range: reference.range,
520
+ });
521
+ if (declarationRelated) {
522
+ related.push(declarationRelated);
523
+ }
524
+ }
525
+ const diagnosticRange = toDiagnosticRange(reference);
526
+ pushUniqueDiagnostic({
527
+ diagnostics,
528
+ diagnosticKeys,
529
+ diagnostic: {
530
+ code: REEXPORT_TARGET_MISSING_CODE,
531
+ severity: "error",
532
+ message: `${path.basename(filePath)}: unable to resolve namespace re-export target '${moduleRequest}' for '${exportedName}'.${attemptedSuffix}`,
533
+ filePath,
534
+ ...diagnosticRange,
535
+ related,
536
+ },
537
+ });
538
+ });
539
+
540
+ namedReExports.forEach(({ moduleRequest, importedName, exportedName }) => {
541
+ if (!moduleRequest || !importedName || !exportedName) {
542
+ return;
543
+ }
544
+ if (!moduleRequest.startsWith(".")) {
545
+ return;
546
+ }
547
+
548
+ const reference = popMatchingReference({
549
+ references: reExportReferences,
550
+ predicate: (candidate) => candidate?.kind === "named-reexport"
551
+ && candidate?.moduleRequest === moduleRequest
552
+ && candidate?.importedName === importedName
553
+ && candidate?.exportedName === exportedName,
554
+ });
555
+ const resolvedTarget = resolveLocalExportTarget({
556
+ importerFilePath: filePath,
557
+ specifier: moduleRequest,
558
+ });
559
+ if (!resolvedTarget) {
560
+ const attemptedCandidates = buildLocalExportTargetCandidates({
561
+ importerFilePath: filePath,
562
+ specifier: moduleRequest,
563
+ });
564
+ const attemptedSuffix = attemptedCandidates.length > 0
565
+ ? ` Tried: ${attemptedCandidates.map((candidate) => path.basename(candidate)).join(", ")}.`
566
+ : "";
567
+ const related = [];
568
+ if (reference?.range) {
569
+ const declarationRelated = toRelatedLocation({
570
+ message: "Re-export declaration span.",
571
+ filePath,
572
+ range: reference.range,
573
+ });
574
+ if (declarationRelated) {
575
+ related.push(declarationRelated);
576
+ }
577
+ }
578
+ const diagnosticRange = toDiagnosticRange(reference);
579
+ pushUniqueDiagnostic({
580
+ diagnostics,
581
+ diagnosticKeys,
582
+ diagnostic: {
583
+ code: REEXPORT_TARGET_MISSING_CODE,
584
+ severity: "error",
585
+ message: `${path.basename(filePath)}: unable to resolve re-export target '${moduleRequest}'.${attemptedSuffix}`,
586
+ filePath,
587
+ ...diagnosticRange,
588
+ related,
589
+ },
590
+ });
591
+ return;
592
+ }
593
+
594
+ const nestedExports = collectNamedExportsWithStar({
595
+ filePath: resolvedTarget.filePath,
596
+ sourceCode: resolvedTarget.text,
597
+ cache,
598
+ stack,
599
+ diagnostics,
600
+ diagnosticKeys,
601
+ });
602
+ if (nestedExports.has(importedName)) {
603
+ exports.add(exportedName);
604
+ return;
605
+ }
606
+
607
+ const related = [];
608
+ const targetRelated = toRelatedLocation({
609
+ message: "Resolved re-export target module.",
610
+ filePath: resolvedTarget.filePath,
611
+ range: { line: 1, column: 1, endLine: 1, endColumn: 1 },
612
+ });
613
+ if (targetRelated) {
614
+ related.push(targetRelated);
615
+ }
616
+ const declarationRelated = toRelatedLocation({
617
+ message: "Re-export declaration span.",
618
+ filePath,
619
+ range: reference?.range,
620
+ });
621
+ if (declarationRelated) {
622
+ related.push(declarationRelated);
623
+ }
624
+ const diagnosticRange = toDiagnosticRange(reference);
625
+ pushUniqueDiagnostic({
626
+ diagnostics,
627
+ diagnosticKeys,
628
+ diagnostic: {
629
+ code: REEXPORT_SYMBOL_MISSING_CODE,
630
+ severity: "error",
631
+ message: `${path.basename(filePath)}: re-export '${importedName}' from '${moduleRequest}' does not exist in target module.`,
632
+ filePath,
633
+ ...diagnosticRange,
634
+ related,
635
+ },
636
+ });
637
+ });
638
+
639
+ stack.delete(filePath);
640
+ cache.set(filePath, exports);
641
+ return exports;
642
+ };
643
+
644
+ export const buildComponentModel = (componentGroup) => {
645
+ const diagnostics = [];
646
+ const files = componentGroup.files || {};
647
+
648
+ const model = {
649
+ ...componentGroup,
650
+ componentIdentity: {
651
+ normalizedKey: toNormalizedComponentIdentity({
652
+ category: componentGroup?.category,
653
+ component: componentGroup?.component,
654
+ }),
655
+ },
656
+ diagnostics,
657
+ view: {
658
+ filePath: files.view || null,
659
+ text: "",
660
+ yaml: null,
661
+ selectorBindings: [],
662
+ topLevelKeyLines: new Map(),
663
+ yamlKeyPathLines: new Map(),
664
+ refListeners: [],
665
+ templateAst: {
666
+ type: "Template",
667
+ nodes: [],
668
+ },
669
+ hasLegacyDotPropBinding: false,
670
+ legacyDotPropBindingLine: undefined,
671
+ },
672
+ schema: {
673
+ filePath: files.schema || null,
674
+ yaml: null,
675
+ normalized: normalizeSchemaYaml(null),
676
+ yamlKeyPathLines: new Map(),
677
+ },
678
+ constants: {
679
+ filePath: files.constants || null,
680
+ yaml: null,
681
+ },
682
+ store: {
683
+ filePath: files.store || null,
684
+ exports: new Set(),
685
+ sourceText: "",
686
+ },
687
+ handlers: {
688
+ filePath: files.handlers || null,
689
+ exports: new Set(),
690
+ sourceText: "",
691
+ },
692
+ methods: {
693
+ filePath: files.methods || null,
694
+ exports: new Set(),
695
+ sourceText: "",
696
+ },
697
+ semanticGraph: {
698
+ globalSymbols: new Set(),
699
+ references: [],
700
+ },
701
+ };
702
+
703
+ diagnostics.push(...validateComponentIdentity({ componentGroup, model }));
704
+
705
+ if (files.view) {
706
+ const viewReadResult = readTextFileSafe(files.view);
707
+ if (!viewReadResult.ok) {
708
+ diagnostics.push(viewReadResult.error);
709
+ } else {
710
+ model.view.text = viewReadResult.value;
711
+ model.view.selectorBindings = attachFilePathToBindings(
712
+ collectSelectorBindingsFromViewText(viewReadResult.value),
713
+ files.view,
714
+ );
715
+ model.view.topLevelKeyLines = collectTopLevelYamlKeyLines(viewReadResult.value);
716
+ model.view.yamlKeyPathLines = collectYamlKeyPathLines(viewReadResult.value);
717
+ const firstLegacyDotPropBinding = model.view.selectorBindings.find(
718
+ (binding) => binding.bindingNames.some((bindingName) => bindingName.startsWith(".")),
719
+ );
720
+ if (firstLegacyDotPropBinding) {
721
+ model.view.hasLegacyDotPropBinding = true;
722
+ model.view.legacyDotPropBindingLine = firstLegacyDotPropBinding.line;
723
+ }
724
+
725
+ const viewYamlResult = parseYamlSafe({ text: viewReadResult.value, filePath: files.view });
726
+ if (!viewYamlResult.ok) {
727
+ diagnostics.push(viewYamlResult.error);
728
+ } else {
729
+ model.view.yaml = viewYamlResult.value;
730
+ model.view.selectorBindings = attachFilePathToBindings(
731
+ collectSelectorBindingsFromView({
732
+ viewText: viewReadResult.value,
733
+ viewYaml: viewYamlResult.value,
734
+ }),
735
+ files.view,
736
+ );
737
+ model.view.templateAst = collectTemplateAstFromView({
738
+ viewText: viewReadResult.value,
739
+ viewYaml: viewYamlResult.value,
740
+ });
741
+ const firstLegacyDotPropBindingAfterAst = model.view.selectorBindings.find(
742
+ (binding) => binding.bindingNames.some((bindingName) => bindingName.startsWith(".")),
743
+ );
744
+ if (firstLegacyDotPropBindingAfterAst) {
745
+ model.view.hasLegacyDotPropBinding = true;
746
+ model.view.legacyDotPropBindingLine = firstLegacyDotPropBindingAfterAst.line;
747
+ }
748
+ model.view.refListeners = collectRefListeners(viewYamlResult.value, model.view.yamlKeyPathLines);
749
+ model.view.hasLegacyDotPropBinding = model.view.hasLegacyDotPropBinding
750
+ || hasLegacyDotPropBinding(viewYamlResult.value?.template);
751
+ }
752
+ }
753
+ }
754
+
755
+ if (files.schema) {
756
+ const schemaReadResult = readTextFileSafe(files.schema);
757
+ if (!schemaReadResult.ok) {
758
+ diagnostics.push(schemaReadResult.error);
759
+ } else {
760
+ model.schema.yamlKeyPathLines = collectYamlKeyPathLines(schemaReadResult.value);
761
+ const schemaYamlResult = parseYamlSafe({ text: schemaReadResult.value, filePath: files.schema });
762
+ if (!schemaYamlResult.ok) {
763
+ diagnostics.push(schemaYamlResult.error);
764
+ } else {
765
+ model.schema.yaml = schemaYamlResult.value;
766
+ model.schema.normalized = normalizeSchemaYaml(schemaYamlResult.value);
767
+ }
768
+ }
769
+ }
770
+
771
+ if (files.constants) {
772
+ const constantsReadResult = readTextFileSafe(files.constants);
773
+ if (!constantsReadResult.ok) {
774
+ diagnostics.push(constantsReadResult.error);
775
+ } else {
776
+ const constantsYamlResult = parseYamlSafe({ text: constantsReadResult.value, filePath: files.constants });
777
+ if (!constantsYamlResult.ok) {
778
+ diagnostics.push(constantsYamlResult.error);
779
+ } else {
780
+ model.constants.yaml = constantsYamlResult.value;
781
+ }
782
+ }
783
+ }
784
+
785
+ if (files.store) {
786
+ const storeReadResult = readTextFileSafe(files.store);
787
+ if (!storeReadResult.ok) {
788
+ diagnostics.push(storeReadResult.error);
789
+ } else {
790
+ const exportResolutionDiagnosticKeys = new Set();
791
+ model.store.sourceText = storeReadResult.value;
792
+ model.store.exports = collectNamedExportsWithStar({
793
+ filePath: files.store,
794
+ sourceCode: storeReadResult.value,
795
+ diagnostics,
796
+ diagnosticKeys: exportResolutionDiagnosticKeys,
797
+ });
798
+ }
799
+ }
800
+
801
+ if (files.handlers) {
802
+ const handlersReadResult = readTextFileSafe(files.handlers);
803
+ if (!handlersReadResult.ok) {
804
+ diagnostics.push(handlersReadResult.error);
805
+ } else {
806
+ const exportResolutionDiagnosticKeys = new Set();
807
+ model.handlers.sourceText = handlersReadResult.value;
808
+ model.handlers.exports = collectNamedExportsWithStar({
809
+ filePath: files.handlers,
810
+ sourceCode: handlersReadResult.value,
811
+ diagnostics,
812
+ diagnosticKeys: exportResolutionDiagnosticKeys,
813
+ });
814
+ model.handlers.exports.forEach((handlerName) => {
815
+ if (isValidHandlerExportName(handlerName)) {
816
+ return;
817
+ }
818
+
819
+ diagnostics.push({
820
+ code: HANDLER_EXPORT_PREFIX_CODE,
821
+ severity: "error",
822
+ filePath: files.handlers,
823
+ message: `${model.componentKey}: invalid handler export '${handlerName}' in .handlers.js. Handler names must start with 'handle'.`,
824
+ });
825
+ });
826
+ diagnostics.push(...validateLifecycleHandlers({
827
+ model,
828
+ handlersPath: files.handlers,
829
+ handlersSourceText: handlersReadResult.value,
830
+ handlersExports: model.handlers.exports,
831
+ }));
832
+ }
833
+ }
834
+
835
+ if (files.methods) {
836
+ const methodsReadResult = readTextFileSafe(files.methods);
837
+ if (!methodsReadResult.ok) {
838
+ diagnostics.push(methodsReadResult.error);
839
+ } else {
840
+ const exportResolutionDiagnosticKeys = new Set();
841
+ model.methods.sourceText = methodsReadResult.value;
842
+ model.methods.exports = collectNamedExportsWithStar({
843
+ filePath: files.methods,
844
+ sourceCode: methodsReadResult.value,
845
+ diagnostics,
846
+ diagnosticKeys: exportResolutionDiagnosticKeys,
847
+ });
848
+ }
849
+ }
850
+
851
+ model.semanticGraph = buildComponentScopeGraph(model);
852
+
853
+ return model;
854
+ };
855
+
856
+ export const buildProjectModel = (componentGroups = []) => {
857
+ const models = componentGroups.map((componentGroup) => buildComponentModel(componentGroup));
858
+ const ownersByNormalizedIdentity = new Map();
859
+
860
+ models.forEach((model) => {
861
+ const normalizedKey = model?.componentIdentity?.normalizedKey;
862
+ if (!normalizedKey) {
863
+ return;
864
+ }
865
+ if (!ownersByNormalizedIdentity.has(normalizedKey)) {
866
+ ownersByNormalizedIdentity.set(normalizedKey, []);
867
+ }
868
+ ownersByNormalizedIdentity.get(normalizedKey).push(model);
869
+ });
870
+
871
+ ownersByNormalizedIdentity.forEach((owners, normalizedKey) => {
872
+ if (owners.length < 2) {
873
+ return;
874
+ }
875
+
876
+ const distinctComponentKeys = [...new Set(owners.map((owner) => owner.componentKey))].sort();
877
+ if (distinctComponentKeys.length < 2) {
878
+ return;
879
+ }
880
+
881
+ owners.forEach((owner) => {
882
+ const collidingKeys = distinctComponentKeys.filter((key) => key !== owner.componentKey);
883
+ if (collidingKeys.length === 0) {
884
+ return;
885
+ }
886
+ const filePath = getPrimaryComponentFilePath(owner.files);
887
+ owner.diagnostics.push({
888
+ code: COMPONENT_IDENTITY_COLLISION_CODE,
889
+ severity: "error",
890
+ filePath: filePath || "unknown",
891
+ line: filePath ? 1 : undefined,
892
+ message: `${owner.componentKey}: normalized component identity '${normalizedKey}' collides with [${collidingKeys.join(", ")}].`,
893
+ });
894
+ });
895
+ });
896
+
897
+ return models;
898
+ };