@wcstack/lint 1.25.0 → 1.26.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.
Files changed (2) hide show
  1. package/dist/cli.cjs +187 -72
  2. package/package.json +1 -1
package/dist/cli.cjs CHANGED
@@ -418,15 +418,17 @@ function extractAttribute(tagContent, attrName) {
418
418
  var RESERVED_STREAMS_KEY = "$streams";
419
419
  var RESERVED_COMMAND_TOKENS_KEY = "$commandTokens";
420
420
  var RESERVED_EVENT_TOKENS_KEY = "$eventTokens";
421
+ var RESERVED_LIST_KEYS_KEY = "$listKeys";
421
422
  function analyzeStatePaths(scriptContent, stateName = "default") {
422
423
  const objectContent = extractDefaultExportObject(scriptContent);
423
424
  if (!objectContent) return [];
424
425
  const paths = [];
425
426
  const topLevelProps = parseTopLevelProperties(objectContent);
426
427
  const pendingStreamValues = [];
428
+ const pendingListKeys = [];
427
429
  for (const prop of topLevelProps) {
428
430
  if (prop.name.startsWith("$")) {
429
- collectReservedKeyPaths(prop, paths, pendingStreamValues, stateName);
431
+ collectReservedKeyPaths(prop, paths, pendingStreamValues, pendingListKeys, stateName);
430
432
  continue;
431
433
  }
432
434
  if (prop.kind === "method") {
@@ -434,7 +436,9 @@ function analyzeStatePaths(scriptContent, stateName = "default") {
434
436
  continue;
435
437
  }
436
438
  if (prop.kind === "getter") {
437
- paths.push({ path: prop.name, kind: "computed", stateName });
439
+ if (!paths.some((p) => p.stateName === stateName && p.path === prop.name)) {
440
+ paths.push({ path: prop.name, kind: "computed", stateName });
441
+ }
438
442
  continue;
439
443
  }
440
444
  pushDataPropertyPaths(prop, paths, stateName);
@@ -443,9 +447,12 @@ function analyzeStatePaths(scriptContent, stateName = "default") {
443
447
  if (paths.some((p) => p.stateName === stateName && p.path === streamValue.name)) continue;
444
448
  pushDataPropertyPaths(streamValue, paths, stateName);
445
449
  }
450
+ for (const listKeyEntry of pendingListKeys) {
451
+ pushListKeyPaths(listKeyEntry, paths, stateName);
452
+ }
446
453
  return paths;
447
454
  }
448
- function collectReservedKeyPaths(prop, paths, pendingStreamValues, stateName) {
455
+ function collectReservedKeyPaths(prop, paths, pendingStreamValues, pendingListKeys, stateName) {
449
456
  if (prop.name === RESERVED_STREAMS_KEY && prop.kind === "data" && prop.value && isObjectLiteral(prop.value)) {
450
457
  const entries = parseTopLevelProperties(extractObjectContent(prop.value));
451
458
  for (const entry of entries) {
@@ -474,6 +481,36 @@ function collectReservedKeyPaths(prop, paths, pendingStreamValues, stateName) {
474
481
  }
475
482
  return;
476
483
  }
484
+ if (prop.name === RESERVED_LIST_KEYS_KEY && prop.kind === "data" && prop.value && isObjectLiteral(prop.value)) {
485
+ for (const entry of parseTopLevelProperties(extractObjectContent(prop.value))) {
486
+ if (entry.kind !== "data") continue;
487
+ pendingListKeys.push(entry);
488
+ }
489
+ return;
490
+ }
491
+ }
492
+ function pushListKeyPaths(entry, paths, stateName) {
493
+ const listPath = entry.name;
494
+ const segments = listPath.split(".");
495
+ if (listPath.length === 0 || segments.some((s) => s.length === 0) || segments[segments.length - 1] === "*") {
496
+ return;
497
+ }
498
+ const has = (path) => paths.some((p) => p.stateName === stateName && p.path === path);
499
+ if (!has(listPath)) paths.push({ path: listPath, kind: "data", typeHint: "array", stateName });
500
+ if (!has(`${listPath}.*`)) paths.push({ path: `${listPath}.*`, kind: "list", stateName });
501
+ if (!has(`${listPath}.length`)) {
502
+ paths.push({ path: `${listPath}.length`, kind: "data", typeHint: "number", stateName });
503
+ }
504
+ const keyField = extractStringLiteralValue(entry.value);
505
+ if (keyField === null || keyField.includes(".") || keyField.includes("*")) return;
506
+ if (!has(`${listPath}.*.${keyField}`)) {
507
+ paths.push({ path: `${listPath}.*.${keyField}`, kind: "data", stateName });
508
+ }
509
+ }
510
+ function extractStringLiteralValue(value) {
511
+ if (!value) return null;
512
+ const match = value.trim().match(/^["']([^"'\\]*)["']$/);
513
+ return match && match[1].length > 0 ? match[1] : null;
477
514
  }
478
515
  function findStreamInitialProperty(entryValue) {
479
516
  const defProps = parseTopLevelProperties(extractObjectContent(entryValue));
@@ -489,46 +526,32 @@ function extractStringArrayItems(value) {
489
526
  }
490
527
  return items;
491
528
  }
529
+ var MAX_OBJECT_NEST_DEPTH = 5;
492
530
  function pushDataPropertyPaths(prop, paths, stateName) {
493
- paths.push({ path: prop.name, kind: "data", typeHint: prop.typeHint, rawInitial: prop.value?.trim(), stateName });
531
+ pushDataPropertyPathsAt(prop.name, prop, paths, stateName, 0);
532
+ }
533
+ function pushDataPropertyPathsAt(path, prop, paths, stateName, depth) {
534
+ paths.push({ path, kind: "data", typeHint: prop.typeHint, rawInitial: prop.value?.trim(), stateName });
494
535
  if (prop.value && isArrayLiteral(prop.value)) {
495
- paths.push({ path: `${prop.name}.*`, kind: "list", stateName });
496
- paths.push({ path: `${prop.name}.length`, kind: "data", typeHint: "number", stateName });
536
+ paths.push({ path: `${path}.*`, kind: "list", stateName });
537
+ paths.push({ path: `${path}.length`, kind: "data", typeHint: "number", stateName });
497
538
  const elementProps = extractArrayElementProperties(prop.value);
498
539
  for (const childProp of elementProps) {
499
540
  paths.push({
500
- path: `${prop.name}.*.${childProp.name}`,
541
+ path: `${path}.*.${childProp.name}`,
501
542
  kind: "data",
502
543
  typeHint: childProp.typeHint,
503
544
  stateName
504
545
  });
505
546
  }
547
+ return;
506
548
  }
507
549
  if (prop.value && isObjectLiteral(prop.value)) {
550
+ if (depth >= MAX_OBJECT_NEST_DEPTH) return;
508
551
  const childProps = parseTopLevelProperties(extractObjectContent(prop.value));
509
552
  for (const childProp of childProps) {
510
- if (childProp.kind === "data") {
511
- paths.push({
512
- path: `${prop.name}.${childProp.name}`,
513
- kind: "data",
514
- typeHint: childProp.typeHint,
515
- rawInitial: childProp.value?.trim(),
516
- stateName
517
- });
518
- if (childProp.value && isArrayLiteral(childProp.value)) {
519
- paths.push({ path: `${prop.name}.${childProp.name}.*`, kind: "list", stateName });
520
- paths.push({ path: `${prop.name}.${childProp.name}.length`, kind: "data", typeHint: "number", stateName });
521
- const grandchildProps = extractArrayElementProperties(childProp.value);
522
- for (const gc of grandchildProps) {
523
- paths.push({
524
- path: `${prop.name}.${childProp.name}.*.${gc.name}`,
525
- kind: "data",
526
- typeHint: gc.typeHint,
527
- stateName
528
- });
529
- }
530
- }
531
- }
553
+ if (childProp.kind !== "data") continue;
554
+ pushDataPropertyPathsAt(`${path}.${childProp.name}`, childProp, paths, stateName, depth + 1);
532
555
  }
533
556
  }
534
557
  }
@@ -576,35 +599,44 @@ function inferJsonTypeHint(value) {
576
599
  return void 0;
577
600
  }
578
601
  function extractDefaultExportObject(script) {
579
- const match = script.match(/export\s+default\s+(?:defineState\s*\(\s*)?(\{)/);
602
+ const scan = maskCommentsAndStrings(script);
603
+ const match = scan.match(/export\s+default\s+(?:defineState\s*\(\s*)?(\{)/);
580
604
  if (!match) return null;
581
- const startIndex = script.indexOf(match[1], match.index);
582
- return extractBracedContent(script, startIndex);
605
+ const startIndex = scan.indexOf(match[1], match.index);
606
+ return extractBracedContent(script, scan, startIndex);
583
607
  }
584
608
  function parseTopLevelProperties(objectContent) {
585
609
  const props = [];
586
- const regex = /(?:get\s+(?:"([^"]+)"|'([^']+)'|([$\w]+))\s*\(\s*\))|(?:(?:async\s+)?([$\w]+)\s*\([^)]*\)\s*\{)|(?:(?:"([^"]+)"|'([^']+)'|([$\w]+))\s*:\s*)/g;
610
+ const scan = maskCommentsAndStrings(objectContent);
611
+ const regex = /(?:(?:get|set)\s+(?:"([^"]+)"|'([^']+)'|([$\w]+))\s*\([^)]*\)\s*\{)|(?:(?:async\s+)?([$\w]+)\s*\([^)]*\)\s*\{)|(?:(?:"([^"]+)"|'([^']+)'|([$\w]+))\s*:\s*)/gd;
587
612
  let match;
588
- while ((match = regex.exec(objectContent)) !== null) {
589
- const getterName = match[1] ?? match[2] ?? match[3];
590
- if (getterName) {
591
- props.push({ name: getterName, kind: "getter" });
613
+ while ((match = regex.exec(scan)) !== null) {
614
+ const indices = match.indices;
615
+ const nameAt = (group) => {
616
+ const span = indices[group];
617
+ return span ? objectContent.slice(span[0], span[1]) : void 0;
618
+ };
619
+ const skipBody = () => {
620
+ const braceStart = match.index + match[0].length - 1;
621
+ const body = extractBracedContent(objectContent, scan, braceStart);
622
+ regex.lastIndex = braceStart + body.length + 2;
623
+ };
624
+ const accessorName = nameAt(1) ?? nameAt(2) ?? nameAt(3);
625
+ if (accessorName) {
626
+ props.push({ name: accessorName, kind: "getter" });
627
+ skipBody();
592
628
  continue;
593
629
  }
594
- const methodName = match[4];
630
+ const methodName = nameAt(4);
595
631
  if (methodName) {
596
632
  props.push({ name: methodName, kind: "method" });
597
- const braceStart = objectContent.indexOf("{", match.index + match[0].length - 1);
598
- if (braceStart !== -1) {
599
- const body = extractBracedContent(objectContent, braceStart);
600
- regex.lastIndex = braceStart + body.length + 2;
601
- }
633
+ skipBody();
602
634
  continue;
603
635
  }
604
- const propName = match[5] ?? match[6] ?? match[7];
636
+ const propName = nameAt(5) ?? nameAt(6) ?? nameAt(7);
605
637
  if (propName) {
606
638
  const valueStartIndex = match.index + match[0].length;
607
- const value = extractFullValue(objectContent, valueStartIndex);
639
+ const value = extractFullValue(objectContent, scan, valueStartIndex);
608
640
  const jsdocType = extractJsDocType(objectContent, match.index);
609
641
  const typeHint = jsdocType ?? inferTypeHint(value);
610
642
  props.push({ name: propName, kind: "data", value, typeHint });
@@ -613,15 +645,48 @@ function parseTopLevelProperties(objectContent) {
613
645
  }
614
646
  return props;
615
647
  }
616
- function extractFullValue(content, startIndex) {
648
+ function maskCommentsAndStrings(source) {
649
+ const out = source.split("");
650
+ const len = source.length;
651
+ const blank = (i2) => {
652
+ if (source[i2] !== "\n" && source[i2] !== "\r") out[i2] = " ";
653
+ };
654
+ let i = 0;
655
+ while (i < len) {
656
+ const ch = source[i];
657
+ if (ch === "/" && source[i + 1] === "/") {
658
+ i += 2;
659
+ while (i < len && source[i] !== "\n") blank(i++);
660
+ continue;
661
+ }
662
+ if (ch === "/" && source[i + 1] === "*") {
663
+ i += 2;
664
+ while (i < len && !(source[i] === "*" && source[i + 1] === "/")) blank(i++);
665
+ i += 2;
666
+ continue;
667
+ }
668
+ if (ch === '"' || ch === "'" || ch === "`") {
669
+ i++;
670
+ while (i < len && source[i] !== ch) {
671
+ if (source[i] === "\\") blank(i++);
672
+ if (i < len) blank(i++);
673
+ }
674
+ i++;
675
+ continue;
676
+ }
677
+ i++;
678
+ }
679
+ return out.join("");
680
+ }
681
+ function extractFullValue(content, scan, startIndex) {
617
682
  let depth = 0;
618
683
  let i = startIndex;
619
- const len = content.length;
684
+ const len = scan.length;
620
685
  let inString = null;
621
686
  while (i < len) {
622
- const ch = content[i];
687
+ const ch = scan[i];
623
688
  if (inString) {
624
- if (ch === inString && !isEscaped(content, i)) {
689
+ if (ch === inString && !isEscaped(scan, i)) {
625
690
  inString = null;
626
691
  }
627
692
  i++;
@@ -641,13 +706,13 @@ function extractFullValue(content, startIndex) {
641
706
  }
642
707
  return content.slice(startIndex, i).trim();
643
708
  }
644
- function extractBracedContent(text, openBraceIndex) {
709
+ function extractBracedContent(text, scan, openBraceIndex) {
645
710
  let depth = 0;
646
711
  let inString = null;
647
- for (let i = openBraceIndex; i < text.length; i++) {
648
- const ch = text[i];
712
+ for (let i = openBraceIndex; i < scan.length; i++) {
713
+ const ch = scan[i];
649
714
  if (inString) {
650
- if (ch === inString && !isEscaped(text, i)) {
715
+ if (ch === inString && !isEscaped(scan, i)) {
651
716
  inString = null;
652
717
  }
653
718
  continue;
@@ -673,16 +738,18 @@ function isObjectLiteral(value) {
673
738
  }
674
739
  function extractObjectContent(value) {
675
740
  const trimmed = value.trim();
676
- const start = trimmed.indexOf("{");
741
+ const scan = maskCommentsAndStrings(trimmed);
742
+ const start = scan.indexOf("{");
677
743
  if (start === -1) return "";
678
- return extractBracedContent(trimmed, start);
744
+ return extractBracedContent(trimmed, scan, start);
679
745
  }
680
746
  function extractArrayElementProperties(value) {
681
747
  const trimmed = value.trim();
682
748
  if (!trimmed.startsWith("[")) return [];
683
- const objectStart = trimmed.indexOf("{");
749
+ const scan = maskCommentsAndStrings(trimmed);
750
+ const objectStart = scan.indexOf("{");
684
751
  if (objectStart === -1) return [];
685
- const objectContent = extractBracedContent(trimmed, objectStart);
752
+ const objectContent = extractBracedContent(trimmed, scan, objectStart);
686
753
  const props = [];
687
754
  const allProps = parseTopLevelProperties(objectContent);
688
755
  for (const prop of allProps) {
@@ -978,6 +1045,11 @@ function validateBindings(html, attrName, stateTagName = "wcs-state", locale) {
978
1045
  pathsByState.set(p.stateName, list);
979
1046
  }
980
1047
  const attrs = findAllBindAttributes(html, attrName);
1048
+ let structuralTemplates = null;
1049
+ const getStructuralTemplates = () => {
1050
+ structuralTemplates ??= collectStructuralTemplates(html, attrName);
1051
+ return structuralTemplates;
1052
+ };
981
1053
  const filterNameSet = new Set(BUILTIN_FILTERS.map((f) => f.name));
982
1054
  for (const attr of attrs) {
983
1055
  const bindings = splitBindingExpressions(attr.value);
@@ -1063,7 +1135,7 @@ function validateBindings(html, attrName, stateTagName = "wcs-state", locale) {
1063
1135
  if (pathTrimmed.startsWith(".")) {
1064
1136
  const forPath = getInnermostForPath(html, attr.valueStart, attrName);
1065
1137
  if (forPath && !forPath.startsWith(".")) {
1066
- checkPath = `${forPath}.*.${pathTrimmed.slice(1)}`;
1138
+ checkPath = pathTrimmed === "." ? `${forPath}.*` : `${forPath}.*.${pathTrimmed.slice(1)}`;
1067
1139
  } else {
1068
1140
  checkPath = "";
1069
1141
  }
@@ -1172,7 +1244,10 @@ function validateBindings(html, attrName, stateTagName = "wcs-state", locale) {
1172
1244
  if (pathTrimmed && !pathTrimmed.startsWith(".") && !isLiteral(pathTrimmed)) {
1173
1245
  const resultType = resolveResultType(pathTrimmed, parsed.filters, scopedPaths);
1174
1246
  if (resultType !== null) {
1175
- const typeReq = getExpectedType(parsed.property);
1247
+ const typeReq = getExpectedType(
1248
+ parsed.property,
1249
+ () => isNegatedByElseChain(getStructuralTemplates(), attr.valueStart)
1250
+ );
1176
1251
  if (typeReq && resultType !== typeReq.expected) {
1177
1252
  const pathOffset = binding.indexOf(parsed.path);
1178
1253
  const pathStart = bindingStart + pathOffset;
@@ -1352,12 +1427,54 @@ function validatePathExistence(checkPath, displayPath, scopedPaths, scopedPathSe
1352
1427
  }
1353
1428
  return null;
1354
1429
  }
1355
- function getExpectedType(property) {
1430
+ function collectStructuralTemplates(html, attrName) {
1431
+ const escaped = attrName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1432
+ const attrRegex = new RegExp(`${escaped}\\s*=\\s*(["'])`, "i");
1433
+ const tagRegex = /<template(?:\s[^>]*)?>|<\/template\s*>/gi;
1434
+ const templates = [];
1435
+ let depth = 0;
1436
+ let match;
1437
+ while ((match = tagRegex.exec(html)) !== null) {
1438
+ if (match[0].startsWith("</")) {
1439
+ depth = Math.max(0, depth - 1);
1440
+ continue;
1441
+ }
1442
+ const attrMatch = attrRegex.exec(match[0]);
1443
+ if (attrMatch) {
1444
+ const quote = attrMatch[1];
1445
+ const valueStart = match.index + attrMatch.index + attrMatch[0].length;
1446
+ const valueEnd = html.indexOf(quote, valueStart);
1447
+ if (valueEnd !== -1) {
1448
+ const first = splitBindingExpressions(html.slice(valueStart, valueEnd))[0] ?? "";
1449
+ const prop = first.split(":")[0].replace(/#.*$/, "").trim();
1450
+ const type = prop === "if" || prop === "elseif" || prop === "else" ? prop : "other";
1451
+ templates.push({ valueStart, depth, type });
1452
+ }
1453
+ }
1454
+ depth++;
1455
+ }
1456
+ return templates;
1457
+ }
1458
+ function isNegatedByElseChain(templates, valueStart) {
1459
+ const index = templates.findIndex((t) => t.valueStart === valueStart);
1460
+ if (index === -1) return false;
1461
+ const selfDepth = templates[index].depth;
1462
+ for (let i = index + 1; i < templates.length; i++) {
1463
+ const next = templates[i];
1464
+ if (next.depth > selfDepth) continue;
1465
+ if (next.depth < selfDepth) return false;
1466
+ if (next.type === "elseif" || next.type === "else") return true;
1467
+ if (next.type === "if") return false;
1468
+ }
1469
+ return false;
1470
+ }
1471
+ function getExpectedType(property, isNegatedIf) {
1356
1472
  const prop = property.replace(/#.*$/, "");
1357
1473
  if (prop === "for") {
1358
1474
  return { label: "for", expected: "array", severity: "error" };
1359
1475
  }
1360
1476
  if (prop === "if" || prop === "elseif") {
1477
+ if (!isNegatedIf()) return null;
1361
1478
  return { label: prop, expected: "boolean", severity: "warning" };
1362
1479
  }
1363
1480
  if (prop.startsWith("class.")) {
@@ -1696,20 +1813,18 @@ function findAllCommentBindings(html, commentTextPrefix = "wcs-text") {
1696
1813
  return results;
1697
1814
  }
1698
1815
  function isInsideTag(html, offset, tagName) {
1699
- const openRegex = new RegExp(`<${tagName}[\\s>]`, "gi");
1700
- const closeRegex = new RegExp(`</${tagName}>`, "gi");
1701
- let lastOpenEnd = -1;
1702
- let lastCloseEnd = -1;
1816
+ const tagRegex = new RegExp(`<(/?)${tagName}[\\s>]`, "gi");
1817
+ let depth = 0;
1703
1818
  let match;
1704
- while ((match = openRegex.exec(html)) !== null) {
1705
- if (match.index > offset) break;
1706
- lastOpenEnd = match.index;
1707
- }
1708
- while ((match = closeRegex.exec(html)) !== null) {
1819
+ while ((match = tagRegex.exec(html)) !== null) {
1709
1820
  if (match.index > offset) break;
1710
- lastCloseEnd = match.index;
1821
+ if (match[1]) {
1822
+ depth = Math.max(0, depth - 1);
1823
+ } else {
1824
+ depth++;
1825
+ }
1711
1826
  }
1712
- return lastOpenEnd > lastCloseEnd;
1827
+ return depth > 0;
1713
1828
  }
1714
1829
 
1715
1830
  // src/service/templateSyntaxValidator.ts
@@ -1779,7 +1894,7 @@ function validateTemplateSyntax(html, stateTagName, bindAttrName = "data-wcs", l
1779
1894
  if (pathPart.startsWith(".")) {
1780
1895
  const forPath = insideFor ? getInnermostForPath(html, item.matchStart, bindAttrName) : null;
1781
1896
  if (forPath && !forPath.startsWith(".")) {
1782
- const expandedPath = `${forPath}.*.${pathPart.slice(1)}`;
1897
+ const expandedPath = pathPart === "." ? `${forPath}.*` : `${forPath}.*.${pathPart.slice(1)}`;
1783
1898
  if (!isValidTemplatePath(expandedPath, pathSet, defaultPaths)) {
1784
1899
  diagnostics.push({
1785
1900
  code: WcsDiagnosticCode.BindingPathMissing,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wcstack/lint",
3
- "version": "1.25.0",
3
+ "version": "1.26.0",
4
4
  "description": "Static-contract validator CLI (wcs-validate) for wcstack data-wcs bindings and wcstack.manifest.json sidecars. Thin npm wrapper around the wcstack-intellisense validator core.",
5
5
  "type": "module",
6
6
  "bin": {