@nudojs/parser 1.5.2 → 1.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -118,10 +118,20 @@ type MockDirective = {
118
118
  name: string;
119
119
  expression?: string;
120
120
  fromPath?: string;
121
+ /**
122
+ * 内联箭头 mock 的扫描结果:形参(源码形态)+ 体 + async 标志。
123
+ * - `params`:1:1 于形参个数的**源码形态**(签名用;默认形参保留默认值、
124
+ * rest 保留 `...`、解构形参保留 `{ a, b }`)。
125
+ * - `paramPatterns`:形参 AST(原样)——消费面可自行解构绑定。
126
+ * - `boundNames`:每个形参**绑定的名字**(作用域用;解构列出顶层绑定名)。
127
+ * - `async`:`async (x) => …` 的 async 语义(Bug 857)。
128
+ */
121
129
  arrowFn?: {
122
130
  params: string[];
123
131
  body: Node;
124
132
  paramPatterns: Node[];
133
+ async?: boolean;
134
+ boundNames?: string[][];
125
135
  };
126
136
  sinonExpr?: SinonExpression;
127
137
  nudoMock?: MockHelper;
@@ -171,9 +181,27 @@ type FunctionWithDirectives = {
171
181
  directives: Directive[];
172
182
  };
173
183
 
184
+ /**
185
+ * 指令文法诊断。
186
+ *
187
+ * `at`:**出错指令在源码中的位置**(行 1-based / 列 0-based)。
188
+ *
189
+ * Bug 848:去重身份此前只有 `code + message`(**无位置**)⇒ 同文件两处
190
+ * **逐字相同**的畸形指令**塌缩成一条** ⇒ 第二处**完全不可见**(用户修好
191
+ * 第一处后以为已经修完)。身份必须含位置:同一处同一问题仍去重(extract
192
+ * + 再解析共域),**不同位置**的同文案问题各报一条。
193
+ *
194
+ * 不传 `at` 的调用点保持旧身份(`code + message`)——兼容未升级的调用方,
195
+ * 但**同文案塌缩**在这些族上仍存在(见交付报告的「需收口」清单)。
196
+ */
174
197
  type DirectiveDiag = {
175
198
  code: string;
176
199
  message: string;
200
+ /** 出错指令的位置(行 1-based / 列 0-based);缺省 ⇒ 旧身份(无位置) */
201
+ at?: {
202
+ line: number;
203
+ column: number;
204
+ };
177
205
  };
178
206
  /**
179
207
  * 在指定诊断数组上执行 fn:fn 内所有指令文法诊断(含不传 diags 的 extract
package/dist/index.js CHANGED
@@ -188,7 +188,9 @@ function runWithDirectiveDiags(diags, fn) {
188
188
  }
189
189
  function emitDirectiveDiag(d) {
190
190
  if (!activeDiagSink) return;
191
- const key = `${d.code}\0${d.message}`;
191
+ const at = d.at;
192
+ const posKey = at ? `${at.line}:${at.column}` : "";
193
+ const key = `${d.code}\0${d.message}\0${posKey}`;
192
194
  if (activeDiagSink.seen.has(key)) return;
193
195
  activeDiagSink.seen.add(key);
194
196
  activeDiagSink.list.push(d);
@@ -202,6 +204,8 @@ import {
202
204
  numLit,
203
205
  strLit,
204
206
  boolLit,
207
+ bigintLit,
208
+ litValue,
205
209
  execNudoModule,
206
210
  isNudoConstraint,
207
211
  constraintToEntryAbs,
@@ -212,6 +216,7 @@ import {
212
216
  import { absLit, DIAG } from "@nudojs/core/internal";
213
217
 
214
218
  // src/directives/expr-scan.ts
219
+ import { formalParamsFromNodes } from "@nudojs/core";
215
220
  function scanWithStrings(s, onChar, onStructural) {
216
221
  let depth = 0;
217
222
  let inString = null;
@@ -307,9 +312,32 @@ function findTopLevelArrow(s) {
307
312
  }
308
313
  return -1;
309
314
  }
315
+ var ARROW_WRAPPER_PREFIX = "const __mock = ";
316
+ function paramSource(param, expr) {
317
+ const start = param.start;
318
+ const end = param.end;
319
+ if (typeof start === "number" && typeof end === "number") {
320
+ const from = start - ARROW_WRAPPER_PREFIX.length;
321
+ const to = end - ARROW_WRAPPER_PREFIX.length;
322
+ if (from >= 0 && to <= expr.length && from < to) return expr.slice(from, to);
323
+ }
324
+ return "";
325
+ }
326
+ function boundNamesOf(formal) {
327
+ if (!formal) return [];
328
+ switch (formal.kind) {
329
+ case "id":
330
+ case "default":
331
+ return [formal.name];
332
+ case "rest":
333
+ return [formal.name];
334
+ case "pattern":
335
+ return [...formal.bound];
336
+ }
337
+ }
310
338
  function parseArrowFunctionExpr(expr) {
311
339
  try {
312
- const wrapped = `const __mock = ${expr};`;
340
+ const wrapped = `${ARROW_WRAPPER_PREFIX}${expr};`;
313
341
  const ast = parse(wrapped);
314
342
  const decl = ast.program.body[0];
315
343
  if (decl.type !== "VariableDeclaration") return null;
@@ -317,27 +345,68 @@ function parseArrowFunctionExpr(expr) {
317
345
  if (!init || init.type !== "ArrowFunctionExpression" && init.type !== "FunctionExpression") {
318
346
  return null;
319
347
  }
348
+ const rawParams = init.params.filter(
349
+ (p) => !(p.type === "Identifier" && p.name === "this")
350
+ );
351
+ const formals = formalParamsFromNodes(rawParams);
320
352
  const params = [];
353
+ const boundNames = [];
321
354
  const paramPatterns = [];
322
- for (let i = 0; i < init.params.length; i++) {
323
- const param = init.params[i];
324
- if (param.type === "Identifier") {
325
- params.push(param.name);
326
- paramPatterns.push(param);
327
- } else if (param.type === "RestElement" && param.argument.type === "Identifier") {
328
- params.push(`...${param.argument.name}`);
329
- paramPatterns.push(param);
330
- } else if (param.type === "ArrayPattern" || param.type === "ObjectPattern") {
331
- params.push(`__destructured_${i}`);
332
- paramPatterns.push(param);
333
- }
355
+ for (let i = 0; i < rawParams.length; i++) {
356
+ const param = rawParams[i];
357
+ paramPatterns.push(param);
358
+ params.push(paramSource(param, expr));
359
+ boundNames.push(boundNamesOf(formals[i]));
334
360
  }
335
- return { params, body: init.body, paramPatterns };
361
+ return {
362
+ params,
363
+ body: init.body,
364
+ paramPatterns,
365
+ async: init.async === true,
366
+ boundNames
367
+ };
336
368
  } catch {
337
369
  return null;
338
370
  }
339
371
  }
340
372
 
373
+ // src/directives/string-literal.ts
374
+ function findClosingQuote(s, quote) {
375
+ for (let i = 1; i < s.length; i++) {
376
+ const c = s[i];
377
+ if (c === "\\") {
378
+ i += 1;
379
+ continue;
380
+ }
381
+ if (c === quote) return i;
382
+ }
383
+ return -1;
384
+ }
385
+ function scanStringLiteral(text) {
386
+ const s = text.trim();
387
+ const quote = s[0];
388
+ if (quote !== '"' && quote !== "'") {
389
+ return { kind: "not-literal", reason: "not-quoted" };
390
+ }
391
+ const close = findClosingQuote(s, quote);
392
+ if (close === -1) return { kind: "not-literal", reason: "unterminated" };
393
+ if (close !== s.length - 1) return { kind: "not-literal", reason: "trailing-text" };
394
+ try {
395
+ const ast = parse(`(${s});`);
396
+ const stmt = ast.program.body[0];
397
+ if (!stmt || stmt.type !== "ExpressionStatement") {
398
+ return { kind: "not-literal", reason: "not-parseable" };
399
+ }
400
+ const expr = stmt.expression;
401
+ if (expr.type !== "StringLiteral") {
402
+ return { kind: "not-literal", reason: "not-parseable" };
403
+ }
404
+ return { kind: "literal", value: expr.value };
405
+ } catch {
406
+ return { kind: "not-literal", reason: "not-parseable" };
407
+ }
408
+ }
409
+
341
410
  // src/directives/case-arg.ts
342
411
  function absExact(shape) {
343
412
  return makeAbs(shape, void 0, void 0, "exact");
@@ -482,7 +551,13 @@ function constraintToCaseArgAbs(c, depth) {
482
551
  if (c.fields) {
483
552
  const slots = {};
484
553
  for (const [key, field] of Object.entries(c.fields)) {
485
- slots[key] = { value: constraintToCaseArgAbs(field.constraint, depth + 1) };
554
+ slots[key] = {
555
+ value: constraintToCaseArgAbs(field.constraint, depth + 1),
556
+ // 可选标记必须投影进槽(与 core 的 constraintToEntryAbs 同口径):
557
+ // 丢掉它 ⇒ 可选槽变必填 ⇒ 缺该字段的**合法**实际值被判红,
558
+ // 且报出的 expected 文案不带 `?`(与契约面同文法自相矛盾)。
559
+ ...field.optional || field.constraint.isOptional ? { optional: true } : {}
560
+ };
486
561
  }
487
562
  return absExact({ k: "obj", slots });
488
563
  }
@@ -536,23 +611,70 @@ function parseLiteralOrStructure(expr, depth) {
536
611
  return absFunction(fnExpr.params, { body: fnExpr.body });
537
612
  }
538
613
  }
539
- if (/^-?\d+(\.\d+)?$/.test(s)) return absLit(Number(s));
540
- if (s.startsWith('"') && s.endsWith('"') || s.startsWith("'") && s.endsWith("'")) {
541
- return absLit(s.slice(1, -1));
614
+ const numAbs = parseNumberLiteralExpr(s);
615
+ if (numAbs) return numAbs;
616
+ if (s.startsWith('"') || s.startsWith("'")) {
617
+ const scan = scanStringLiteral(s);
618
+ if (scan.kind === "literal") return absLit(scan.value);
619
+ return absUnknown();
620
+ }
621
+ if (s.startsWith("{") || s.startsWith("[")) {
622
+ if (literalNestingDepth(s) > MAX_CASE_ARG_DEPTH) return depthCapDiag(s);
623
+ const kind = structureLiteralKind(s);
624
+ if (kind === "obj") {
625
+ return parseObjectLiteral(s.slice(1, -1).trim(), depth);
626
+ }
627
+ if (kind === "arr") {
628
+ const content = s.slice(1, -1).trim();
629
+ if (!content) return absExact({ k: "tuple", elements: [] });
630
+ const elements = splitTopLevelArgs(content);
631
+ return absExact({
632
+ k: "tuple",
633
+ elements: elements.map((e) => parseCaseArgExpr(e, depth + 1))
634
+ });
635
+ }
636
+ return absUnknown();
542
637
  }
543
- if (s.startsWith("{") && s.endsWith("}")) {
544
- return parseObjectLiteral(s.slice(1, -1).trim(), depth);
638
+ const templateAbs = parseTemplateLiteralExpr(s);
639
+ if (templateAbs) return templateAbs;
640
+ return absUnknown();
641
+ }
642
+ function literalNestingDepth(s) {
643
+ let depth = 0;
644
+ let max = 0;
645
+ let inString = null;
646
+ for (let i = 0; i < s.length; i++) {
647
+ const ch = s[i];
648
+ if (inString) {
649
+ if (ch === inString) inString = null;
650
+ continue;
651
+ }
652
+ if (ch === '"' || ch === "'") {
653
+ inString = ch;
654
+ continue;
655
+ }
656
+ if (ch === "(" || ch === "[" || ch === "{") {
657
+ depth++;
658
+ if (depth > max) max = depth;
659
+ } else if (ch === ")" || ch === "]" || ch === "}") {
660
+ depth--;
661
+ }
545
662
  }
546
- if (s.startsWith("[") && s.endsWith("]")) {
547
- const content = s.slice(1, -1).trim();
548
- if (!content) return absExact({ k: "tuple", elements: [] });
549
- const elements = splitTopLevelArgs(content);
550
- return absExact({
551
- k: "tuple",
552
- elements: elements.map((e) => parseCaseArgExpr(e, depth + 1))
553
- });
663
+ return max;
664
+ }
665
+ function structureLiteralKind(s) {
666
+ try {
667
+ const ast = parse(`(${s});`);
668
+ if (ast.program.body.length !== 1) return void 0;
669
+ const stmt = ast.program.body[0];
670
+ if (stmt.type !== "ExpressionStatement") return void 0;
671
+ const expr = stmt.expression;
672
+ if (expr.type === "ObjectExpression") return "obj";
673
+ if (expr.type === "ArrayExpression") return "arr";
674
+ return void 0;
675
+ } catch {
676
+ return void 0;
554
677
  }
555
- return absUnknown();
556
678
  }
557
679
  function parseObjectLiteral(content, depth) {
558
680
  if (!content) return absExact({ k: "obj", slots: {} });
@@ -560,7 +682,13 @@ function parseObjectLiteral(content, depth) {
560
682
  const slots = {};
561
683
  for (const entry of entries) {
562
684
  const colonIdx = findTopLevelColon(entry);
563
- if (colonIdx === -1) continue;
685
+ if (colonIdx === -1) {
686
+ emitDirectiveDiag({
687
+ code: DIAG.directiveSyntax,
688
+ message: `Unrecognized property in object literal: ${entry.slice(0, 60)} (getter / method shorthand / shorthand property are not supported in directive payloads)`
689
+ });
690
+ return absUnknown();
691
+ }
564
692
  const key = entry.slice(0, colonIdx).trim().replace(/^["']|["']$/g, "");
565
693
  const val = entry.slice(colonIdx + 1).trim();
566
694
  slots[key] = { value: parseCaseArgExpr(val, depth + 1) };
@@ -577,13 +705,93 @@ function parseAbsExpr(expr) {
577
705
  function parseAbsExprDiag(expr, context) {
578
706
  const abs = parseCaseArgExpr(expr);
579
707
  if (abs.shape.k === "unknown" && !abs.term && !isExplicitUnknownExpr(expr)) {
580
- emitDirectiveDiag({
581
- code: DIAG.directiveSyntax,
582
- message: `Unrecognized type expression in ${context}: ${expr.trim()} (use constraint builders, literals, or structure syntax)`
583
- });
708
+ emitUnrecognizedExprDiag(expr, context);
709
+ if (isCaseExpectedContext(context)) return absParseFailure();
584
710
  }
585
711
  return abs;
586
712
  }
713
+ function emitUnrecognizedExprDiag(expr, context) {
714
+ emitDirectiveDiag({
715
+ code: DIAG.directiveSyntax,
716
+ message: `Unrecognized type expression in ${context}: ${expr.trim()} (use constraint builders, literals, or structure syntax)`
717
+ });
718
+ }
719
+ function absParseFailure() {
720
+ return absExact({ k: "never" });
721
+ }
722
+ function isCaseExpectedContext(context) {
723
+ return /^@nudo:case\b/.test(context) && /=>\s*expected\s*$/.test(context);
724
+ }
725
+ var PLAIN_DECIMAL_RE = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/;
726
+ var LEGACY_LEADING_ZERO_RE = /^([+-]?)0([0-9]+)$/;
727
+ function legacyLeadingZeroValue(s) {
728
+ const m = LEGACY_LEADING_ZERO_RE.exec(s);
729
+ if (!m) return void 0;
730
+ const sign = m[1] === "-" ? -1 : 1;
731
+ const digits = `0${m[2]}`;
732
+ if (/^0[0-7]+$/.test(digits)) return sign * parseInt(digits, 8);
733
+ return sign * Number(digits);
734
+ }
735
+ function parseDirectiveExprNode(s) {
736
+ try {
737
+ const ast = parse(`(${s});`);
738
+ if (ast.program.body.length !== 1) return void 0;
739
+ const stmt = ast.program.body[0];
740
+ if (stmt.type !== "ExpressionStatement") return void 0;
741
+ return stmt.expression;
742
+ } catch {
743
+ return void 0;
744
+ }
745
+ }
746
+ function numericNodeValue(node) {
747
+ switch (node.type) {
748
+ case "NumericLiteral":
749
+ return typeof node.value === "number" ? node.value : void 0;
750
+ case "BigIntLiteral": {
751
+ const raw = node.value;
752
+ try {
753
+ return typeof raw === "bigint" ? raw : BigInt(raw);
754
+ } catch {
755
+ return void 0;
756
+ }
757
+ }
758
+ case "UnaryExpression": {
759
+ if (node.operator !== "+" && node.operator !== "-") return void 0;
760
+ const inner = numericNodeValue(node.argument);
761
+ if (inner === void 0) return void 0;
762
+ if (node.operator === "+") return inner;
763
+ return typeof inner === "bigint" ? -inner : -inner;
764
+ }
765
+ case "Identifier":
766
+ if (node.name === "Infinity") return Infinity;
767
+ if (node.name === "NaN") return NaN;
768
+ return void 0;
769
+ default:
770
+ return void 0;
771
+ }
772
+ }
773
+ function parseNumberLiteralExpr(s) {
774
+ const body = s.trim();
775
+ if (PLAIN_DECIMAL_RE.test(body)) return numLit(Number(body));
776
+ const legacy = legacyLeadingZeroValue(body);
777
+ if (legacy !== void 0) return numLit(legacy);
778
+ const node = parseDirectiveExprNode(body);
779
+ if (!node) return void 0;
780
+ const value = numericNodeValue(node);
781
+ if (value === void 0) return void 0;
782
+ if (typeof value === "bigint") return bigintLit(value);
783
+ return numLit(value);
784
+ }
785
+ function parseTemplateLiteralExpr(s) {
786
+ const body = s.trim();
787
+ if (!body.startsWith("`") || !body.endsWith("`") || body.length < 2) return void 0;
788
+ const node = parseDirectiveExprNode(body);
789
+ if (!node || node.type !== "TemplateLiteral") return void 0;
790
+ if (node.expressions.length > 0) return void 0;
791
+ const cooked = node.quasis[0]?.value.cooked;
792
+ if (typeof cooked !== "string") return void 0;
793
+ return strLit(cooked);
794
+ }
587
795
 
588
796
  // src/directives/fn-directives.ts
589
797
  import {
@@ -660,6 +868,8 @@ function parseSinonExpr(expr) {
660
868
  if (arrowFn) {
661
869
  return {
662
870
  type: "stub",
871
+ // 严格性不可得:mock 表达式是**独立解析的载荷文本**,enclosing 源
872
+ // 文件的 ESM/CJS 种类未随指令流转 ⇒ 不写 strict(sloppy,保守)。
663
873
  returnValue: absFunction2(arrowFn.params, {
664
874
  body: arrowFn.body
665
875
  })
@@ -706,7 +916,14 @@ function parseNudoMockExpr(expr) {
706
916
  if (stubCallsFakeMatch) {
707
917
  const arrowFn = parseArrowFunctionExpr(stubCallsFakeMatch[1].trim());
708
918
  if (arrowFn) {
709
- return stub.callsFake({ params: arrowFn.params, body: arrowFn.body, async: false });
919
+ return stub.callsFake({
920
+ params: arrowFn.params,
921
+ body: arrowFn.body,
922
+ // Bug 857 的第三处:sinon 路径此前也把 async 硬编码成 false ⇒
923
+ // `stub().callsFake(async (x) => …)` 被建模成同步函数 ⇒ 调用点
924
+ // `.then(…)` 报「method 'then' missing」。
925
+ async: arrowFn.async === true
926
+ });
710
927
  }
711
928
  return { kind: "mock-helper" };
712
929
  }
@@ -762,18 +979,18 @@ function parseNudoMockExpr(expr) {
762
979
  }
763
980
 
764
981
  // src/directives/fn-directives.ts
765
- var CASE_TAG_REGEX = /(?:^|\n)[ \t]*(?:\*[ \t]*|\/[ \t]*)?@nudo:case\b[ \t]*([^\n]*)/g;
982
+ var CASE_TAG_REGEX = /(?:^|\n)[ \t]*(?:\*[ \t]*|\/[ \t]*)?@nudo:case\b([ \t]*)([^\n]*)/g;
766
983
  var JS_IDENT_RE = /^[\p{L}$_][\p{L}\p{N}$_]*$/u;
767
- var MOCK_INLINE_REGEX = /(?:^|\n)[ \t]*(?:\*[ \t]*|\/[ \t]*)?@nudo:mock\b[ \t]+([^\s=]+)[ \t]*=[ \t]*(.+)/g;
768
- var MOCK_FROM_REGEX = /(?:^|\n)[ \t]*(?:\*[ \t]*|\/[ \t]*)?@nudo:mock\b[ \t]+([^\s]+)[ \t]+from[ \t]+(?:"([^"\n]+)"|'([^'\n]+)')/g;
769
- var MOCK_TAG_REGEX = /(?:^|\n)[ \t]*(?:\*[ \t]*|\/[ \t]*)?@nudo:mock\b[ \t]*([^\n]*)/g;
984
+ var MOCK_INLINE_REGEX = /(?:^|\n)[ \t]*(?:\*[ \t]*|\/[ \t]*)?@nudo:mock(?![-\w])[ \t]+([^\s=]+)[ \t]*=[ \t]*(.+)/g;
985
+ var MOCK_FROM_REGEX = /(?:^|\n)[ \t]*(?:\*[ \t]*|\/[ \t]*)?@nudo:mock(?![-\w])[ \t]+([^\s]+)[ \t]+from[ \t]+(?:"([^"\n]+)"|'([^'\n]+)')/g;
986
+ var MOCK_TAG_REGEX = /(?:^|\n)[ \t]*(?:\*[ \t]*|\/[ \t]*)?@nudo:mock(?!-)([ \t]*)([^\n]*)/g;
770
987
  var PURE_REGEX = /(?:^|\n)[ \t]*(?:\*[ \t]*|\/[ \t]*)?@nudo:pure\b/g;
771
- var SKIP_REGEX = /(?:^|\n)[ \t]*(?:\*[ \t]*|\/[ \t]*)?@nudo:skip\b(?:[ \t]+(\S[^\n]*))?/g;
988
+ var SKIP_REGEX = /(?:^|\n)[ \t]*(?:\*[ \t]*|\/[ \t]*)?@nudo:skip\b[ \t]*(.*)/g;
772
989
  var SAMPLE_REGEX = /(?:^|\n)[ \t]*(?:\*[ \t]*|\/[ \t]*)?@nudo:sample\b(?:[ \t]+(\S+))?/g;
773
990
  var SAMPLE_NUM_RE = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
774
991
  function parseSkipReturnsExpr(rest) {
775
992
  if (rest === void 0) return void 0;
776
- const s = rest.trim();
993
+ const s = stripTrailingComment(rest);
777
994
  if (s === "") return void 0;
778
995
  if (s.startsWith("=>")) {
779
996
  const expr = s.slice(2).trim();
@@ -801,14 +1018,51 @@ function parseSkipReturnsExpr(rest) {
801
1018
  }
802
1019
  return void 0;
803
1020
  }
1021
+ function siteInCommentText(text, startLine, offset) {
1022
+ const before = text.slice(0, Math.max(0, offset));
1023
+ const nl = before.lastIndexOf("\n");
1024
+ const newlines = before.split("\n").length - 1;
1025
+ return {
1026
+ line: startLine + newlines,
1027
+ column: nl < 0 ? offset : offset - nl - 1
1028
+ };
1029
+ }
1030
+ function stripTrailingComment(text) {
1031
+ let quote = null;
1032
+ for (let i = 0; i < text.length; i++) {
1033
+ const ch = text[i];
1034
+ if (quote !== null) {
1035
+ if (ch === "\\") {
1036
+ i++;
1037
+ continue;
1038
+ }
1039
+ if (ch === quote) quote = null;
1040
+ continue;
1041
+ }
1042
+ if (ch === '"' || ch === "'" || ch === "`") {
1043
+ quote = ch;
1044
+ continue;
1045
+ }
1046
+ if (ch === "/" && text[i + 1] === "/") return text.slice(0, i).trim();
1047
+ }
1048
+ return text.trim();
1049
+ }
804
1050
  function parseDirectivesFromCommentTexts(comments) {
805
1051
  const directives = [];
806
1052
  for (const { text, startLine: commentStartLine } of comments) {
807
1053
  const caseArgSpans = scanCaseArgSpans(text);
808
1054
  const caseTags = scanCaseTags(text);
809
- scanMalformedCaseNames(text, caseArgSpans);
1055
+ scanMalformedCaseNames(text, caseArgSpans, commentStartLine);
1056
+ for (const tag of caseTags) {
1057
+ if (!tag.expectedIssue) continue;
1058
+ emitDirectiveDiag({
1059
+ code: DIAG2.directiveSyntax,
1060
+ message: `Malformed @nudo:case: ${tag.expectedIssue} \u2014 got: @nudo:case "${tag.name}" (\u2026)`,
1061
+ at: siteInCommentText(text, commentStartLine, tag.tagOffset)
1062
+ });
1063
+ }
810
1064
  const inCaseArgs = (idx) => caseArgSpans.some(([s, e]) => idx >= s && idx < e);
811
- scanMalformedMockNames(text, caseArgSpans);
1065
+ scanMalformedMockNames(text, caseArgSpans, commentStartLine);
812
1066
  MOCK_FROM_REGEX.lastIndex = 0;
813
1067
  let mockFromMatch;
814
1068
  const mockFromRanges = [];
@@ -833,6 +1087,7 @@ function parseDirectivesFromCommentTexts(comments) {
833
1087
  const mockName = mockMatch[1];
834
1088
  if (!JS_IDENT_RE.test(mockName)) continue;
835
1089
  const expr = mockMatch[2].trim();
1090
+ if (expr === "") continue;
836
1091
  let arrowFn = parseArrowFunctionExpr(expr);
837
1092
  const sinonExpr = parseSinonExpr(expr);
838
1093
  const nudoMock = parseNudoMockExpr(expr);
@@ -856,7 +1111,8 @@ function parseDirectivesFromCommentTexts(comments) {
856
1111
  const cleaned = tag.argsText.split("\n").map((line) => line.replace(/^\s*\*\s?/, "")).join("\n");
857
1112
  const rawArgs = splitTopLevelArgs(cleaned);
858
1113
  const argsAbs = rawArgs.map((a) => parseAbsExprDiag(a, `@nudo:case "${name}" argument`));
859
- const expected = tag.expectedText !== void 0 ? parseAbsExprDiag(tag.expectedText, `@nudo:case "${name}" => expected`) : void 0;
1114
+ const expectedText = tag.expectedText !== void 0 ? stripTrailingComment(tag.expectedText) : void 0;
1115
+ const expected = expectedText !== void 0 ? parseAbsExprDiag(expectedText, `@nudo:case "${name}" => expected`) : void 0;
860
1116
  const linesBeforeMatch = text.slice(0, tag.tagOffset).split("\n").length - 1;
861
1117
  const commentLine = commentStartLine + linesBeforeMatch;
862
1118
  directives.push({
@@ -892,7 +1148,8 @@ function parseDirectivesFromCommentTexts(comments) {
892
1148
  if (raw === void 0 || !SAMPLE_NUM_RE.test(raw)) {
893
1149
  emitDirectiveDiag({
894
1150
  code: DIAG2.directiveSyntax,
895
- message: `Malformed @nudo:sample: expected a numeric count (integer, decimal, or negative) \u2014 got: @nudo:sample ${raw ?? "(missing count)"}`
1151
+ message: `Malformed @nudo:sample: expected a numeric count (integer, decimal, or negative) \u2014 got: @nudo:sample ${raw ?? "(missing count)"}`,
1152
+ at: siteInCommentText(text, commentStartLine, sampleMatch.index)
896
1153
  });
897
1154
  continue;
898
1155
  }
@@ -901,20 +1158,30 @@ function parseDirectivesFromCommentTexts(comments) {
901
1158
  }
902
1159
  return directives;
903
1160
  }
904
- function scanMalformedCaseNames(text, caseArgSpans) {
1161
+ function scanMalformedCaseNames(text, caseArgSpans, startLine) {
905
1162
  const inCaseArgs = (idx) => caseArgSpans.some(([s, e]) => idx >= s && idx < e);
906
1163
  CASE_TAG_REGEX.lastIndex = 0;
907
1164
  let m;
908
1165
  while ((m = CASE_TAG_REGEX.exec(text)) !== null) {
909
1166
  if (inCaseArgs(m.index)) continue;
910
- const rest = m[1].trim();
1167
+ const separator = m[1];
1168
+ const rest = m[2].trim();
1169
+ if (separator === "" && rest.startsWith('"')) {
1170
+ emitDirectiveDiag({
1171
+ code: DIAG2.directiveSyntax,
1172
+ message: `Malformed @nudo:case: expected a space between '@nudo:case' and the name (expected @nudo:case "name" (\u2026)) \u2014 got: @nudo:case${rest.slice(0, 60)}`,
1173
+ at: siteInCommentText(text, startLine, m.index)
1174
+ });
1175
+ continue;
1176
+ }
911
1177
  const legal = rest.match(/^("[^"]+")[ \t]*\(/);
912
1178
  if (legal) {
913
- const parenIdx = m.index + m[0].length - m[1].length + legal[0].length - 1;
1179
+ const parenIdx = m.index + m[0].length - m[2].length + legal[0].length - 1;
914
1180
  if (extractBalancedParens(text, parenIdx) === null) {
915
1181
  emitDirectiveDiag({
916
1182
  code: DIAG2.directiveSyntax,
917
- message: `Malformed @nudo:case: unclosed argument list (missing closing ")") \u2014 got: @nudo:case ${rest.slice(0, 60)}`
1183
+ message: `Malformed @nudo:case: unclosed argument list (missing closing ")") \u2014 got: @nudo:case ${rest.slice(0, 60)}`,
1184
+ at: siteInCommentText(text, startLine, m.index)
918
1185
  });
919
1186
  }
920
1187
  continue;
@@ -933,22 +1200,41 @@ function scanMalformedCaseNames(text, caseArgSpans) {
933
1200
  }
934
1201
  emitDirectiveDiag({
935
1202
  code: DIAG2.directiveSyntax,
936
- message: `Malformed @nudo:case: ${reason} \u2014 got: @nudo:case ${rest.slice(0, 60)}`
1203
+ message: `Malformed @nudo:case: ${reason} \u2014 got: @nudo:case ${rest.slice(0, 60)}`,
1204
+ at: siteInCommentText(text, startLine, m.index)
937
1205
  });
938
1206
  }
939
1207
  }
940
- function scanMalformedMockNames(text, caseArgSpans) {
1208
+ function mockStrictGrammarHits(text, index) {
1209
+ MOCK_INLINE_REGEX.lastIndex = index;
1210
+ const inline = MOCK_INLINE_REGEX.exec(text);
1211
+ if (inline !== null && inline.index === index && inline[2].trim() !== "") return true;
1212
+ MOCK_FROM_REGEX.lastIndex = index;
1213
+ const from = MOCK_FROM_REGEX.exec(text);
1214
+ return from !== null && from.index === index;
1215
+ }
1216
+ function scanMalformedMockNames(text, caseArgSpans, startLine) {
941
1217
  const inCaseArgs = (idx) => caseArgSpans.some(([s, e]) => idx >= s && idx < e);
942
1218
  MOCK_TAG_REGEX.lastIndex = 0;
943
1219
  let m;
944
1220
  while ((m = MOCK_TAG_REGEX.exec(text)) !== null) {
945
1221
  if (inCaseArgs(m.index)) continue;
946
- const rest = m[1].trim();
1222
+ const separator = m[1];
1223
+ const rest = m[2].trim();
1224
+ if (separator === "" && /^[A-Za-z_$]/.test(rest)) {
1225
+ emitDirectiveDiag({
1226
+ code: DIAG2.directiveSyntax,
1227
+ message: `Malformed @nudo:mock: expected a space between '@nudo:mock' and the name (expected @nudo:mock <ident> = <expr> or @nudo:mock <ident> from "path" / 'path') \u2014 got: @nudo:mock${rest.slice(0, 60)}`,
1228
+ at: siteInCommentText(text, startLine, m.index)
1229
+ });
1230
+ continue;
1231
+ }
947
1232
  const nameMatch = rest.match(/^([^\s=]+)(?:\s*=|\s+from\b)/);
948
1233
  if (!nameMatch) {
949
1234
  emitDirectiveDiag({
950
1235
  code: DIAG2.directiveSyntax,
951
- message: `Malformed @nudo:mock: cannot parse name (expected @nudo:mock <ident> = <expr> or @nudo:mock <ident> from "path" / 'path') \u2014 got: ${rest.slice(0, 60)}`
1236
+ message: `Malformed @nudo:mock: cannot parse name (expected @nudo:mock <ident> = <expr> or @nudo:mock <ident> from "path" / 'path') \u2014 got: ${rest.slice(0, 60)}`,
1237
+ at: siteInCommentText(text, startLine, m.index)
952
1238
  });
953
1239
  continue;
954
1240
  }
@@ -956,7 +1242,8 @@ function scanMalformedMockNames(text, caseArgSpans) {
956
1242
  if (!JS_IDENT_RE.test(name)) {
957
1243
  emitDirectiveDiag({
958
1244
  code: DIAG2.directiveSyntax,
959
- message: `Malformed @nudo:mock name '${name}': not a valid identifier (use letters, digits, _, $; Unicode letters allowed)`
1245
+ message: `Malformed @nudo:mock name '${name}': not a valid identifier (use letters, digits, _, $; Unicode letters allowed)`,
1246
+ at: siteInCommentText(text, startLine, m.index)
960
1247
  });
961
1248
  continue;
962
1249
  }
@@ -964,7 +1251,16 @@ function scanMalformedMockNames(text, caseArgSpans) {
964
1251
  if (/^[ \t]+from[ \t]+(?:"[^"\n]*|'[^'\n]*)$/.test(afterName)) {
965
1252
  emitDirectiveDiag({
966
1253
  code: DIAG2.directiveSyntax,
967
- message: `Malformed @nudo:mock from: unclosed quote in path (expected @nudo:mock ${name} from "path" / 'path') \u2014 got: @nudo:mock ${rest.slice(0, 60)}`
1254
+ message: `Malformed @nudo:mock from: unclosed quote in path (expected @nudo:mock ${name} from "path" / 'path') \u2014 got: @nudo:mock ${rest.slice(0, 60)}`,
1255
+ at: siteInCommentText(text, startLine, m.index)
1256
+ });
1257
+ continue;
1258
+ }
1259
+ if (!mockStrictGrammarHits(text, m.index)) {
1260
+ emitDirectiveDiag({
1261
+ code: DIAG2.directiveSyntax,
1262
+ message: `Malformed @nudo:mock: empty or invalid payload (expected @nudo:mock ${name} = <expr> or @nudo:mock ${name} from "path" / 'path') \u2014 got: @nudo:mock ${rest.slice(0, 60)}`,
1263
+ at: siteInCommentText(text, startLine, m.index)
968
1264
  });
969
1265
  }
970
1266
  }
@@ -1000,10 +1296,48 @@ function stripInlineCommentPrefix(line, kind) {
1000
1296
  const s = kind === "block" ? line.replace(/^\s*\*\s?/, "") : line.replace(/^\//, "");
1001
1297
  return s.trim();
1002
1298
  }
1003
- var AS_LINE_REGEX = /^@nudo:as[ \t]+(.+)$/;
1004
1299
  var REPLACE_LINE_REGEX = /^@nudo:replace[ \t]+(.+)$/;
1005
- var AS_BARE_RE = /^@nudo:as[ \t]*$/;
1006
1300
  var REPLACE_BARE_RE = /^@nudo:replace[ \t]*$/;
1301
+ var REPLACE_GLUED_RE = /^@nudo:replace[A-Za-z0-9_$]/;
1302
+ var AS_TAG = "@nudo:as";
1303
+ function asPayloadOf(line) {
1304
+ if (!line.startsWith(AS_TAG)) return null;
1305
+ const rest = line.slice(AS_TAG.length);
1306
+ const next = rest[0];
1307
+ if (next !== void 0 && /[A-Za-z0-9_$]/.test(next)) return null;
1308
+ return rest.trim();
1309
+ }
1310
+ function unwrapOuterParens(s) {
1311
+ let cur = s.trim();
1312
+ while (cur.length >= 2 && cur.startsWith("(") && cur.endsWith(")") && outerParensWrapAll(cur)) {
1313
+ cur = cur.slice(1, -1).trim();
1314
+ }
1315
+ return cur;
1316
+ }
1317
+ function outerParensWrapAll(s) {
1318
+ let depth = 0;
1319
+ let inString = null;
1320
+ for (let i = 0; i < s.length; i++) {
1321
+ const ch = s[i];
1322
+ if (inString) {
1323
+ if (ch === inString) inString = null;
1324
+ continue;
1325
+ }
1326
+ if (ch === '"' || ch === "'") {
1327
+ inString = ch;
1328
+ continue;
1329
+ }
1330
+ if (ch === "(") {
1331
+ depth++;
1332
+ continue;
1333
+ }
1334
+ if (ch === ")") {
1335
+ depth--;
1336
+ if (depth === 0) return i === s.length - 1;
1337
+ }
1338
+ }
1339
+ return false;
1340
+ }
1007
1341
  function findTrailingCommentStart(expr) {
1008
1342
  let inString = null;
1009
1343
  for (let i = 0; i < expr.length; i++) {
@@ -1016,12 +1350,23 @@ function findTrailingCommentStart(expr) {
1016
1350
  inString = ch;
1017
1351
  continue;
1018
1352
  }
1353
+ if (ch === "/" && (expr[i + 1] === "/" || expr[i + 1] === "*")) {
1354
+ if (!looksLikeDivision(expr, i)) return i;
1355
+ continue;
1356
+ }
1019
1357
  if ((ch === " " || ch === " ") && /^[ \t]+(?:\/\/|\/\*)/.test(expr.slice(i))) {
1020
1358
  return i;
1021
1359
  }
1022
1360
  }
1023
1361
  return -1;
1024
1362
  }
1363
+ function looksLikeDivision(expr, slashIdx) {
1364
+ let left = slashIdx - 1;
1365
+ while (left >= 0 && (expr[left] === " " || expr[left] === " ")) left--;
1366
+ const ch = left >= 0 ? expr[left] : "";
1367
+ if (ch !== "." && !/^[0-9]$/.test(ch)) return false;
1368
+ return /^[0-9.]/.test(expr.slice(slashIdx + 2));
1369
+ }
1025
1370
  function extractInlineDirectives(node, opts) {
1026
1371
  const comments = node.leadingComments;
1027
1372
  if (!comments) return [];
@@ -1033,29 +1378,36 @@ function collectInlineDirectivesFromComments(comments) {
1033
1378
  for (const comment of comments) {
1034
1379
  if (comment.type !== "CommentLine" && comment.type !== "CommentBlock") continue;
1035
1380
  const kind = comment.type === "CommentLine" ? "line" : "block";
1036
- for (const rawLine of comment.value.split("\n")) {
1381
+ const rawLines = comment.value.split("\n");
1382
+ for (let lineIdx = 0; lineIdx < rawLines.length; lineIdx++) {
1383
+ const rawLine = rawLines[lineIdx];
1037
1384
  const line = stripInlineCommentPrefix(rawLine, kind);
1038
- const asMatch = line.match(AS_LINE_REGEX);
1039
- if (asMatch) {
1040
- const rawExpr = asMatch[1].trim();
1041
- const trailingIdx = findTrailingCommentStart(rawExpr);
1385
+ const at = commentSite(comment, rawLine, lineIdx, line);
1386
+ const asPayload = asPayloadOf(line);
1387
+ if (asPayload !== null) {
1388
+ if (asPayload === "") {
1389
+ emitDirectiveDiag({
1390
+ code: DIAG3.directiveSyntax,
1391
+ message: `@nudo:as requires a type expression (e.g. @nudo:as number())`,
1392
+ at
1393
+ });
1394
+ continue;
1395
+ }
1396
+ const trailingIdx = findTrailingCommentStart(asPayload);
1042
1397
  if (trailingIdx !== -1) {
1043
- const typePart = rawExpr.slice(0, trailingIdx).trim();
1398
+ const typePart = asPayload.slice(0, trailingIdx).trim();
1044
1399
  emitDirectiveDiag({
1045
1400
  code: DIAG3.directiveSyntax,
1046
- message: `Trailing comment in @nudo:as type expression: '${rawExpr}' (remove the comment; the type must be a clean expression)`
1401
+ message: `Trailing comment in @nudo:as type expression: '${asPayload}' (remove the comment; the type must be a clean expression)`,
1402
+ at
1403
+ });
1404
+ results.push({
1405
+ kind: "as",
1406
+ typeAbs: typePart ? parseAbsExprDiag(unwrapOuterParens(typePart), "@nudo:as") : absUnknown()
1047
1407
  });
1048
- results.push({ kind: "as", typeAbs: typePart ? parseAbsExprDiag(typePart, "@nudo:as") : absUnknown() });
1049
1408
  continue;
1050
1409
  }
1051
- results.push({ kind: "as", typeAbs: parseAbsExprDiag(rawExpr, "@nudo:as") });
1052
- continue;
1053
- }
1054
- if (AS_BARE_RE.test(line)) {
1055
- emitDirectiveDiag({
1056
- code: DIAG3.directiveSyntax,
1057
- message: `@nudo:as requires a type expression (e.g. @nudo:as number())`
1058
- });
1410
+ results.push({ kind: "as", typeAbs: parseAbsExprDiag(unwrapOuterParens(asPayload), "@nudo:as") });
1059
1411
  continue;
1060
1412
  }
1061
1413
  const replaceMatch = line.match(REPLACE_LINE_REGEX);
@@ -1073,7 +1425,8 @@ function collectInlineDirectivesFromComments(comments) {
1073
1425
  } else {
1074
1426
  emitDirectiveDiag({
1075
1427
  code: DIAG3.directiveSyntax,
1076
- message: `Malformed @nudo:replace: cannot separate target from type (expected @nudo:replace <target> <typeExpr>) \u2014 got: ${raw.slice(0, 60)}`
1428
+ message: `Malformed @nudo:replace: cannot separate target from type (expected @nudo:replace <target> <typeExpr>) \u2014 got: ${raw.slice(0, 60)}`,
1429
+ at
1077
1430
  });
1078
1431
  }
1079
1432
  continue;
@@ -1081,13 +1434,33 @@ function collectInlineDirectivesFromComments(comments) {
1081
1434
  if (REPLACE_BARE_RE.test(line)) {
1082
1435
  emitDirectiveDiag({
1083
1436
  code: DIAG3.directiveSyntax,
1084
- message: `@nudo:replace requires a target and type expression (expected @nudo:replace <target> <typeExpr>)`
1437
+ message: `@nudo:replace requires a target and type expression (expected @nudo:replace <target> <typeExpr>)`,
1438
+ at
1439
+ });
1440
+ continue;
1441
+ }
1442
+ if (REPLACE_GLUED_RE.test(line)) {
1443
+ emitDirectiveDiag({
1444
+ code: DIAG3.directiveSyntax,
1445
+ message: `Malformed @nudo:replace directive '${line.slice(0, 60)}' (expected a space after the tag: @nudo:replace <target> <typeExpr>)`,
1446
+ at
1085
1447
  });
1448
+ continue;
1086
1449
  }
1087
1450
  }
1088
1451
  }
1089
1452
  return results;
1090
1453
  }
1454
+ function commentSite(comment, rawLine, lineIdx, stripped) {
1455
+ const locStart = comment.loc?.start;
1456
+ if (!locStart) return void 0;
1457
+ const base = lineIdx === 0 ? locStart.column + 2 : 0;
1458
+ const inLine = stripped.length > 0 ? rawLine.indexOf(stripped) : 0;
1459
+ return {
1460
+ line: locStart.line + lineIdx,
1461
+ column: base + (inLine < 0 ? 0 : inLine)
1462
+ };
1463
+ }
1091
1464
  function findReplaceSeparator(raw) {
1092
1465
  let depth = 0;
1093
1466
  let inString = null;
@@ -1120,11 +1493,16 @@ function findReplaceSeparator(raw) {
1120
1493
  }
1121
1494
 
1122
1495
  // src/directives/file-directives.ts
1123
- import { parseEnvPayload, parseMockModulePayload } from "@nudojs/core";
1496
+ import {
1497
+ listFnDirectiveScopes as listFnDirectiveScopes2,
1498
+ parseEnvPayload,
1499
+ parseMockModulePayload
1500
+ } from "@nudojs/core";
1124
1501
  import { DIAG as DIAG4 } from "@nudojs/core/internal";
1125
1502
  var ENV_REGEX = /^\/?\s*@nudo:env\s+(.+)/;
1503
+ var ENV_BARE_REGEX = /^\/?\s*@nudo:env\b[ \t]*$/;
1126
1504
  var MOCK_MODULE_REGEX = /^\/?\s*@nudo:mock-module\s+(.+)/;
1127
- var FILE_TAG_IN_BLOCK_REGEX = /(?:^|\n)[ \t]*(?:\*[ \t]*)?@nudo:(env|mock-module)\b/g;
1505
+ var FILE_TAG_IN_BLOCK_REGEX = /(?:^|\n)[ \t]*(?:\*[ \t]*)?@nudo:(env|mock-module|import)\b/g;
1128
1506
  function warnMisplacedFileTags(blockText) {
1129
1507
  FILE_TAG_IN_BLOCK_REGEX.lastIndex = 0;
1130
1508
  let m;
@@ -1132,10 +1510,48 @@ function warnMisplacedFileTags(blockText) {
1132
1510
  const tag = m[1];
1133
1511
  emitDirectiveDiag({
1134
1512
  code: DIAG4.directiveSyntax,
1135
- message: tag === "env" ? `@nudo:env is a file-level directive \u2014 use '/// @nudo:env node' at the top of the file (JSDoc block form is ignored)` : `@nudo:mock-module is a file-level directive \u2014 use '/// @nudo:mock-module "fs" from "./mock-fs.js"' at the top of the file (JSDoc block form is ignored)`
1513
+ message: tag === "env" ? `@nudo:env is a file-level directive \u2014 use '/// @nudo:env node' at the top of the file (JSDoc block form is ignored)` : tag === "mock-module" ? `@nudo:mock-module is a file-level directive \u2014 use '/// @nudo:mock-module "fs" from "./mock-fs.js"' at the top of the file (JSDoc block form is ignored)` : `@nudo:import is a file-level directive \u2014 use '/// @nudo:import { a } from "./x.nudo.js"' at the top of the file (JSDoc block form is ignored)`
1136
1514
  });
1137
1515
  }
1138
1516
  }
1517
+ function warnMisplacedFileTagLine(tag) {
1518
+ emitDirectiveDiag({
1519
+ code: DIAG4.directiveSyntax,
1520
+ message: tag === "env" ? `@nudo:env is a file-level directive \u2014 use '/// @nudo:env node' at the top of the file (line comment inside a function/class/object/array/destructuring body is ignored)` : `@nudo:mock-module is a file-level directive \u2014 use '/// @nudo:mock-module "fs" from "./mock-fs.js"' at the top of the file (line comment inside a function/class/object/array/destructuring body is ignored)`
1521
+ });
1522
+ }
1523
+ function functionBodyRanges(ast) {
1524
+ if (ast.type !== "File") return [];
1525
+ return listFnDirectiveScopes2(ast).map((scope) => [
1526
+ scope.node.start ?? 0,
1527
+ scope.node.end ?? 0
1528
+ ]);
1529
+ }
1530
+ function nestedBodyRanges(ast) {
1531
+ const out = [];
1532
+ const visit = (node) => {
1533
+ if (!node || typeof node !== "object") return;
1534
+ if (Array.isArray(node)) {
1535
+ for (const item of node) visit(item);
1536
+ return;
1537
+ }
1538
+ const n = node;
1539
+ if ((n.type === "ClassBody" || n.type === "ObjectExpression" || n.type === "ArrayExpression" || n.type === "ObjectPattern" || n.type === "ArrayPattern") && typeof n.start === "number" && typeof n.end === "number") {
1540
+ out.push([n.start, n.end]);
1541
+ }
1542
+ for (const key of Object.keys(n)) {
1543
+ if (key === "loc" || key === "leadingComments" || key === "trailingComments" || key === "innerComments" || key === "comments" || key === "tokens" || key === "extra") {
1544
+ continue;
1545
+ }
1546
+ visit(n[key]);
1547
+ }
1548
+ };
1549
+ visit(ast);
1550
+ return out;
1551
+ }
1552
+ function inRanges(ranges, offset) {
1553
+ return ranges.some(([s, e]) => offset >= s && offset < e);
1554
+ }
1139
1555
  function extractFileDirectives(ast, opts) {
1140
1556
  if (!opts?.diags) return collectFileDirectives(ast);
1141
1557
  return runWithDirectiveDiags(opts.diags, () => collectFileDirectives(ast));
@@ -1144,6 +1560,8 @@ function collectFileDirectives(ast) {
1144
1560
  const results = [];
1145
1561
  if (ast.type !== "File") return results;
1146
1562
  const comments = ast.comments ?? [];
1563
+ const fnRanges = functionBodyRanges(ast);
1564
+ const bodyRanges = nestedBodyRanges(ast);
1147
1565
  for (const comment of comments) {
1148
1566
  if (comment.type === "CommentBlock") {
1149
1567
  warnMisplacedFileTags(comment.value);
@@ -1151,6 +1569,12 @@ function collectFileDirectives(ast) {
1151
1569
  }
1152
1570
  if (comment.type !== "CommentLine") continue;
1153
1571
  const text = comment.value;
1572
+ const misplaced = typeof comment.start === "number" && (inRanges(fnRanges, comment.start) || inRanges(bodyRanges, comment.start));
1573
+ if (misplaced) {
1574
+ if (MOCK_MODULE_REGEX.test(text)) warnMisplacedFileTagLine("mock-module");
1575
+ else if (ENV_REGEX.test(text) || ENV_BARE_REGEX.test(text)) warnMisplacedFileTagLine("env");
1576
+ continue;
1577
+ }
1154
1578
  const mockModuleMatch = text.match(MOCK_MODULE_REGEX);
1155
1579
  if (mockModuleMatch) {
1156
1580
  const rec = parseMockModulePayload(mockModuleMatch[1]);
@@ -1163,11 +1587,30 @@ function collectFileDirectives(ast) {
1163
1587
  });
1164
1588
  continue;
1165
1589
  }
1590
+ emitDirectiveDiag({
1591
+ code: DIAG4.directiveSyntax,
1592
+ message: `Unrecognized @nudo:mock-module form (expected '"src" from "path"' or '"src" { a, b } from "path"')`
1593
+ });
1594
+ continue;
1166
1595
  }
1167
1596
  const envMatch = text.match(ENV_REGEX);
1168
1597
  if (envMatch) {
1169
1598
  const envs = parseEnvPayload(envMatch[1]);
1170
- if (envs.length > 0) results.push({ kind: "env", envs });
1599
+ if (envs.length > 0) {
1600
+ results.push({ kind: "env", envs });
1601
+ } else {
1602
+ emitDirectiveDiag({
1603
+ code: DIAG4.directiveSyntax,
1604
+ message: `Unrecognized @nudo:env list (expected env names, e.g. '/// @nudo:env node, web')`
1605
+ });
1606
+ }
1607
+ continue;
1608
+ }
1609
+ if (ENV_BARE_REGEX.test(text)) {
1610
+ emitDirectiveDiag({
1611
+ code: DIAG4.directiveSyntax,
1612
+ message: `@nudo:env requires at least one env name (e.g. '/// @nudo:env node')`
1613
+ });
1171
1614
  }
1172
1615
  }
1173
1616
  return results;
@@ -1180,7 +1623,7 @@ import {
1180
1623
  extractNudoImportRecords,
1181
1624
  findFnDirectiveScope,
1182
1625
  fnDirectiveCommentLines,
1183
- listFnDirectiveScopes as listFnDirectiveScopes2,
1626
+ listFnDirectiveScopes as listFnDirectiveScopes3,
1184
1627
  parseEnvPayload as parseEnvPayload2,
1185
1628
  parseMockModulePayload as parseMockModulePayload2,
1186
1629
  parseNudoImportPayload,
@@ -1222,7 +1665,7 @@ export {
1222
1665
  getFirstDeclaratorId,
1223
1666
  identifierName,
1224
1667
  isModuleExportsMember,
1225
- listFnDirectiveScopes2 as listFnDirectiveScopes,
1668
+ listFnDirectiveScopes3 as listFnDirectiveScopes,
1226
1669
  memberObjectName,
1227
1670
  memberPropertyKey,
1228
1671
  memberPropertyName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nudojs/parser",
3
- "version": "1.5.2",
3
+ "version": "1.5.4",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },
@@ -39,7 +39,7 @@
39
39
  "registry": "https://registry.npmjs.org"
40
40
  },
41
41
  "dependencies": {
42
- "@nudojs/core": "1.15.2",
42
+ "@nudojs/core": "1.17.0",
43
43
  "@babel/types": "^8.0.6"
44
44
  },
45
45
  "scripts": {