@wcstack/lint 1.25.0 → 1.27.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 +310 -75
  2. package/package.json +1 -1
package/dist/cli.cjs CHANGED
@@ -89,6 +89,13 @@ var WcsDiagnosticCode = {
89
89
  TokenUndeclared: "wcs/token-undeclared",
90
90
  TokenMisconfigured: "wcs/token-misconfigured",
91
91
  NestedAssign: "wcs/nested-assign",
92
+ // --- <wcs-state> script: $watch declaration ---
93
+ // ランタイム(watch/processWatchDeclaration.ts)が raiseError で落とす宣言。
94
+ // 越境 `@` / `$` 始まり / 空キー・空セグメント / 明らかな非関数ハンドラ。
95
+ WatchDeclarationInvalid: "wcs/watch-declaration-invalid",
96
+ // `$watch` のキーが状態定義に存在しない。バインディング側と違い黙って発火しない
97
+ // だけなので気づけない。severity は binding-path-missing に揃える(warning)。
98
+ WatchPathMissing: "wcs/watch-path-missing",
92
99
  TypeAnnotation: "wcs/type-annotation",
93
100
  TemplateSyntax: "wcs/template-syntax",
94
101
  // --- <wcs-state> script: array reactivity hazards ---
@@ -134,6 +141,8 @@ var builtinFilterMeta = {
134
141
  mul: { description: "\u4E57\u7B97", hasArgs: true, resultType: "number", acceptTypes: ["number"], minArgs: 1, maxArgs: 1, argTypes: ["number"] },
135
142
  div: { description: "\u9664\u7B97", hasArgs: true, resultType: "number", acceptTypes: ["number"], minArgs: 1, maxArgs: 1, argTypes: ["number"] },
136
143
  mod: { description: "\u5270\u4F59", hasArgs: true, resultType: "number", acceptTypes: ["number"], minArgs: 1, maxArgs: 1, argTypes: ["number"] },
144
+ abs: { description: "\u7D76\u5BFE\u5024", hasArgs: false, resultType: "number", acceptTypes: ["number"], minArgs: 0, maxArgs: 0 },
145
+ clamp: { description: "\u7BC4\u56F2\u5185\u306B\u4E38\u3081\u308B (min,max)", hasArgs: true, resultType: "number", acceptTypes: ["number"], minArgs: 2, maxArgs: 2, argTypes: ["number", "number"] },
137
146
  // 数値フォーマット
138
147
  fix: { description: "\u56FA\u5B9A\u5C0F\u6570\u70B9\u8868\u8A18", hasArgs: true, resultType: "string", acceptTypes: ["number"], minArgs: 0, maxArgs: 1, argTypes: ["number"] },
139
148
  locale: { description: "\u30ED\u30B1\u30FC\u30EB\u5F62\u5F0F\u3067\u6570\u5024\u30D5\u30A9\u30FC\u30DE\u30C3\u30C8", hasArgs: true, resultType: "string", acceptTypes: ["number"], minArgs: 0, maxArgs: 1, argTypes: ["string"] },
@@ -147,6 +156,8 @@ var builtinFilterMeta = {
147
156
  pad: { description: "\u30D1\u30C7\u30A3\u30F3\u30B0 (length[,char])", hasArgs: true, resultType: "string", acceptTypes: ["string"], minArgs: 1, maxArgs: 2, argTypes: ["number", "string"] },
148
157
  rep: { description: "\u7E70\u308A\u8FD4\u3057 (count)", hasArgs: true, resultType: "string", acceptTypes: ["string"], minArgs: 1, maxArgs: 1, argTypes: ["number"] },
149
158
  rev: { description: "\u6587\u5B57\u9806\u3092\u53CD\u8EE2", hasArgs: false, resultType: "string", acceptTypes: ["string"], minArgs: 0, maxArgs: 0 },
159
+ truncate: { description: "\u5207\u308A\u8A70\u3081\u3066\u7701\u7565\u8A18\u53F7 (length[,suffix])", hasArgs: true, resultType: "string", acceptTypes: ["string"], minArgs: 1, maxArgs: 2, argTypes: ["number", "string"] },
160
+ join: { description: "\u914D\u5217\u3092\u9023\u7D50 ([separator])", hasArgs: true, resultType: "string", acceptTypes: ["array"], minArgs: 0, maxArgs: 1, argTypes: ["string"] },
150
161
  // 数値パース・丸め
151
162
  int: { description: "\u6574\u6570\u306B\u30D1\u30FC\u30B9", hasArgs: false, resultType: "number", acceptTypes: ["string", "number"], minArgs: 0, maxArgs: 0 },
152
163
  float: { description: "\u6D6E\u52D5\u5C0F\u6570\u70B9\u6570\u306B\u30D1\u30FC\u30B9", hasArgs: false, resultType: "number", acceptTypes: ["string", "number"], minArgs: 0, maxArgs: 0 },
@@ -154,11 +165,15 @@ var builtinFilterMeta = {
154
165
  floor: { description: "\u5207\u308A\u4E0B\u3052", hasArgs: true, resultType: "number", acceptTypes: ["number"], minArgs: 0, maxArgs: 1, argTypes: ["number"] },
155
166
  ceil: { description: "\u5207\u308A\u4E0A\u3052", hasArgs: true, resultType: "number", acceptTypes: ["number"], minArgs: 0, maxArgs: 1, argTypes: ["number"] },
156
167
  percent: { description: "\u30D1\u30FC\u30BB\u30F3\u30C6\u30FC\u30B8\u5F62\u5F0F", hasArgs: true, resultType: "string", acceptTypes: ["number"], minArgs: 0, maxArgs: 1, argTypes: ["number"] },
168
+ // number だけでなく string も受ける。実用チェーンは fix / percent の後ろに繋がり、
169
+ // それらは既に string を返すため(builtinFilters.ts の unit を参照)
170
+ unit: { description: "\u5358\u4F4D\uFF08\u63A5\u5C3E\u8F9E\uFF09\u3092\u4ED8\u52A0", hasArgs: true, resultType: "string", acceptTypes: ["number", "string"], minArgs: 1, maxArgs: 1, argTypes: ["string"] },
157
171
  // 日付・時刻
158
172
  date: { description: "\u30ED\u30B1\u30FC\u30EB\u5F62\u5F0F\u306E\u65E5\u4ED8", hasArgs: false, resultType: "string", acceptTypes: "any", minArgs: 0, maxArgs: 0 },
159
173
  time: { description: "\u30ED\u30B1\u30FC\u30EB\u5F62\u5F0F\u306E\u6642\u523B", hasArgs: false, resultType: "string", acceptTypes: "any", minArgs: 0, maxArgs: 0 },
160
174
  datetime: { description: "\u30ED\u30B1\u30FC\u30EB\u5F62\u5F0F\u306E\u65E5\u6642", hasArgs: false, resultType: "string", acceptTypes: "any", minArgs: 0, maxArgs: 0 },
161
175
  ymd: { description: "YYYY-MM-DD \u5F62\u5F0F", hasArgs: true, resultType: "string", acceptTypes: "any", minArgs: 0, maxArgs: 1, argTypes: ["string"] },
176
+ hms: { description: "HH:MM:SS \u5F62\u5F0F", hasArgs: true, resultType: "string", acceptTypes: "any", minArgs: 0, maxArgs: 1, argTypes: ["string"] },
162
177
  // 真偽値・変換
163
178
  falsy: { description: "\u507D\u5024\u304B\u5224\u5B9A", hasArgs: false, resultType: "boolean", acceptTypes: "any", minArgs: 0, maxArgs: 0 },
164
179
  truthy: { description: "\u771F\u5024\u304B\u5224\u5B9A", hasArgs: false, resultType: "boolean", acceptTypes: "any", minArgs: 0, maxArgs: 0 },
@@ -418,15 +433,18 @@ function extractAttribute(tagContent, attrName) {
418
433
  var RESERVED_STREAMS_KEY = "$streams";
419
434
  var RESERVED_COMMAND_TOKENS_KEY = "$commandTokens";
420
435
  var RESERVED_EVENT_TOKENS_KEY = "$eventTokens";
436
+ var RESERVED_LIST_KEYS_KEY = "$listKeys";
437
+ var RESERVED_WATCH_KEY = "$watch";
421
438
  function analyzeStatePaths(scriptContent, stateName = "default") {
422
439
  const objectContent = extractDefaultExportObject(scriptContent);
423
440
  if (!objectContent) return [];
424
441
  const paths = [];
425
442
  const topLevelProps = parseTopLevelProperties(objectContent);
426
443
  const pendingStreamValues = [];
444
+ const pendingListKeys = [];
427
445
  for (const prop of topLevelProps) {
428
446
  if (prop.name.startsWith("$")) {
429
- collectReservedKeyPaths(prop, paths, pendingStreamValues, stateName);
447
+ collectReservedKeyPaths(prop, paths, pendingStreamValues, pendingListKeys, stateName);
430
448
  continue;
431
449
  }
432
450
  if (prop.kind === "method") {
@@ -434,7 +452,9 @@ function analyzeStatePaths(scriptContent, stateName = "default") {
434
452
  continue;
435
453
  }
436
454
  if (prop.kind === "getter") {
437
- paths.push({ path: prop.name, kind: "computed", stateName });
455
+ if (!paths.some((p) => p.stateName === stateName && p.path === prop.name)) {
456
+ paths.push({ path: prop.name, kind: "computed", stateName });
457
+ }
438
458
  continue;
439
459
  }
440
460
  pushDataPropertyPaths(prop, paths, stateName);
@@ -443,9 +463,42 @@ function analyzeStatePaths(scriptContent, stateName = "default") {
443
463
  if (paths.some((p) => p.stateName === stateName && p.path === streamValue.name)) continue;
444
464
  pushDataPropertyPaths(streamValue, paths, stateName);
445
465
  }
466
+ for (const listKeyEntry of pendingListKeys) {
467
+ pushListKeyPaths(listKeyEntry, paths, stateName);
468
+ }
446
469
  return paths;
447
470
  }
448
- function collectReservedKeyPaths(prop, paths, pendingStreamValues, stateName) {
471
+ function analyzeWatchEntries(scriptContent) {
472
+ const root = locateDefaultExportObject(scriptContent);
473
+ if (!root) return [];
474
+ const watchProp = parseTopLevelProperties(root.content).find((p) => p.name === RESERVED_WATCH_KEY);
475
+ if (!watchProp || watchProp.kind !== "data" || !watchProp.value || !isObjectLiteral(watchProp.value) || watchProp.valueStart === void 0) {
476
+ return [];
477
+ }
478
+ const leading = watchProp.value.length - watchProp.value.trimStart().length;
479
+ const innerStart = root.start + watchProp.valueStart + leading + 1;
480
+ const entries = [];
481
+ for (const entry of parseTopLevelProperties(extractObjectContent(watchProp.value))) {
482
+ if (entry.nameStart === void 0 || entry.nameEnd === void 0) continue;
483
+ entries.push({
484
+ key: entry.name,
485
+ start: innerStart + entry.nameStart,
486
+ end: innerStart + entry.nameEnd,
487
+ // メソッド短縮記法は関数。data は値リテラルの形で判定し、識別子参照は疑わない。
488
+ definitelyNotFunction: entry.kind === "data" && isNonFunctionLiteral(entry.value)
489
+ });
490
+ }
491
+ return entries;
492
+ }
493
+ function isNonFunctionLiteral(value) {
494
+ if (value === void 0) return false;
495
+ const trimmed = value.trim();
496
+ if (trimmed.length === 0) return false;
497
+ const scan = maskCommentsAndStrings(trimmed);
498
+ if (/^(?:async\s+)?function\b/.test(trimmed) || scan.includes("=>")) return false;
499
+ return /^["'`]/.test(trimmed) || /^-?\d/.test(trimmed) || /^(?:true|false|null|undefined)\b/.test(trimmed) || trimmed.startsWith("[") || trimmed.startsWith("{");
500
+ }
501
+ function collectReservedKeyPaths(prop, paths, pendingStreamValues, pendingListKeys, stateName) {
449
502
  if (prop.name === RESERVED_STREAMS_KEY && prop.kind === "data" && prop.value && isObjectLiteral(prop.value)) {
450
503
  const entries = parseTopLevelProperties(extractObjectContent(prop.value));
451
504
  for (const entry of entries) {
@@ -474,6 +527,36 @@ function collectReservedKeyPaths(prop, paths, pendingStreamValues, stateName) {
474
527
  }
475
528
  return;
476
529
  }
530
+ if (prop.name === RESERVED_LIST_KEYS_KEY && prop.kind === "data" && prop.value && isObjectLiteral(prop.value)) {
531
+ for (const entry of parseTopLevelProperties(extractObjectContent(prop.value))) {
532
+ if (entry.kind !== "data") continue;
533
+ pendingListKeys.push(entry);
534
+ }
535
+ return;
536
+ }
537
+ }
538
+ function pushListKeyPaths(entry, paths, stateName) {
539
+ const listPath = entry.name;
540
+ const segments = listPath.split(".");
541
+ if (listPath.length === 0 || segments.some((s) => s.length === 0) || segments[segments.length - 1] === "*") {
542
+ return;
543
+ }
544
+ const has = (path) => paths.some((p) => p.stateName === stateName && p.path === path);
545
+ if (!has(listPath)) paths.push({ path: listPath, kind: "data", typeHint: "array", stateName });
546
+ if (!has(`${listPath}.*`)) paths.push({ path: `${listPath}.*`, kind: "list", stateName });
547
+ if (!has(`${listPath}.length`)) {
548
+ paths.push({ path: `${listPath}.length`, kind: "data", typeHint: "number", stateName });
549
+ }
550
+ const keyField = extractStringLiteralValue(entry.value);
551
+ if (keyField === null || keyField.includes(".") || keyField.includes("*")) return;
552
+ if (!has(`${listPath}.*.${keyField}`)) {
553
+ paths.push({ path: `${listPath}.*.${keyField}`, kind: "data", stateName });
554
+ }
555
+ }
556
+ function extractStringLiteralValue(value) {
557
+ if (!value) return null;
558
+ const match = value.trim().match(/^["']([^"'\\]*)["']$/);
559
+ return match && match[1].length > 0 ? match[1] : null;
477
560
  }
478
561
  function findStreamInitialProperty(entryValue) {
479
562
  const defProps = parseTopLevelProperties(extractObjectContent(entryValue));
@@ -489,46 +572,32 @@ function extractStringArrayItems(value) {
489
572
  }
490
573
  return items;
491
574
  }
575
+ var MAX_OBJECT_NEST_DEPTH = 5;
492
576
  function pushDataPropertyPaths(prop, paths, stateName) {
493
- paths.push({ path: prop.name, kind: "data", typeHint: prop.typeHint, rawInitial: prop.value?.trim(), stateName });
577
+ pushDataPropertyPathsAt(prop.name, prop, paths, stateName, 0);
578
+ }
579
+ function pushDataPropertyPathsAt(path, prop, paths, stateName, depth) {
580
+ paths.push({ path, kind: "data", typeHint: prop.typeHint, rawInitial: prop.value?.trim(), stateName });
494
581
  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 });
582
+ paths.push({ path: `${path}.*`, kind: "list", stateName });
583
+ paths.push({ path: `${path}.length`, kind: "data", typeHint: "number", stateName });
497
584
  const elementProps = extractArrayElementProperties(prop.value);
498
585
  for (const childProp of elementProps) {
499
586
  paths.push({
500
- path: `${prop.name}.*.${childProp.name}`,
587
+ path: `${path}.*.${childProp.name}`,
501
588
  kind: "data",
502
589
  typeHint: childProp.typeHint,
503
590
  stateName
504
591
  });
505
592
  }
593
+ return;
506
594
  }
507
595
  if (prop.value && isObjectLiteral(prop.value)) {
596
+ if (depth >= MAX_OBJECT_NEST_DEPTH) return;
508
597
  const childProps = parseTopLevelProperties(extractObjectContent(prop.value));
509
598
  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
- }
599
+ if (childProp.kind !== "data") continue;
600
+ pushDataPropertyPathsAt(`${path}.${childProp.name}`, childProp, paths, stateName, depth + 1);
532
601
  }
533
602
  }
534
603
  }
@@ -575,53 +644,109 @@ function inferJsonTypeHint(value) {
575
644
  if (typeof value === "object") return "object";
576
645
  return void 0;
577
646
  }
578
- function extractDefaultExportObject(script) {
579
- const match = script.match(/export\s+default\s+(?:defineState\s*\(\s*)?(\{)/);
647
+ function locateDefaultExportObject(script) {
648
+ const scan = maskCommentsAndStrings(script);
649
+ const match = scan.match(/export\s+default\s+(?:defineState\s*\(\s*)?(\{)/);
580
650
  if (!match) return null;
581
- const startIndex = script.indexOf(match[1], match.index);
582
- return extractBracedContent(script, startIndex);
651
+ const braceIndex = scan.indexOf(match[1], match.index);
652
+ return { content: extractBracedContent(script, scan, braceIndex), start: braceIndex + 1 };
653
+ }
654
+ function extractDefaultExportObject(script) {
655
+ return locateDefaultExportObject(script)?.content ?? null;
583
656
  }
584
657
  function parseTopLevelProperties(objectContent) {
585
658
  const props = [];
586
- const regex = /(?:get\s+(?:"([^"]+)"|'([^']+)'|([$\w]+))\s*\(\s*\))|(?:(?:async\s+)?([$\w]+)\s*\([^)]*\)\s*\{)|(?:(?:"([^"]+)"|'([^']+)'|([$\w]+))\s*:\s*)/g;
659
+ const scan = maskCommentsAndStrings(objectContent);
660
+ const regex = /(?:(?:get|set)\s+(?:"([^"]+)"|'([^']+)'|([$\w]+))\s*\([^)]*\)\s*\{)|(?:(?:async\s+)?(?:"([^"]+)"|'([^']+)'|([$\w]+))\s*\([^)]*\)\s*\{)|(?:(?:"([^"]+)"|'([^']+)'|([$\w]+))\s*:\s*)/gd;
587
661
  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" });
662
+ while ((match = regex.exec(scan)) !== null) {
663
+ const indices = match.indices;
664
+ let nameSpan;
665
+ const nameAt = (group) => {
666
+ const span = indices[group];
667
+ if (!span) return void 0;
668
+ nameSpan = [span[0], span[1]];
669
+ return objectContent.slice(span[0], span[1]);
670
+ };
671
+ const skipBody = () => {
672
+ const braceStart = match.index + match[0].length - 1;
673
+ const body = extractBracedContent(objectContent, scan, braceStart);
674
+ regex.lastIndex = braceStart + body.length + 2;
675
+ };
676
+ const accessorName = nameAt(1) ?? nameAt(2) ?? nameAt(3);
677
+ if (accessorName) {
678
+ props.push({ name: accessorName, kind: "getter", nameStart: nameSpan[0], nameEnd: nameSpan[1] });
679
+ skipBody();
592
680
  continue;
593
681
  }
594
- const methodName = match[4];
682
+ const methodName = nameAt(4) ?? nameAt(5) ?? nameAt(6);
595
683
  if (methodName) {
596
- 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
- }
684
+ props.push({ name: methodName, kind: "method", nameStart: nameSpan[0], nameEnd: nameSpan[1] });
685
+ skipBody();
602
686
  continue;
603
687
  }
604
- const propName = match[5] ?? match[6] ?? match[7];
688
+ const propName = nameAt(7) ?? nameAt(8) ?? nameAt(9);
605
689
  if (propName) {
606
690
  const valueStartIndex = match.index + match[0].length;
607
- const value = extractFullValue(objectContent, valueStartIndex);
691
+ const value = extractFullValue(objectContent, scan, valueStartIndex);
608
692
  const jsdocType = extractJsDocType(objectContent, match.index);
609
693
  const typeHint = jsdocType ?? inferTypeHint(value);
610
- props.push({ name: propName, kind: "data", value, typeHint });
694
+ props.push({
695
+ name: propName,
696
+ kind: "data",
697
+ value,
698
+ typeHint,
699
+ nameStart: nameSpan[0],
700
+ nameEnd: nameSpan[1],
701
+ valueStart: valueStartIndex
702
+ });
611
703
  regex.lastIndex = valueStartIndex + value.length;
612
704
  }
613
705
  }
614
706
  return props;
615
707
  }
616
- function extractFullValue(content, startIndex) {
708
+ function maskCommentsAndStrings(source) {
709
+ const out = source.split("");
710
+ const len = source.length;
711
+ const blank = (i2) => {
712
+ if (source[i2] !== "\n" && source[i2] !== "\r") out[i2] = " ";
713
+ };
714
+ let i = 0;
715
+ while (i < len) {
716
+ const ch = source[i];
717
+ if (ch === "/" && source[i + 1] === "/") {
718
+ i += 2;
719
+ while (i < len && source[i] !== "\n") blank(i++);
720
+ continue;
721
+ }
722
+ if (ch === "/" && source[i + 1] === "*") {
723
+ i += 2;
724
+ while (i < len && !(source[i] === "*" && source[i + 1] === "/")) blank(i++);
725
+ i += 2;
726
+ continue;
727
+ }
728
+ if (ch === '"' || ch === "'" || ch === "`") {
729
+ i++;
730
+ while (i < len && source[i] !== ch) {
731
+ if (source[i] === "\\") blank(i++);
732
+ if (i < len) blank(i++);
733
+ }
734
+ i++;
735
+ continue;
736
+ }
737
+ i++;
738
+ }
739
+ return out.join("");
740
+ }
741
+ function extractFullValue(content, scan, startIndex) {
617
742
  let depth = 0;
618
743
  let i = startIndex;
619
- const len = content.length;
744
+ const len = scan.length;
620
745
  let inString = null;
621
746
  while (i < len) {
622
- const ch = content[i];
747
+ const ch = scan[i];
623
748
  if (inString) {
624
- if (ch === inString && !isEscaped(content, i)) {
749
+ if (ch === inString && !isEscaped(scan, i)) {
625
750
  inString = null;
626
751
  }
627
752
  i++;
@@ -641,13 +766,13 @@ function extractFullValue(content, startIndex) {
641
766
  }
642
767
  return content.slice(startIndex, i).trim();
643
768
  }
644
- function extractBracedContent(text, openBraceIndex) {
769
+ function extractBracedContent(text, scan, openBraceIndex) {
645
770
  let depth = 0;
646
771
  let inString = null;
647
- for (let i = openBraceIndex; i < text.length; i++) {
648
- const ch = text[i];
772
+ for (let i = openBraceIndex; i < scan.length; i++) {
773
+ const ch = scan[i];
649
774
  if (inString) {
650
- if (ch === inString && !isEscaped(text, i)) {
775
+ if (ch === inString && !isEscaped(scan, i)) {
651
776
  inString = null;
652
777
  }
653
778
  continue;
@@ -673,16 +798,18 @@ function isObjectLiteral(value) {
673
798
  }
674
799
  function extractObjectContent(value) {
675
800
  const trimmed = value.trim();
676
- const start = trimmed.indexOf("{");
801
+ const scan = maskCommentsAndStrings(trimmed);
802
+ const start = scan.indexOf("{");
677
803
  if (start === -1) return "";
678
- return extractBracedContent(trimmed, start);
804
+ return extractBracedContent(trimmed, scan, start);
679
805
  }
680
806
  function extractArrayElementProperties(value) {
681
807
  const trimmed = value.trim();
682
808
  if (!trimmed.startsWith("[")) return [];
683
- const objectStart = trimmed.indexOf("{");
809
+ const scan = maskCommentsAndStrings(trimmed);
810
+ const objectStart = scan.indexOf("{");
684
811
  if (objectStart === -1) return [];
685
- const objectContent = extractBracedContent(trimmed, objectStart);
812
+ const objectContent = extractBracedContent(trimmed, scan, objectStart);
686
813
  const props = [];
687
814
  const allProps = parseTopLevelProperties(objectContent);
688
815
  for (const prop of allProps) {
@@ -904,6 +1031,11 @@ var ja = {
904
1031
  wcsTextInfo: (e) => `wcs-text \u30D0\u30A4\u30F3\u30C7\u30A3\u30F3\u30B0: ${e}`,
905
1032
  moustacheFouc: (e) => `<template> \u5916\u306E {{ }} \u69CB\u6587\u306F FOUC\uFF08\u521D\u671F\u8868\u793A\u6642\u306B\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u6587\u5B57\u5217\u304C\u898B\u3048\u308B\uFF09\u306E\u539F\u56E0\u306B\u306A\u308A\u307E\u3059\u3002<!--@@:${e}--> \u307E\u305F\u306F\u30B3\u30E1\u30F3\u30C8\u69CB\u6587\u306E\u4F7F\u7528\u3092\u691C\u8A0E\u3057\u3066\u304F\u3060\u3055\u3044\u3002`,
906
1033
  nestedAssign: (sp) => `\u30CD\u30B9\u30C8\u3055\u308C\u305F\u30D7\u30ED\u30D1\u30C6\u30A3\u3078\u306E\u4EE3\u5165\u306F\u30EA\u30A2\u30AF\u30C6\u30A3\u30D6\u66F4\u65B0\u3092\u30C8\u30EA\u30AC\u30FC\u3057\u307E\u305B\u3093\u3002this["${sp}"] \u3092\u4F7F\u7528\u3057\u3066\u304F\u3060\u3055\u3044\u3002`,
1034
+ watchKeyCrossState: (k) => `$watch \u306E\u30AD\u30FC "${k}" \u306F\u4ED6\u306E state \u3092\u6307\u3057\u3066\u3044\u307E\u3059\u3002@ \u4ED8\u304D\u306E\u8D8A\u5883 watch \u306F\u4F7F\u3048\u307E\u305B\u3093\uFF08\u81EA state \u306E\u30D1\u30B9\u306E\u307F\uFF09`,
1035
+ watchKeyReserved: (k) => `$watch \u306E\u30AD\u30FC "${k}" \u306F "$" \u3067\u59CB\u3081\u3089\u308C\u307E\u305B\u3093\uFF08\u4E88\u7D04\u540D\u524D\u7A7A\u9593\uFF09`,
1036
+ watchKeyEmptySegment: (k) => `$watch \u306E\u30AD\u30FC "${k}" \u306B\u7A7A\u306E\u30D1\u30B9\u30BB\u30B0\u30E1\u30F3\u30C8\u304C\u3042\u308A\u307E\u3059`,
1037
+ watchHandlerNotFunction: (k) => `$watch \u306E\u30A8\u30F3\u30C8\u30EA "${k}" \u306E\u5024\u306F\u95A2\u6570\u3067\u3042\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059`,
1038
+ watchPathMissing: (k) => `$watch \u306E\u30AD\u30FC "${k}" \u306F\u72B6\u614B\u5B9A\u7FA9\u306B\u5B58\u5728\u3057\u307E\u305B\u3093\uFF08\u4E00\u5EA6\u3082\u767A\u706B\u3057\u307E\u305B\u3093\uFF09`,
907
1039
  typeAnnotationIncompatible: (vt, rt) => `\u578B "${vt}" \u306F @type {${rt}} \u3068\u4E92\u63DB\u6027\u304C\u3042\u308A\u307E\u305B\u3093`,
908
1040
  arrayMutation: (m, alt) => `\u914D\u5217\u306E\u7834\u58CA\u7684\u30E1\u30BD\u30C3\u30C9 "${m}" \u306F\u30EA\u30A2\u30AF\u30C6\u30A3\u30D6\u66F4\u65B0\u3092\u30C8\u30EA\u30AC\u30FC\u3057\u307E\u305B\u3093\uFF08\u540C\u4E00\u53C2\u7167\u306E\u81EA\u5DF1\u518D\u4EE3\u5165\u3067\u3082\u8981\u7D20\u306E\u8FFD\u52A0\u30FB\u524A\u9664\u306F\u53CD\u6620\u3055\u308C\u307E\u305B\u3093\uFF09\u3002\u975E\u7834\u58CA\u30E1\u30BD\u30C3\u30C9\u3068\u518D\u4EE3\u5165\u3092\u4F7F\u7528\u3057\u3066\u304F\u3060\u3055\u3044\uFF08\u4F8B: ${alt}\uFF09\u3002`,
909
1041
  arrayIndexAssign: (sp) => `\u914D\u5217\u30A4\u30F3\u30C7\u30C3\u30AF\u30B9\u3078\u306E\u76F4\u63A5\u4EE3\u5165\u306F\u30EA\u30A2\u30AF\u30C6\u30A3\u30D6\u66F4\u65B0\u3092\u30C8\u30EA\u30AC\u30FC\u3057\u307E\u305B\u3093\u3002this["${sp}"] \u306E\u3088\u3046\u306A\u30C9\u30C3\u30C8\u30D1\u30B9\u4EE3\u5165\u3001\u307E\u305F\u306F with() \u3068\u518D\u4EE3\u5165\u3092\u4F7F\u7528\u3057\u3066\u304F\u3060\u3055\u3044\u3002`,
@@ -946,6 +1078,11 @@ var en = {
946
1078
  wcsTextInfo: (e) => `wcs-text binding: ${e}`,
947
1079
  moustacheFouc: (e) => `{{ }} outside a <template> causes FOUC (the raw template string is visible before binding). Consider the comment syntax <!--@@:${e}--> instead.`,
948
1080
  nestedAssign: (sp) => `Assigning to a nested property does not trigger a reactive update. Use this["${sp}"] instead.`,
1081
+ watchKeyCrossState: (k) => `$watch key "${k}" targets another state. Cross-state watching with @ is not supported (own paths only)`,
1082
+ watchKeyReserved: (k) => `$watch key "${k}" must not start with "$" (reserved namespace)`,
1083
+ watchKeyEmptySegment: (k) => `$watch key "${k}" has an empty path segment`,
1084
+ watchHandlerNotFunction: (k) => `The value of $watch entry "${k}" must be a function`,
1085
+ watchPathMissing: (k) => `$watch key "${k}" does not exist in the state definition (it will never fire)`,
949
1086
  typeAnnotationIncompatible: (vt, rt) => `Type "${vt}" is not compatible with @type {${rt}}`,
950
1087
  arrayMutation: (m, alt) => `Destructive array method "${m}" does not trigger a reactive update (re-assigning the same reference does not reflect added/removed elements either). Use a non-destructive method with reassignment (e.g. ${alt}).`,
951
1088
  arrayIndexAssign: (sp) => `Assigning directly to an array index does not trigger a reactive update. Use a dot-path assignment like this["${sp}"], or with() plus reassignment.`,
@@ -978,6 +1115,11 @@ function validateBindings(html, attrName, stateTagName = "wcs-state", locale) {
978
1115
  pathsByState.set(p.stateName, list);
979
1116
  }
980
1117
  const attrs = findAllBindAttributes(html, attrName);
1118
+ let structuralTemplates = null;
1119
+ const getStructuralTemplates = () => {
1120
+ structuralTemplates ??= collectStructuralTemplates(html, attrName);
1121
+ return structuralTemplates;
1122
+ };
981
1123
  const filterNameSet = new Set(BUILTIN_FILTERS.map((f) => f.name));
982
1124
  for (const attr of attrs) {
983
1125
  const bindings = splitBindingExpressions(attr.value);
@@ -1063,7 +1205,7 @@ function validateBindings(html, attrName, stateTagName = "wcs-state", locale) {
1063
1205
  if (pathTrimmed.startsWith(".")) {
1064
1206
  const forPath = getInnermostForPath(html, attr.valueStart, attrName);
1065
1207
  if (forPath && !forPath.startsWith(".")) {
1066
- checkPath = `${forPath}.*.${pathTrimmed.slice(1)}`;
1208
+ checkPath = pathTrimmed === "." ? `${forPath}.*` : `${forPath}.*.${pathTrimmed.slice(1)}`;
1067
1209
  } else {
1068
1210
  checkPath = "";
1069
1211
  }
@@ -1172,7 +1314,10 @@ function validateBindings(html, attrName, stateTagName = "wcs-state", locale) {
1172
1314
  if (pathTrimmed && !pathTrimmed.startsWith(".") && !isLiteral(pathTrimmed)) {
1173
1315
  const resultType = resolveResultType(pathTrimmed, parsed.filters, scopedPaths);
1174
1316
  if (resultType !== null) {
1175
- const typeReq = getExpectedType(parsed.property);
1317
+ const typeReq = getExpectedType(
1318
+ parsed.property,
1319
+ () => isNegatedByElseChain(getStructuralTemplates(), attr.valueStart)
1320
+ );
1176
1321
  if (typeReq && resultType !== typeReq.expected) {
1177
1322
  const pathOffset = binding.indexOf(parsed.path);
1178
1323
  const pathStart = bindingStart + pathOffset;
@@ -1352,12 +1497,54 @@ function validatePathExistence(checkPath, displayPath, scopedPaths, scopedPathSe
1352
1497
  }
1353
1498
  return null;
1354
1499
  }
1355
- function getExpectedType(property) {
1500
+ function collectStructuralTemplates(html, attrName) {
1501
+ const escaped = attrName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1502
+ const attrRegex = new RegExp(`${escaped}\\s*=\\s*(["'])`, "i");
1503
+ const tagRegex = /<template(?:\s[^>]*)?>|<\/template\s*>/gi;
1504
+ const templates = [];
1505
+ let depth = 0;
1506
+ let match;
1507
+ while ((match = tagRegex.exec(html)) !== null) {
1508
+ if (match[0].startsWith("</")) {
1509
+ depth = Math.max(0, depth - 1);
1510
+ continue;
1511
+ }
1512
+ const attrMatch = attrRegex.exec(match[0]);
1513
+ if (attrMatch) {
1514
+ const quote = attrMatch[1];
1515
+ const valueStart = match.index + attrMatch.index + attrMatch[0].length;
1516
+ const valueEnd = html.indexOf(quote, valueStart);
1517
+ if (valueEnd !== -1) {
1518
+ const first = splitBindingExpressions(html.slice(valueStart, valueEnd))[0] ?? "";
1519
+ const prop = first.split(":")[0].replace(/#.*$/, "").trim();
1520
+ const type = prop === "if" || prop === "elseif" || prop === "else" ? prop : "other";
1521
+ templates.push({ valueStart, depth, type });
1522
+ }
1523
+ }
1524
+ depth++;
1525
+ }
1526
+ return templates;
1527
+ }
1528
+ function isNegatedByElseChain(templates, valueStart) {
1529
+ const index = templates.findIndex((t) => t.valueStart === valueStart);
1530
+ if (index === -1) return false;
1531
+ const selfDepth = templates[index].depth;
1532
+ for (let i = index + 1; i < templates.length; i++) {
1533
+ const next = templates[i];
1534
+ if (next.depth > selfDepth) continue;
1535
+ if (next.depth < selfDepth) return false;
1536
+ if (next.type === "elseif" || next.type === "else") return true;
1537
+ if (next.type === "if") return false;
1538
+ }
1539
+ return false;
1540
+ }
1541
+ function getExpectedType(property, isNegatedIf) {
1356
1542
  const prop = property.replace(/#.*$/, "");
1357
1543
  if (prop === "for") {
1358
1544
  return { label: "for", expected: "array", severity: "error" };
1359
1545
  }
1360
1546
  if (prop === "if" || prop === "elseif") {
1547
+ if (!isNegatedIf()) return null;
1361
1548
  return { label: prop, expected: "boolean", severity: "warning" };
1362
1549
  }
1363
1550
  if (prop.startsWith("class.")) {
@@ -1696,20 +1883,18 @@ function findAllCommentBindings(html, commentTextPrefix = "wcs-text") {
1696
1883
  return results;
1697
1884
  }
1698
1885
  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;
1886
+ const tagRegex = new RegExp(`<(/?)${tagName}[\\s>]`, "gi");
1887
+ let depth = 0;
1703
1888
  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) {
1889
+ while ((match = tagRegex.exec(html)) !== null) {
1709
1890
  if (match.index > offset) break;
1710
- lastCloseEnd = match.index;
1891
+ if (match[1]) {
1892
+ depth = Math.max(0, depth - 1);
1893
+ } else {
1894
+ depth++;
1895
+ }
1711
1896
  }
1712
- return lastOpenEnd > lastCloseEnd;
1897
+ return depth > 0;
1713
1898
  }
1714
1899
 
1715
1900
  // src/service/templateSyntaxValidator.ts
@@ -1779,7 +1964,7 @@ function validateTemplateSyntax(html, stateTagName, bindAttrName = "data-wcs", l
1779
1964
  if (pathPart.startsWith(".")) {
1780
1965
  const forPath = insideFor ? getInnermostForPath(html, item.matchStart, bindAttrName) : null;
1781
1966
  if (forPath && !forPath.startsWith(".")) {
1782
- const expandedPath = `${forPath}.*.${pathPart.slice(1)}`;
1967
+ const expandedPath = pathPart === "." ? `${forPath}.*` : `${forPath}.*.${pathPart.slice(1)}`;
1783
1968
  if (!isValidTemplatePath(expandedPath, pathSet, defaultPaths)) {
1784
1969
  diagnostics.push({
1785
1970
  code: WcsDiagnosticCode.BindingPathMissing,
@@ -2963,6 +3148,55 @@ function blankJsComments(code) {
2963
3148
  return code.replace(/\/\*[\s\S]*?\*\//g, (m) => " ".repeat(m.length)).replace(/(^|[^:])\/\/[^\n]*/g, (m, pre) => pre + " ".repeat(m.length - pre.length));
2964
3149
  }
2965
3150
 
3151
+ // src/service/watchDeclarationValidator.ts
3152
+ var STATE_NAME_SEPARATOR = "@";
3153
+ function validateWatchDeclarations(html, stateTagName = "wcs-state", locale) {
3154
+ const msgs = getMessages(locale);
3155
+ const out = [];
3156
+ for (const block of parseWcsScriptBlocks(html, stateTagName)) {
3157
+ const entries = analyzeWatchEntries(block.content);
3158
+ if (entries.length === 0) continue;
3159
+ const paths = analyzeStatePaths(block.content, block.stateName);
3160
+ const pathSet = new Set(paths.map((p) => p.path));
3161
+ for (const entry of entries) {
3162
+ const diagnostic = validateEntry(entry, pathSet, msgs);
3163
+ if (diagnostic === null) continue;
3164
+ out.push({
3165
+ code: diagnostic.code,
3166
+ start: block.contentStart + entry.start,
3167
+ end: block.contentStart + entry.end,
3168
+ message: diagnostic.message,
3169
+ severity: diagnostic.severity
3170
+ });
3171
+ }
3172
+ }
3173
+ return out;
3174
+ }
3175
+ function validateEntry(entry, pathSet, msgs) {
3176
+ const { key } = entry;
3177
+ const invalid = (message) => ({ code: WcsDiagnosticCode.WatchDeclarationInvalid, message, severity: "error" });
3178
+ if (key.includes(STATE_NAME_SEPARATOR)) {
3179
+ return invalid(msgs.watchKeyCrossState(key));
3180
+ }
3181
+ if (key.startsWith("$")) {
3182
+ return invalid(msgs.watchKeyReserved(key));
3183
+ }
3184
+ if (key.split(".").some((segment) => segment.length === 0)) {
3185
+ return invalid(msgs.watchKeyEmptySegment(key));
3186
+ }
3187
+ if (entry.definitelyNotFunction) {
3188
+ return invalid(msgs.watchHandlerNotFunction(key));
3189
+ }
3190
+ if (pathSet.size > 0 && !pathSet.has(key)) {
3191
+ return {
3192
+ code: WcsDiagnosticCode.WatchPathMissing,
3193
+ message: msgs.watchPathMissing(key),
3194
+ severity: "warning"
3195
+ };
3196
+ }
3197
+ return null;
3198
+ }
3199
+
2966
3200
  // src/core/validateDocument.ts
2967
3201
  function validateDocument(text, options = {}) {
2968
3202
  const bindAttribute = options.bindAttribute ?? "data-wcs";
@@ -2974,6 +3208,7 @@ function validateDocument(text, options = {}) {
2974
3208
  out.push(...validateIoNodes(text, bindAttribute, stateTagName, locale));
2975
3209
  out.push(...validateDocumentEnv(text, locale));
2976
3210
  out.push(...validateArrayMutations(text, stateTagName, locale));
3211
+ out.push(...validateWatchDeclarations(text, stateTagName, locale));
2977
3212
  for (const d of validateStateTypes(text, stateTagName, locale)) {
2978
3213
  out.push({ code: WcsDiagnosticCode.TypeAnnotation, start: d.start, end: d.end, message: d.message, severity: d.severity });
2979
3214
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wcstack/lint",
3
- "version": "1.25.0",
3
+ "version": "1.27.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": {