@nudojs/parser 1.5.0 → 1.5.3

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 {
@@ -706,7 +914,14 @@ function parseNudoMockExpr(expr) {
706
914
  if (stubCallsFakeMatch) {
707
915
  const arrowFn = parseArrowFunctionExpr(stubCallsFakeMatch[1].trim());
708
916
  if (arrowFn) {
709
- return stub.callsFake({ params: arrowFn.params, body: arrowFn.body, async: false });
917
+ return stub.callsFake({
918
+ params: arrowFn.params,
919
+ body: arrowFn.body,
920
+ // Bug 857 的第三处:sinon 路径此前也把 async 硬编码成 false ⇒
921
+ // `stub().callsFake(async (x) => …)` 被建模成同步函数 ⇒ 调用点
922
+ // `.then(…)` 报「method 'then' missing」。
923
+ async: arrowFn.async === true
924
+ });
710
925
  }
711
926
  return { kind: "mock-helper" };
712
927
  }
@@ -762,18 +977,18 @@ function parseNudoMockExpr(expr) {
762
977
  }
763
978
 
764
979
  // src/directives/fn-directives.ts
765
- var CASE_TAG_REGEX = /(?:^|\n)[ \t]*(?:\*[ \t]*|\/[ \t]*)?@nudo:case\b[ \t]*([^\n]*)/g;
980
+ var CASE_TAG_REGEX = /(?:^|\n)[ \t]*(?:\*[ \t]*|\/[ \t]*)?@nudo:case\b([ \t]*)([^\n]*)/g;
766
981
  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;
982
+ var MOCK_INLINE_REGEX = /(?:^|\n)[ \t]*(?:\*[ \t]*|\/[ \t]*)?@nudo:mock(?![-\w])[ \t]+([^\s=]+)[ \t]*=[ \t]*(.+)/g;
983
+ var MOCK_FROM_REGEX = /(?:^|\n)[ \t]*(?:\*[ \t]*|\/[ \t]*)?@nudo:mock(?![-\w])[ \t]+([^\s]+)[ \t]+from[ \t]+(?:"([^"\n]+)"|'([^'\n]+)')/g;
984
+ var MOCK_TAG_REGEX = /(?:^|\n)[ \t]*(?:\*[ \t]*|\/[ \t]*)?@nudo:mock(?!-)([ \t]*)([^\n]*)/g;
770
985
  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;
986
+ var SKIP_REGEX = /(?:^|\n)[ \t]*(?:\*[ \t]*|\/[ \t]*)?@nudo:skip\b[ \t]*(.*)/g;
772
987
  var SAMPLE_REGEX = /(?:^|\n)[ \t]*(?:\*[ \t]*|\/[ \t]*)?@nudo:sample\b(?:[ \t]+(\S+))?/g;
773
988
  var SAMPLE_NUM_RE = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
774
989
  function parseSkipReturnsExpr(rest) {
775
990
  if (rest === void 0) return void 0;
776
- const s = rest.trim();
991
+ const s = stripTrailingComment(rest);
777
992
  if (s === "") return void 0;
778
993
  if (s.startsWith("=>")) {
779
994
  const expr = s.slice(2).trim();
@@ -801,14 +1016,51 @@ function parseSkipReturnsExpr(rest) {
801
1016
  }
802
1017
  return void 0;
803
1018
  }
1019
+ function siteInCommentText(text, startLine, offset) {
1020
+ const before = text.slice(0, Math.max(0, offset));
1021
+ const nl = before.lastIndexOf("\n");
1022
+ const newlines = before.split("\n").length - 1;
1023
+ return {
1024
+ line: startLine + newlines,
1025
+ column: nl < 0 ? offset : offset - nl - 1
1026
+ };
1027
+ }
1028
+ function stripTrailingComment(text) {
1029
+ let quote = null;
1030
+ for (let i = 0; i < text.length; i++) {
1031
+ const ch = text[i];
1032
+ if (quote !== null) {
1033
+ if (ch === "\\") {
1034
+ i++;
1035
+ continue;
1036
+ }
1037
+ if (ch === quote) quote = null;
1038
+ continue;
1039
+ }
1040
+ if (ch === '"' || ch === "'" || ch === "`") {
1041
+ quote = ch;
1042
+ continue;
1043
+ }
1044
+ if (ch === "/" && text[i + 1] === "/") return text.slice(0, i).trim();
1045
+ }
1046
+ return text.trim();
1047
+ }
804
1048
  function parseDirectivesFromCommentTexts(comments) {
805
1049
  const directives = [];
806
1050
  for (const { text, startLine: commentStartLine } of comments) {
807
1051
  const caseArgSpans = scanCaseArgSpans(text);
808
1052
  const caseTags = scanCaseTags(text);
809
- scanMalformedCaseNames(text, caseArgSpans);
1053
+ scanMalformedCaseNames(text, caseArgSpans, commentStartLine);
1054
+ for (const tag of caseTags) {
1055
+ if (!tag.expectedIssue) continue;
1056
+ emitDirectiveDiag({
1057
+ code: DIAG2.directiveSyntax,
1058
+ message: `Malformed @nudo:case: ${tag.expectedIssue} \u2014 got: @nudo:case "${tag.name}" (\u2026)`,
1059
+ at: siteInCommentText(text, commentStartLine, tag.tagOffset)
1060
+ });
1061
+ }
810
1062
  const inCaseArgs = (idx) => caseArgSpans.some(([s, e]) => idx >= s && idx < e);
811
- scanMalformedMockNames(text, caseArgSpans);
1063
+ scanMalformedMockNames(text, caseArgSpans, commentStartLine);
812
1064
  MOCK_FROM_REGEX.lastIndex = 0;
813
1065
  let mockFromMatch;
814
1066
  const mockFromRanges = [];
@@ -833,6 +1085,7 @@ function parseDirectivesFromCommentTexts(comments) {
833
1085
  const mockName = mockMatch[1];
834
1086
  if (!JS_IDENT_RE.test(mockName)) continue;
835
1087
  const expr = mockMatch[2].trim();
1088
+ if (expr === "") continue;
836
1089
  let arrowFn = parseArrowFunctionExpr(expr);
837
1090
  const sinonExpr = parseSinonExpr(expr);
838
1091
  const nudoMock = parseNudoMockExpr(expr);
@@ -856,7 +1109,8 @@ function parseDirectivesFromCommentTexts(comments) {
856
1109
  const cleaned = tag.argsText.split("\n").map((line) => line.replace(/^\s*\*\s?/, "")).join("\n");
857
1110
  const rawArgs = splitTopLevelArgs(cleaned);
858
1111
  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;
1112
+ const expectedText = tag.expectedText !== void 0 ? stripTrailingComment(tag.expectedText) : void 0;
1113
+ const expected = expectedText !== void 0 ? parseAbsExprDiag(expectedText, `@nudo:case "${name}" => expected`) : void 0;
860
1114
  const linesBeforeMatch = text.slice(0, tag.tagOffset).split("\n").length - 1;
861
1115
  const commentLine = commentStartLine + linesBeforeMatch;
862
1116
  directives.push({
@@ -892,7 +1146,8 @@ function parseDirectivesFromCommentTexts(comments) {
892
1146
  if (raw === void 0 || !SAMPLE_NUM_RE.test(raw)) {
893
1147
  emitDirectiveDiag({
894
1148
  code: DIAG2.directiveSyntax,
895
- message: `Malformed @nudo:sample: expected a numeric count (integer, decimal, or negative) \u2014 got: @nudo:sample ${raw ?? "(missing count)"}`
1149
+ message: `Malformed @nudo:sample: expected a numeric count (integer, decimal, or negative) \u2014 got: @nudo:sample ${raw ?? "(missing count)"}`,
1150
+ at: siteInCommentText(text, commentStartLine, sampleMatch.index)
896
1151
  });
897
1152
  continue;
898
1153
  }
@@ -901,20 +1156,30 @@ function parseDirectivesFromCommentTexts(comments) {
901
1156
  }
902
1157
  return directives;
903
1158
  }
904
- function scanMalformedCaseNames(text, caseArgSpans) {
1159
+ function scanMalformedCaseNames(text, caseArgSpans, startLine) {
905
1160
  const inCaseArgs = (idx) => caseArgSpans.some(([s, e]) => idx >= s && idx < e);
906
1161
  CASE_TAG_REGEX.lastIndex = 0;
907
1162
  let m;
908
1163
  while ((m = CASE_TAG_REGEX.exec(text)) !== null) {
909
1164
  if (inCaseArgs(m.index)) continue;
910
- const rest = m[1].trim();
1165
+ const separator = m[1];
1166
+ const rest = m[2].trim();
1167
+ if (separator === "" && rest.startsWith('"')) {
1168
+ emitDirectiveDiag({
1169
+ code: DIAG2.directiveSyntax,
1170
+ 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)}`,
1171
+ at: siteInCommentText(text, startLine, m.index)
1172
+ });
1173
+ continue;
1174
+ }
911
1175
  const legal = rest.match(/^("[^"]+")[ \t]*\(/);
912
1176
  if (legal) {
913
- const parenIdx = m.index + m[0].length - m[1].length + legal[0].length - 1;
1177
+ const parenIdx = m.index + m[0].length - m[2].length + legal[0].length - 1;
914
1178
  if (extractBalancedParens(text, parenIdx) === null) {
915
1179
  emitDirectiveDiag({
916
1180
  code: DIAG2.directiveSyntax,
917
- message: `Malformed @nudo:case: unclosed argument list (missing closing ")") \u2014 got: @nudo:case ${rest.slice(0, 60)}`
1181
+ message: `Malformed @nudo:case: unclosed argument list (missing closing ")") \u2014 got: @nudo:case ${rest.slice(0, 60)}`,
1182
+ at: siteInCommentText(text, startLine, m.index)
918
1183
  });
919
1184
  }
920
1185
  continue;
@@ -933,22 +1198,41 @@ function scanMalformedCaseNames(text, caseArgSpans) {
933
1198
  }
934
1199
  emitDirectiveDiag({
935
1200
  code: DIAG2.directiveSyntax,
936
- message: `Malformed @nudo:case: ${reason} \u2014 got: @nudo:case ${rest.slice(0, 60)}`
1201
+ message: `Malformed @nudo:case: ${reason} \u2014 got: @nudo:case ${rest.slice(0, 60)}`,
1202
+ at: siteInCommentText(text, startLine, m.index)
937
1203
  });
938
1204
  }
939
1205
  }
940
- function scanMalformedMockNames(text, caseArgSpans) {
1206
+ function mockStrictGrammarHits(text, index) {
1207
+ MOCK_INLINE_REGEX.lastIndex = index;
1208
+ const inline = MOCK_INLINE_REGEX.exec(text);
1209
+ if (inline !== null && inline.index === index && inline[2].trim() !== "") return true;
1210
+ MOCK_FROM_REGEX.lastIndex = index;
1211
+ const from = MOCK_FROM_REGEX.exec(text);
1212
+ return from !== null && from.index === index;
1213
+ }
1214
+ function scanMalformedMockNames(text, caseArgSpans, startLine) {
941
1215
  const inCaseArgs = (idx) => caseArgSpans.some(([s, e]) => idx >= s && idx < e);
942
1216
  MOCK_TAG_REGEX.lastIndex = 0;
943
1217
  let m;
944
1218
  while ((m = MOCK_TAG_REGEX.exec(text)) !== null) {
945
1219
  if (inCaseArgs(m.index)) continue;
946
- const rest = m[1].trim();
1220
+ const separator = m[1];
1221
+ const rest = m[2].trim();
1222
+ if (separator === "" && /^[A-Za-z_$]/.test(rest)) {
1223
+ emitDirectiveDiag({
1224
+ code: DIAG2.directiveSyntax,
1225
+ 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)}`,
1226
+ at: siteInCommentText(text, startLine, m.index)
1227
+ });
1228
+ continue;
1229
+ }
947
1230
  const nameMatch = rest.match(/^([^\s=]+)(?:\s*=|\s+from\b)/);
948
1231
  if (!nameMatch) {
949
1232
  emitDirectiveDiag({
950
1233
  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)}`
1234
+ 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)}`,
1235
+ at: siteInCommentText(text, startLine, m.index)
952
1236
  });
953
1237
  continue;
954
1238
  }
@@ -956,7 +1240,8 @@ function scanMalformedMockNames(text, caseArgSpans) {
956
1240
  if (!JS_IDENT_RE.test(name)) {
957
1241
  emitDirectiveDiag({
958
1242
  code: DIAG2.directiveSyntax,
959
- message: `Malformed @nudo:mock name '${name}': not a valid identifier (use letters, digits, _, $; Unicode letters allowed)`
1243
+ message: `Malformed @nudo:mock name '${name}': not a valid identifier (use letters, digits, _, $; Unicode letters allowed)`,
1244
+ at: siteInCommentText(text, startLine, m.index)
960
1245
  });
961
1246
  continue;
962
1247
  }
@@ -964,7 +1249,16 @@ function scanMalformedMockNames(text, caseArgSpans) {
964
1249
  if (/^[ \t]+from[ \t]+(?:"[^"\n]*|'[^'\n]*)$/.test(afterName)) {
965
1250
  emitDirectiveDiag({
966
1251
  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)}`
1252
+ message: `Malformed @nudo:mock from: unclosed quote in path (expected @nudo:mock ${name} from "path" / 'path') \u2014 got: @nudo:mock ${rest.slice(0, 60)}`,
1253
+ at: siteInCommentText(text, startLine, m.index)
1254
+ });
1255
+ continue;
1256
+ }
1257
+ if (!mockStrictGrammarHits(text, m.index)) {
1258
+ emitDirectiveDiag({
1259
+ code: DIAG2.directiveSyntax,
1260
+ 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)}`,
1261
+ at: siteInCommentText(text, startLine, m.index)
968
1262
  });
969
1263
  }
970
1264
  }
@@ -1000,10 +1294,48 @@ function stripInlineCommentPrefix(line, kind) {
1000
1294
  const s = kind === "block" ? line.replace(/^\s*\*\s?/, "") : line.replace(/^\//, "");
1001
1295
  return s.trim();
1002
1296
  }
1003
- var AS_LINE_REGEX = /^@nudo:as[ \t]+(.+)$/;
1004
1297
  var REPLACE_LINE_REGEX = /^@nudo:replace[ \t]+(.+)$/;
1005
- var AS_BARE_RE = /^@nudo:as[ \t]*$/;
1006
1298
  var REPLACE_BARE_RE = /^@nudo:replace[ \t]*$/;
1299
+ var REPLACE_GLUED_RE = /^@nudo:replace[A-Za-z0-9_$]/;
1300
+ var AS_TAG = "@nudo:as";
1301
+ function asPayloadOf(line) {
1302
+ if (!line.startsWith(AS_TAG)) return null;
1303
+ const rest = line.slice(AS_TAG.length);
1304
+ const next = rest[0];
1305
+ if (next !== void 0 && /[A-Za-z0-9_$]/.test(next)) return null;
1306
+ return rest.trim();
1307
+ }
1308
+ function unwrapOuterParens(s) {
1309
+ let cur = s.trim();
1310
+ while (cur.length >= 2 && cur.startsWith("(") && cur.endsWith(")") && outerParensWrapAll(cur)) {
1311
+ cur = cur.slice(1, -1).trim();
1312
+ }
1313
+ return cur;
1314
+ }
1315
+ function outerParensWrapAll(s) {
1316
+ let depth = 0;
1317
+ let inString = null;
1318
+ for (let i = 0; i < s.length; i++) {
1319
+ const ch = s[i];
1320
+ if (inString) {
1321
+ if (ch === inString) inString = null;
1322
+ continue;
1323
+ }
1324
+ if (ch === '"' || ch === "'") {
1325
+ inString = ch;
1326
+ continue;
1327
+ }
1328
+ if (ch === "(") {
1329
+ depth++;
1330
+ continue;
1331
+ }
1332
+ if (ch === ")") {
1333
+ depth--;
1334
+ if (depth === 0) return i === s.length - 1;
1335
+ }
1336
+ }
1337
+ return false;
1338
+ }
1007
1339
  function findTrailingCommentStart(expr) {
1008
1340
  let inString = null;
1009
1341
  for (let i = 0; i < expr.length; i++) {
@@ -1016,12 +1348,23 @@ function findTrailingCommentStart(expr) {
1016
1348
  inString = ch;
1017
1349
  continue;
1018
1350
  }
1351
+ if (ch === "/" && (expr[i + 1] === "/" || expr[i + 1] === "*")) {
1352
+ if (!looksLikeDivision(expr, i)) return i;
1353
+ continue;
1354
+ }
1019
1355
  if ((ch === " " || ch === " ") && /^[ \t]+(?:\/\/|\/\*)/.test(expr.slice(i))) {
1020
1356
  return i;
1021
1357
  }
1022
1358
  }
1023
1359
  return -1;
1024
1360
  }
1361
+ function looksLikeDivision(expr, slashIdx) {
1362
+ let left = slashIdx - 1;
1363
+ while (left >= 0 && (expr[left] === " " || expr[left] === " ")) left--;
1364
+ const ch = left >= 0 ? expr[left] : "";
1365
+ if (ch !== "." && !/^[0-9]$/.test(ch)) return false;
1366
+ return /^[0-9.]/.test(expr.slice(slashIdx + 2));
1367
+ }
1025
1368
  function extractInlineDirectives(node, opts) {
1026
1369
  const comments = node.leadingComments;
1027
1370
  if (!comments) return [];
@@ -1033,29 +1376,36 @@ function collectInlineDirectivesFromComments(comments) {
1033
1376
  for (const comment of comments) {
1034
1377
  if (comment.type !== "CommentLine" && comment.type !== "CommentBlock") continue;
1035
1378
  const kind = comment.type === "CommentLine" ? "line" : "block";
1036
- for (const rawLine of comment.value.split("\n")) {
1379
+ const rawLines = comment.value.split("\n");
1380
+ for (let lineIdx = 0; lineIdx < rawLines.length; lineIdx++) {
1381
+ const rawLine = rawLines[lineIdx];
1037
1382
  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);
1383
+ const at = commentSite(comment, rawLine, lineIdx, line);
1384
+ const asPayload = asPayloadOf(line);
1385
+ if (asPayload !== null) {
1386
+ if (asPayload === "") {
1387
+ emitDirectiveDiag({
1388
+ code: DIAG3.directiveSyntax,
1389
+ message: `@nudo:as requires a type expression (e.g. @nudo:as number())`,
1390
+ at
1391
+ });
1392
+ continue;
1393
+ }
1394
+ const trailingIdx = findTrailingCommentStart(asPayload);
1042
1395
  if (trailingIdx !== -1) {
1043
- const typePart = rawExpr.slice(0, trailingIdx).trim();
1396
+ const typePart = asPayload.slice(0, trailingIdx).trim();
1044
1397
  emitDirectiveDiag({
1045
1398
  code: DIAG3.directiveSyntax,
1046
- message: `Trailing comment in @nudo:as type expression: '${rawExpr}' (remove the comment; the type must be a clean expression)`
1399
+ message: `Trailing comment in @nudo:as type expression: '${asPayload}' (remove the comment; the type must be a clean expression)`,
1400
+ at
1401
+ });
1402
+ results.push({
1403
+ kind: "as",
1404
+ typeAbs: typePart ? parseAbsExprDiag(unwrapOuterParens(typePart), "@nudo:as") : absUnknown()
1047
1405
  });
1048
- results.push({ kind: "as", typeAbs: typePart ? parseAbsExprDiag(typePart, "@nudo:as") : absUnknown() });
1049
1406
  continue;
1050
1407
  }
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
- });
1408
+ results.push({ kind: "as", typeAbs: parseAbsExprDiag(unwrapOuterParens(asPayload), "@nudo:as") });
1059
1409
  continue;
1060
1410
  }
1061
1411
  const replaceMatch = line.match(REPLACE_LINE_REGEX);
@@ -1073,7 +1423,8 @@ function collectInlineDirectivesFromComments(comments) {
1073
1423
  } else {
1074
1424
  emitDirectiveDiag({
1075
1425
  code: DIAG3.directiveSyntax,
1076
- message: `Malformed @nudo:replace: cannot separate target from type (expected @nudo:replace <target> <typeExpr>) \u2014 got: ${raw.slice(0, 60)}`
1426
+ message: `Malformed @nudo:replace: cannot separate target from type (expected @nudo:replace <target> <typeExpr>) \u2014 got: ${raw.slice(0, 60)}`,
1427
+ at
1077
1428
  });
1078
1429
  }
1079
1430
  continue;
@@ -1081,13 +1432,33 @@ function collectInlineDirectivesFromComments(comments) {
1081
1432
  if (REPLACE_BARE_RE.test(line)) {
1082
1433
  emitDirectiveDiag({
1083
1434
  code: DIAG3.directiveSyntax,
1084
- message: `@nudo:replace requires a target and type expression (expected @nudo:replace <target> <typeExpr>)`
1435
+ message: `@nudo:replace requires a target and type expression (expected @nudo:replace <target> <typeExpr>)`,
1436
+ at
1437
+ });
1438
+ continue;
1439
+ }
1440
+ if (REPLACE_GLUED_RE.test(line)) {
1441
+ emitDirectiveDiag({
1442
+ code: DIAG3.directiveSyntax,
1443
+ message: `Malformed @nudo:replace directive '${line.slice(0, 60)}' (expected a space after the tag: @nudo:replace <target> <typeExpr>)`,
1444
+ at
1085
1445
  });
1446
+ continue;
1086
1447
  }
1087
1448
  }
1088
1449
  }
1089
1450
  return results;
1090
1451
  }
1452
+ function commentSite(comment, rawLine, lineIdx, stripped) {
1453
+ const locStart = comment.loc?.start;
1454
+ if (!locStart) return void 0;
1455
+ const base = lineIdx === 0 ? locStart.column + 2 : 0;
1456
+ const inLine = stripped.length > 0 ? rawLine.indexOf(stripped) : 0;
1457
+ return {
1458
+ line: locStart.line + lineIdx,
1459
+ column: base + (inLine < 0 ? 0 : inLine)
1460
+ };
1461
+ }
1091
1462
  function findReplaceSeparator(raw) {
1092
1463
  let depth = 0;
1093
1464
  let inString = null;
@@ -1120,11 +1491,16 @@ function findReplaceSeparator(raw) {
1120
1491
  }
1121
1492
 
1122
1493
  // src/directives/file-directives.ts
1123
- import { parseEnvPayload, parseMockModulePayload } from "@nudojs/core";
1494
+ import {
1495
+ listFnDirectiveScopes as listFnDirectiveScopes2,
1496
+ parseEnvPayload,
1497
+ parseMockModulePayload
1498
+ } from "@nudojs/core";
1124
1499
  import { DIAG as DIAG4 } from "@nudojs/core/internal";
1125
1500
  var ENV_REGEX = /^\/?\s*@nudo:env\s+(.+)/;
1501
+ var ENV_BARE_REGEX = /^\/?\s*@nudo:env\b[ \t]*$/;
1126
1502
  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;
1503
+ var FILE_TAG_IN_BLOCK_REGEX = /(?:^|\n)[ \t]*(?:\*[ \t]*)?@nudo:(env|mock-module|import)\b/g;
1128
1504
  function warnMisplacedFileTags(blockText) {
1129
1505
  FILE_TAG_IN_BLOCK_REGEX.lastIndex = 0;
1130
1506
  let m;
@@ -1132,10 +1508,48 @@ function warnMisplacedFileTags(blockText) {
1132
1508
  const tag = m[1];
1133
1509
  emitDirectiveDiag({
1134
1510
  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)`
1511
+ 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
1512
  });
1137
1513
  }
1138
1514
  }
1515
+ function warnMisplacedFileTagLine(tag) {
1516
+ emitDirectiveDiag({
1517
+ code: DIAG4.directiveSyntax,
1518
+ 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)`
1519
+ });
1520
+ }
1521
+ function functionBodyRanges(ast) {
1522
+ if (ast.type !== "File") return [];
1523
+ return listFnDirectiveScopes2(ast).map((scope) => [
1524
+ scope.node.start ?? 0,
1525
+ scope.node.end ?? 0
1526
+ ]);
1527
+ }
1528
+ function nestedBodyRanges(ast) {
1529
+ const out = [];
1530
+ const visit = (node) => {
1531
+ if (!node || typeof node !== "object") return;
1532
+ if (Array.isArray(node)) {
1533
+ for (const item of node) visit(item);
1534
+ return;
1535
+ }
1536
+ const n = node;
1537
+ if ((n.type === "ClassBody" || n.type === "ObjectExpression" || n.type === "ArrayExpression" || n.type === "ObjectPattern" || n.type === "ArrayPattern") && typeof n.start === "number" && typeof n.end === "number") {
1538
+ out.push([n.start, n.end]);
1539
+ }
1540
+ for (const key of Object.keys(n)) {
1541
+ if (key === "loc" || key === "leadingComments" || key === "trailingComments" || key === "innerComments" || key === "comments" || key === "tokens" || key === "extra") {
1542
+ continue;
1543
+ }
1544
+ visit(n[key]);
1545
+ }
1546
+ };
1547
+ visit(ast);
1548
+ return out;
1549
+ }
1550
+ function inRanges(ranges, offset) {
1551
+ return ranges.some(([s, e]) => offset >= s && offset < e);
1552
+ }
1139
1553
  function extractFileDirectives(ast, opts) {
1140
1554
  if (!opts?.diags) return collectFileDirectives(ast);
1141
1555
  return runWithDirectiveDiags(opts.diags, () => collectFileDirectives(ast));
@@ -1144,6 +1558,8 @@ function collectFileDirectives(ast) {
1144
1558
  const results = [];
1145
1559
  if (ast.type !== "File") return results;
1146
1560
  const comments = ast.comments ?? [];
1561
+ const fnRanges = functionBodyRanges(ast);
1562
+ const bodyRanges = nestedBodyRanges(ast);
1147
1563
  for (const comment of comments) {
1148
1564
  if (comment.type === "CommentBlock") {
1149
1565
  warnMisplacedFileTags(comment.value);
@@ -1151,6 +1567,12 @@ function collectFileDirectives(ast) {
1151
1567
  }
1152
1568
  if (comment.type !== "CommentLine") continue;
1153
1569
  const text = comment.value;
1570
+ const misplaced = typeof comment.start === "number" && (inRanges(fnRanges, comment.start) || inRanges(bodyRanges, comment.start));
1571
+ if (misplaced) {
1572
+ if (MOCK_MODULE_REGEX.test(text)) warnMisplacedFileTagLine("mock-module");
1573
+ else if (ENV_REGEX.test(text) || ENV_BARE_REGEX.test(text)) warnMisplacedFileTagLine("env");
1574
+ continue;
1575
+ }
1154
1576
  const mockModuleMatch = text.match(MOCK_MODULE_REGEX);
1155
1577
  if (mockModuleMatch) {
1156
1578
  const rec = parseMockModulePayload(mockModuleMatch[1]);
@@ -1163,11 +1585,30 @@ function collectFileDirectives(ast) {
1163
1585
  });
1164
1586
  continue;
1165
1587
  }
1588
+ emitDirectiveDiag({
1589
+ code: DIAG4.directiveSyntax,
1590
+ message: `Unrecognized @nudo:mock-module form (expected '"src" from "path"' or '"src" { a, b } from "path"')`
1591
+ });
1592
+ continue;
1166
1593
  }
1167
1594
  const envMatch = text.match(ENV_REGEX);
1168
1595
  if (envMatch) {
1169
1596
  const envs = parseEnvPayload(envMatch[1]);
1170
- if (envs.length > 0) results.push({ kind: "env", envs });
1597
+ if (envs.length > 0) {
1598
+ results.push({ kind: "env", envs });
1599
+ } else {
1600
+ emitDirectiveDiag({
1601
+ code: DIAG4.directiveSyntax,
1602
+ message: `Unrecognized @nudo:env list (expected env names, e.g. '/// @nudo:env node, web')`
1603
+ });
1604
+ }
1605
+ continue;
1606
+ }
1607
+ if (ENV_BARE_REGEX.test(text)) {
1608
+ emitDirectiveDiag({
1609
+ code: DIAG4.directiveSyntax,
1610
+ message: `@nudo:env requires at least one env name (e.g. '/// @nudo:env node')`
1611
+ });
1171
1612
  }
1172
1613
  }
1173
1614
  return results;
@@ -1180,7 +1621,7 @@ import {
1180
1621
  extractNudoImportRecords,
1181
1622
  findFnDirectiveScope,
1182
1623
  fnDirectiveCommentLines,
1183
- listFnDirectiveScopes as listFnDirectiveScopes2,
1624
+ listFnDirectiveScopes as listFnDirectiveScopes3,
1184
1625
  parseEnvPayload as parseEnvPayload2,
1185
1626
  parseMockModulePayload as parseMockModulePayload2,
1186
1627
  parseNudoImportPayload,
@@ -1222,7 +1663,7 @@ export {
1222
1663
  getFirstDeclaratorId,
1223
1664
  identifierName,
1224
1665
  isModuleExportsMember,
1225
- listFnDirectiveScopes2 as listFnDirectiveScopes,
1666
+ listFnDirectiveScopes3 as listFnDirectiveScopes,
1226
1667
  memberObjectName,
1227
1668
  memberPropertyKey,
1228
1669
  memberPropertyName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nudojs/parser",
3
- "version": "1.5.0",
3
+ "version": "1.5.3",
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.0",
42
+ "@nudojs/core": "1.16.0",
43
43
  "@babel/types": "^8.0.6"
44
44
  },
45
45
  "scripts": {